From 45055c839bca93b1edec8313bf0b08687cd47f46 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Thu, 23 Jul 2026 21:56:14 -0700 Subject: [PATCH 1/2] Document Hermes-owned session titles --- CONTEXT.md | 4 ++++ docs/architecture/t3-agent.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 4a5a7736661..5a71c02daa0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,6 +30,10 @@ _Avoid_: Project thread, workspace, channel The durable Hermes-owned conversation context that the agent continues across turns. _Avoid_: T3 transcript, project +**Hermes session title**: +The Hermes-owned name of a session. A Hermes-backed T3 Agent thread displays this title as a local projection rather than owning an independent name. +_Avoid_: T3 thread name, synchronized title + **Cross-gateway conversation**: A Hermes session that originated from the CLI or another messaging surface rather than T3 Agent. _Avoid_: Synced thread diff --git a/docs/architecture/t3-agent.md b/docs/architecture/t3-agent.md index b79831dd825..cfd704bcd06 100644 --- a/docs/architecture/t3-agent.md +++ b/docs/architecture/t3-agent.md @@ -30,6 +30,10 @@ The compatibility project still exists internally because T3's persistence model A T3 Agent thread is backed by one Hermes session. New conversations create new Hermes sessions; a fork creates a new T3 thread and a child Hermes session. T3's stored transcript is the visible projection, while Hermes's session database remains the authoritative agent context. +The Hermes session title is likewise authoritative. A Hermes-backed T3 thread's stored title is only the sidebar projection of that value, not an independently generated name. While a new session is waiting for Hermes's automatic title, the sidebar may display the first user message as a provisional label. Every successful Hermes title change—including automatic naming and `/title`—replaces that provisional label and updates the projection. + +A sidebar rename first updates Hermes and changes the projection only after Hermes accepts it; rejection or unavailability leaves the displayed title unchanged and surfaces the error. T3's inherited first-turn title generator must not run for Hermes-backed threads. Live Hermes title callbacks are the normal update path, while startup and reconnect reconciliation repair missed callbacks; whenever the stored projection disagrees with a titled Hermes session, Hermes wins. + The interface must never silently switch the Hermes session underneath an existing T3 transcript. Lifecycle commands therefore require T3-native navigation semantics rather than unconditional command passthrough. ### Forking From af7734bba8391971c069d72f468243562492e6f6 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Thu, 23 Jul 2026 22:31:05 -0700 Subject: [PATCH 2/2] feat(t3-agent): sync Hermes session titles --- .../Layers/ProviderCommandReactor.test.ts | 41 ++++ .../Layers/ProviderCommandReactor.ts | 7 +- .../src/provider/Layers/HermesAdapter.test.ts | 36 ++++ .../src/provider/Layers/HermesAdapter.ts | 9 + .../hermes/HermesBridgeClient.test.ts | 40 ++++ .../src/provider/hermes/HermesBridgeClient.ts | 26 ++- .../provider/hermes/HermesBridgeRegistry.ts | 7 +- .../HermesConversationLifecycle.test.ts | 118 ++++++++++- .../hermes/HermesConversationLifecycle.ts | 129 +++++++++++- apps/server/src/ws.ts | 13 +- apps/web/src/components/Sidebar.tsx | 17 +- apps/web/src/components/SidebarV2.tsx | 13 +- apps/web/src/hooks/useRenameThreadTitle.ts | 30 +++ integrations/hermes/t3agent/adapter.py | 169 +++++++++++++++ .../hermes/t3agent/tests/test_adapter.py | 193 ++++++++++++++++++ packages/client-runtime/src/state/server.ts | 8 + packages/contracts/src/hermesBridge.test.ts | 57 ++++++ packages/contracts/src/hermesBridge.ts | 49 ++++- packages/contracts/src/rpc.ts | 10 + 19 files changed, 938 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/hooks/useRenameThreadTitle.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..e10383ebb88 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -637,6 +637,47 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); + it("leaves the provisional title for Hermes to replace", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "openai-codex::gpt-5.6-sol", + }, + }); + const seededTitle = "Please investigate reconnect failures after restar..."; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-hermes-title-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-hermes-first-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-hermes-title"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + expect(readModel.threads.find((entry) => entry.id === "thread-1")?.title).toBe(seededTitle); + }); + it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..e7a88f22a8e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -7,6 +7,7 @@ import { ProviderDriverKind, type ProjectId, type OrchestrationSession, + ProviderInstanceId, ThreadId, type ProviderSession, type RuntimeMode, @@ -87,6 +88,7 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +const HERMES_PROVIDER_INSTANCE_ID = ProviderInstanceId.make("hermes"); export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); @@ -816,7 +818,10 @@ const make = Effect.gen(function* () { ...generationInput, }).pipe(Effect.forkScoped); - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + if ( + thread.modelSelection.instanceId !== HERMES_PROVIDER_INSTANCE_ID && + canReplaceThreadTitle(thread.title, event.payload.titleSeed) + ) { yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, cwd: generationCwd, diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts index 1f84d22c33d..8727a09170f 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.test.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -71,6 +71,7 @@ const testLayer = Layer.effect( listSessions: Effect.die("not used by adapter tests"), forkSession: () => Effect.die("not used by adapter tests"), deleteSession: () => Effect.die("not used by adapter tests"), + updateSessionTitle: () => Effect.die("not used by adapter tests"), }; const adapter = yield* makeHermesAdapter({ instanceId: ProviderInstanceId.make("hermes-test"), @@ -268,6 +269,41 @@ it.layer(testLayer)("HermesAdapter", (it) => { }), ); + it.effect("projects Hermes session title callbacks as thread metadata", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-title-thread"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "title-callback-request", + deliveryId: "title-callback-delivery", + type: "session.title.updated", + chatId: "t3agent", + threadId, + sessionId: "session-title-thread", + title: "Renamed with /title", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepEqual(events[0], { + eventId: "hermes:title-callback-delivery:title", + provider: "hermes", + providerInstanceId: "hermes-test", + threadId, + createdAt: events[0]?.createdAt, + type: "thread.metadata.updated", + payload: { name: "Renamed with /title" }, + }); + }), + ); + it.effect("round-trips approval and clarification responses through the bridge", () => Effect.gen(function* () { const { adapter, sent } = yield* HermesAdapterTestHarness; diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts index ea13a70e975..beb210ca22d 100644 --- a/apps/server/src/provider/Layers/HermesAdapter.ts +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -615,6 +615,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* ( }; break; } + case "session.title.updated": { + const base = yield* eventBase(callback, threadId, "title"); + yield* publish({ + ...base, + type: "thread.metadata.updated", + payload: { name: callback.title }, + }); + break; + } case "approval.request": yield* rememberApproval(callback, threadId, context); break; diff --git a/apps/server/src/provider/hermes/HermesBridgeClient.test.ts b/apps/server/src/provider/hermes/HermesBridgeClient.test.ts index 6663741c86a..c496b7bb91e 100644 --- a/apps/server/src/provider/hermes/HermesBridgeClient.test.ts +++ b/apps/server/src/provider/hermes/HermesBridgeClient.test.ts @@ -166,6 +166,45 @@ describe("HermesBridgeClient", () => { }); }); + it.effect("updates a Hermes session title through the dedicated endpoint", () => { + const targetThreadId = ThreadId.make("00000000-0000-4000-8000-000000000003"); + const { client, execute } = makeClient(() => + Response.json({ + protocolVersion: 1, + requestId: "title-request", + status: "accepted", + title: "Canonical Hermes title", + }), + ); + + return Effect.gen(function* () { + const result = yield* client.updateSessionTitle({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make("title-request"), + type: "session.title.update", + sessionId: HermesBridgeSessionId.make("session-1"), + targetThreadId, + title: " Canonical Hermes title ", + }); + + assert.strictEqual(result.title, "Canonical Hermes title"); + const call = execute.mock.calls[0]; + assert.ok(call); + const [request] = call; + assert.strictEqual(request.method, "POST"); + assert.strictEqual(new URL(request.url).pathname, "/v1/sessions/title"); + assert.strictEqual(request.headers["idempotency-key"], "title-request"); + assert.deepStrictEqual(jsonBody(request), { + protocolVersion: 1, + requestId: "title-request", + type: "session.title.update", + sessionId: "session-1", + targetThreadId, + title: " Canonical Hermes title ", + }); + }); + }); + it.effect("deletes only the child session correlated to its T3 thread", () => { const targetThreadId = ThreadId.make("00000000-0000-4000-8000-000000000002"); const sessionId = HermesBridgeSessionId.make("t3-child"); @@ -223,6 +262,7 @@ describe("HermesBridgeClient", () => { protocolVersion: 1, requestId: "request-interrupt", type: "turn.interrupt", + chatId: "t3agent", threadId: "thread-1", }), ], diff --git a/apps/server/src/provider/hermes/HermesBridgeClient.ts b/apps/server/src/provider/hermes/HermesBridgeClient.ts index 9ea2a27ddaf..62f49c620c3 100644 --- a/apps/server/src/provider/hermes/HermesBridgeClient.ts +++ b/apps/server/src/provider/hermes/HermesBridgeClient.ts @@ -6,6 +6,8 @@ import { HermesBridgeSessionForkRequest, HermesBridgeSessionForkResponse, HermesBridgeSessionListResponse, + HermesBridgeSessionTitleUpdateRequest, + HermesBridgeSessionTitleUpdateResponse, type HermesBridgeT3ToHermesRequest, } from "@t3tools/contracts/hermesBridge"; import * as Effect from "effect/Effect"; @@ -33,6 +35,9 @@ export interface HermesBridgeClient { readonly deleteSession: ( request: HermesBridgeSessionDeleteRequest, ) => Effect.Effect; + readonly updateSessionTitle: ( + request: HermesBridgeSessionTitleUpdateRequest, + ) => Effect.Effect; } function requestPath(request: HermesBridgeT3ToHermesRequest): string { @@ -127,6 +132,18 @@ export function makeHermesBridgeClient(input: { ), ); + const updateSessionTitle = Effect.fn("HermesBridgeClient.updateSessionTitle")( + (request: HermesBridgeSessionTitleUpdateRequest) => + HttpClientRequest.post(`${baseUrl}/v1/sessions/title`).pipe( + authorize, + HttpClientRequest.setHeader("idempotency-key", request.requestId), + HttpClientRequest.bodyJsonUnsafe(request), + execute.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(HermesBridgeSessionTitleUpdateResponse)), + Effect.mapError(mapRequestError("session.title.update")), + ), + ); + const send: HermesBridgeClient["send"] = (request) => HttpClientRequest.post(`${baseUrl}${requestPath(request)}`).pipe( authorize, @@ -137,5 +154,12 @@ export function makeHermesBridgeClient(input: { Effect.mapError(mapRequestError(request.type)), ); - return { getCapabilities, listSessions, forkSession, deleteSession, send }; + return { + getCapabilities, + listSessions, + forkSession, + deleteSession, + updateSessionTitle, + send, + }; } diff --git a/apps/server/src/provider/hermes/HermesBridgeRegistry.ts b/apps/server/src/provider/hermes/HermesBridgeRegistry.ts index c059e993f28..5b1e7cb89e0 100644 --- a/apps/server/src/provider/hermes/HermesBridgeRegistry.ts +++ b/apps/server/src/provider/hermes/HermesBridgeRegistry.ts @@ -17,7 +17,10 @@ export class HermesBridgeRegistryError extends Schema.TaggedErrorClass Effect.Effect; - readonly client?: Pick; + readonly client?: Pick< + HermesBridgeClient, + "listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle" + >; } export interface HermesBridgeRegistration { @@ -60,7 +63,7 @@ export const unregister = Effect.fn("HermesBridgeRegistry.unregister")(function* export const getClient = Effect.fn("HermesBridgeRegistry.getClient")(function* ( instanceId: ProviderInstanceId, ): Effect.fn.Return< - Pick, + Pick, HermesBridgeRegistryError > { const receiver = registrations.get(instanceId)?.receiver; diff --git a/apps/server/src/provider/hermes/HermesConversationLifecycle.test.ts b/apps/server/src/provider/hermes/HermesConversationLifecycle.test.ts index 411dbfb37f5..c2c49ddb4ac 100644 --- a/apps/server/src/provider/hermes/HermesConversationLifecycle.test.ts +++ b/apps/server/src/provider/hermes/HermesConversationLifecycle.test.ts @@ -24,6 +24,7 @@ const NOW = "2026-07-23T10:00:00.000Z"; const PROJECT_ID = ProjectId.make("project"); const SOURCE_SESSION_ID = HermesBridgeSessionId.make("discord-source"); const CHILD_SESSION_ID = HermesBridgeSessionId.make("t3-child"); +const T3_SOURCE_SESSION_ID = HermesBridgeSessionId.make("t3-source"); const SOURCE_THREAD_ID = ThreadId.make("source-thread"); const LIVE_IMPORTED_THREAD_ID = ThreadId.make("live-import"); const DELETED_IMPORTED_THREAD_ID = ThreadId.make("deleted-import"); @@ -41,7 +42,7 @@ const sessionList = { importedThreadIds: [LIVE_IMPORTED_THREAD_ID, DELETED_IMPORTED_THREAD_ID], }, { - sessionId: HermesBridgeSessionId.make("t3-source"), + sessionId: T3_SOURCE_SESSION_ID, source: "t3agent", title: "Native conversation", threadId: SOURCE_THREAD_ID, @@ -68,6 +69,7 @@ function makeHarness(input: { readonly sessions?: HermesBridgeSessionListResponse; readonly failureTarget?: FailureTarget; readonly liveImportedThread?: boolean; + readonly titleUpdateRejection?: string; }) { const commands: Array = []; const createdThreadIds: Array = []; @@ -115,8 +117,28 @@ function makeHarness(input: { status: "accepted", }), ); + const updateSessionTitle = vi.fn((request) => + Effect.succeed( + input.titleUpdateRejection + ? { + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: request.requestId, + status: "rejected" as const, + message: input.titleUpdateRejection, + } + : { + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: request.requestId, + status: "accepted" as const, + title: request.title.trim(), + }, + ), + ); const baseSessions = input.sessions ?? sessionList; - const client: Pick = { + const client: Pick< + HermesBridgeClient, + "listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle" + > = { listSessions: Effect.sync(() => ({ ...baseSessions, sessions: baseSessions.sessions.map((session) => @@ -130,6 +152,7 @@ function makeHarness(input: { })), forkSession, deleteSession, + updateSessionTitle, }; const dispatch: HermesConversationLifecycleDependencies["dispatch"] = (command) => { commands.push(command); @@ -158,10 +181,11 @@ function makeHarness(input: { threads: [ { id: LIVE_IMPORTED_THREAD_ID, + title: "Imported conversation", deletedAt: input.liveImportedThread === false ? NOW : null, }, - { id: DELETED_IMPORTED_THREAD_ID, deletedAt: NOW }, - { id: SOURCE_THREAD_ID, deletedAt: null }, + { id: DELETED_IMPORTED_THREAD_ID, title: "Deleted", deletedAt: NOW }, + { id: SOURCE_THREAD_ID, title: "Provisional title", deletedAt: null }, ...createdThreadIds.map((id) => ({ id, deletedAt: null })), ], }), @@ -180,6 +204,7 @@ function makeHarness(input: { commands, forkSession, deleteSession, + updateSessionTitle, }; } @@ -223,12 +248,95 @@ function assertCompensates(failureTarget: FailureTarget) { describe("HermesConversationLifecycle", () => { it.effect("lists sessions without stale T3 thread links", () => { - const { lifecycle } = makeHarness({}); + const { lifecycle, commands } = makeHarness({}); return Effect.gen(function* () { const result = yield* lifecycle.listSessions; assert.deepStrictEqual(result.sessions[0]?.importedThreadIds, [LIVE_IMPORTED_THREAD_ID]); assert.strictEqual(result.sessions[1]?.threadId, SOURCE_THREAD_ID); + const titleUpdate = commands.find( + (command) => command.type === "thread.meta.update" && command.threadId === SOURCE_THREAD_ID, + ); + assert.strictEqual(titleUpdate?.type, "thread.meta.update"); + if (titleUpdate?.type === "thread.meta.update") { + assert.strictEqual(titleUpdate.title, "Native conversation"); + } + }); + }); + + it.effect("renames Hermes before updating the local projection", () => { + const { lifecycle, commands, updateSessionTitle } = makeHarness({}); + return Effect.gen(function* () { + const result = yield* lifecycle.renameConversation({ + threadId: SOURCE_THREAD_ID, + title: " Sidebar rename ", + }); + + assert.deepStrictEqual(result, { + threadId: SOURCE_THREAD_ID, + title: "Sidebar rename", + }); + assert.strictEqual(updateSessionTitle.mock.calls.length, 1); + assert.deepInclude(updateSessionTitle.mock.calls[0]?.[0], { + type: "session.title.update", + sessionId: T3_SOURCE_SESSION_ID, + targetThreadId: SOURCE_THREAD_ID, + }); + assert.deepInclude(commands.at(-1), { + type: "thread.meta.update", + threadId: SOURCE_THREAD_ID, + title: "Sidebar rename", + }); + }); + }); + + it.effect("preserves the local projection when Hermes rejects a rename", () => { + const { lifecycle, commands } = makeHarness({ + titleUpdateRejection: "Title is already in use.", + }); + return Effect.gen(function* () { + const result = yield* Effect.result( + lifecycle.renameConversation({ + threadId: SOURCE_THREAD_ID, + title: "Duplicate", + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isSuccess(result)) return; + assert.strictEqual(result.failure.operation, "conversation.rename"); + assert.match(result.failure.message, /already in use/); + assert.lengthOf(commands, 0); + }); + }); + + it.effect("reconciles titled Hermes sessions and ignores missing titles", () => { + const sessions = { + ...sessionList, + sessions: [ + sessionList.sessions[0]!, + sessionList.sessions[1]!, + { + sessionId: HermesBridgeSessionId.make("untitled"), + source: "t3agent", + threadId: LIVE_IMPORTED_THREAD_ID, + startedAt: NOW, + messageCount: 1, + }, + ], + } satisfies HermesBridgeSessionListResponse; + const { lifecycle, commands } = makeHarness({ sessions }); + return Effect.gen(function* () { + yield* lifecycle.reconcileTitles; + + assert.deepStrictEqual( + commands.map((command) => + command.type === "thread.meta.update" + ? { threadId: command.threadId, title: command.title } + : { type: command.type }, + ), + [{ threadId: SOURCE_THREAD_ID, title: "Native conversation" }], + ); }); }); diff --git a/apps/server/src/provider/hermes/HermesConversationLifecycle.ts b/apps/server/src/provider/hermes/HermesConversationLifecycle.ts index 5b79efa2901..ca85a9747af 100644 --- a/apps/server/src/provider/hermes/HermesConversationLifecycle.ts +++ b/apps/server/src/provider/hermes/HermesConversationLifecycle.ts @@ -12,6 +12,8 @@ import { type HermesBridgeSessionListResponse, type HermesConversationForkInput, type HermesConversationForkResult, + type HermesConversationRenameInput, + type HermesConversationRenameResult, type OrchestrationCommand, type ProjectId, } from "@t3tools/contracts"; @@ -48,7 +50,10 @@ interface LifecycleProvider { export interface HermesConversationLifecycleDependencies { readonly getClient: () => Effect.Effect< - Pick, + Pick< + HermesBridgeClient, + "listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle" + >, Error >; readonly getSnapshot: () => Effect.Effect; @@ -63,6 +68,10 @@ export interface HermesConversationLifecycle { readonly forkConversation: ( input: HermesConversationForkInput, ) => Effect.Effect; + readonly renameConversation: ( + input: HermesConversationRenameInput, + ) => Effect.Effect; + readonly reconcileTitles: Effect.Effect; } type LifecycleOperation = HermesLifecycleError["operation"]; @@ -101,6 +110,7 @@ export function makeHermesConversationLifecycle( dependencies: HermesConversationLifecycleDependencies, ): HermesConversationLifecycle { const forkLock = Semaphore.makeUnsafe(1); + const renameLock = Semaphore.makeUnsafe(1); const nextCommandId = Effect.fn("HermesConversationLifecycle.nextCommandId")(function* ( tag: string, ) { @@ -120,12 +130,38 @@ export function makeHermesConversationLifecycle( .pipe(Effect.ignoreCause({ log: true })); }); + const reconcileTitleProjection = Effect.fn( + "HermesConversationLifecycle.reconcileTitleProjection", + )(function* (sessions: HermesBridgeSessionListResponse, snapshot: LifecycleSnapshot) { + const liveThreads = new Map( + snapshot.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => [thread.id, thread] as const), + ); + yield* Effect.forEach( + sessions.sessions, + Effect.fnUntraced(function* (session) { + if (session.threadId === undefined || session.title === undefined) return; + const thread = liveThreads.get(session.threadId); + if (thread === undefined || thread.title === session.title) return; + yield* dependencies.dispatch({ + type: "thread.meta.update", + commandId: yield* nextCommandId("hermes-title-reconcile"), + threadId: session.threadId, + title: session.title, + }); + }), + { concurrency: 1, discard: true }, + ); + }); + const listSessions = Effect.gen(function* () { const client = yield* dependencies.getClient(); const [response, snapshot] = yield* Effect.all([ client.listSessions, dependencies.getSnapshot(), ]); + yield* reconcileTitleProjection(response, snapshot); const liveThreadIds = new Set( snapshot.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), ); @@ -153,6 +189,95 @@ export function makeHermesConversationLifecycle( ), ); + const reconcileTitles = Effect.gen(function* () { + const client = yield* dependencies.getClient(); + const [sessions, snapshot] = yield* Effect.all([ + client.listSessions, + dependencies.getSnapshot(), + ]); + yield* reconcileTitleProjection(sessions, snapshot); + }).pipe( + Effect.withSpan("HermesConversationLifecycle.reconcileTitles"), + Effect.mapError((cause) => + lifecycleError("titles.reconcile", cause, "Unable to reconcile Hermes session titles."), + ), + ); + + const renameConversation = Effect.fn("HermesConversationLifecycle.renameConversation")( + function* (input: HermesConversationRenameInput) { + const client = yield* dependencies.getClient(); + const [sessions, snapshot] = yield* Effect.all([ + client.listSessions, + dependencies.getSnapshot(), + ]); + const thread = snapshot.threads.find( + (candidate) => candidate.id === input.threadId && candidate.deletedAt === null, + ); + if (thread === undefined) { + return yield* new HermesLifecycleError({ + operation: "conversation.rename", + message: "The T3 Agent conversation is no longer available.", + sourceThreadId: input.threadId, + }); + } + const session = sessions.sessions.find((candidate) => candidate.threadId === input.threadId); + if (session === undefined) { + return yield* new HermesLifecycleError({ + operation: "conversation.rename", + message: "The Hermes session is not available yet.", + sourceThreadId: input.threadId, + }); + } + + const response = yield* client.updateSessionTitle({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make( + `conversation-rename:${session.sessionId}:${yield* dependencies.randomUuid}`, + ), + type: "session.title.update", + sessionId: session.sessionId, + targetThreadId: input.threadId, + title: input.title, + }); + if (response.status === "rejected") { + return yield* new HermesLifecycleError({ + operation: "conversation.rename", + message: response.message ?? "Hermes rejected the session title.", + sourceSessionId: session.sessionId, + sourceThreadId: input.threadId, + }); + } + if (response.title === undefined) { + return yield* new HermesLifecycleError({ + operation: "conversation.rename", + message: "Hermes accepted the rename without returning the session title.", + sourceSessionId: session.sessionId, + sourceThreadId: input.threadId, + }); + } + if (thread.title !== response.title) { + yield* dependencies.dispatch({ + type: "thread.meta.update", + commandId: yield* nextCommandId("hermes-conversation-rename"), + threadId: input.threadId, + title: response.title, + }); + } + return { + threadId: input.threadId, + title: response.title, + }; + }, + (effect) => + renameLock + .withPermit(effect) + .pipe( + Effect.mapError((cause) => + lifecycleError("conversation.rename", cause, "Unable to rename the Hermes session."), + ), + ), + ); + const forkConversation = Effect.fn("HermesConversationLifecycle.forkConversation")( function* (input: HermesConversationForkInput) { const client = yield* dependencies.getClient(); @@ -346,5 +471,5 @@ export function makeHermesConversationLifecycle( ), ); - return { listSessions, forkConversation }; + return { listSessions, forkConversation, renameConversation, reconcileTitles }; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 11bf0bb12ab..56b711fba87 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -312,6 +312,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], [WS_METHODS.hermesSessionsList, AuthOrchestrationReadScope], [WS_METHODS.hermesConversationFork, AuthOrchestrationOperateScope], + [WS_METHODS.hermesConversationRename, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -1581,6 +1582,12 @@ const makeWsRpcLayer = ( hermesConversationLifecycle.forkConversation(input), { "rpc.aggregate": "hermes" }, ), + [WS_METHODS.hermesConversationRename]: (input) => + observeRpcEffect( + WS_METHODS.hermesConversationRename, + hermesConversationLifecycle.renameConversation(input), + { "rpc.aggregate": "hermes" }, + ), [WS_METHODS.cloudGetRelayClientStatus]: (_input) => observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { "rpc.aggregate": "cloud", @@ -2039,7 +2046,11 @@ const makeWsRpcLayer = ( yield* providerRegistry .refresh() - .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + .pipe( + Effect.andThen(hermesConversationLifecycle.reconcileTitles), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); const liveUpdates = Stream.merge( keybindingsUpdates, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fbac36975d4..05b743504d8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -107,12 +107,13 @@ import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useRenameThreadTitle } from "../hooks/useRenameThreadTitle"; import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; -import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { @@ -1113,9 +1114,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); + const renameThreadTitle = useRenameThreadTitle(); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -2060,13 +2059,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec finishRename(); return; } - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - title: trimmed, - }, - }); + const result = await renameThreadTitle(threadRef, trimmed); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2079,7 +2072,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [updateThreadMetadata], + [renameThreadTitle], ); const closeProjectRenameDialog = useCallback(() => { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 03766c3dcaf..76975e5d390 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -74,6 +74,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useRenameThreadTitle } from "../hooks/useRenameThreadTitle"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; @@ -82,7 +83,6 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments" import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; -import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; @@ -833,9 +833,7 @@ export default function SidebarV2() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, unsettleThread, deleteThread } = useThreadActions(); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); + const renameThreadTitle = useRenameThreadTitle(); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); @@ -1344,10 +1342,7 @@ export default function SidebarV2() { return; } if (trimmed === originalTitle) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, title: trimmed }, - }); + const result = await renameThreadTitle(threadRef, trimmed); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1360,7 +1355,7 @@ export default function SidebarV2() { } })(); }, - [updateThreadMetadata], + [renameThreadTitle], ); const handleThreadClick = useCallback( diff --git a/apps/web/src/hooks/useRenameThreadTitle.ts b/apps/web/src/hooks/useRenameThreadTitle.ts new file mode 100644 index 00000000000..0794d94a68e --- /dev/null +++ b/apps/web/src/hooks/useRenameThreadTitle.ts @@ -0,0 +1,30 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { IS_T3_AGENT_MODE } from "../productMode"; +import { serverEnvironment } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; + +export function useRenameThreadTitle() { + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const renameHermesConversation = useAtomCommand(serverEnvironment.hermesConversationRename, { + reportFailure: false, + }); + + return useCallback( + (threadRef: ScopedThreadRef, title: string) => + IS_T3_AGENT_MODE + ? renameHermesConversation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title }, + }) + : updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title }, + }), + [renameHermesConversation, updateThreadMetadata], + ); +} diff --git a/integrations/hermes/t3agent/adapter.py b/integrations/hermes/t3agent/adapter.py index 6f5b4d7fd90..dce524fc2d9 100644 --- a/integrations/hermes/t3agent/adapter.py +++ b/integrations/hermes/t3agent/adapter.py @@ -61,6 +61,8 @@ MAX_IMAGE_BYTES = 10 * 1_048_576 DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_IDEMPOTENCY_CACHE_SIZE = 2_048 +TITLE_WATCH_INTERVAL_SECONDS = 0.25 +TITLE_WATCH_TIMEOUT_SECONDS = 90.0 CANONICAL_CHAT_ID = "t3agent" _INGRESS_TOKEN_ENV = "T3_AGENT_INGRESS_TOKEN" @@ -648,6 +650,8 @@ def __init__(self, config: PlatformConfig): self._completion_outbox_wakeup = asyncio.Event() self._completion_outbox_task: Optional[asyncio.Task] = None self._completion_outbox_stopping = False + self._title_watch_tasks: Dict[str, asyncio.Task] = {} + self._published_titles: Dict[str, str] = {} @staticmethod def _destination_key( @@ -912,6 +916,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: app.router.add_get("/v1/sessions", self._list_sessions) app.router.add_post("/v1/sessions/fork", self._fork_session) app.router.add_post("/v1/sessions/delete", self._delete_session) + app.router.add_post("/v1/sessions/title", self._update_session_title) app.router.add_post("/v1/messages", self._submit_message) app.router.add_post("/v1/interrupt", self._interrupt_turn) app.router.add_post("/v1/approvals", self._respond_approval) @@ -942,6 +947,12 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: return True async def disconnect(self) -> None: + title_watch_tasks = list(self._title_watch_tasks.values()) + self._title_watch_tasks.clear() + for task in title_watch_tasks: + task.cancel() + if title_watch_tasks: + await asyncio.gather(*title_watch_tasks, return_exceptions=True) self._completion_outbox_stopping = True self._completion_outbox_wakeup.set() if self._completion_outbox_task is not None: @@ -1445,6 +1456,77 @@ async def delete() -> Tuple[int, Dict[str, Any]]: return await self._run_once(request_id, delete) + async def _update_session_title(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await self._read_frame(request, "session.title.update") + request_id = _require_string(payload, "requestId") + session_id = _require_string(payload, "sessionId") + target_thread_id = _require_string(payload, "targetThreadId") + title = _require_string(payload, "title") + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + + async def update() -> Tuple[int, Dict[str, Any]]: + gateway_runner = getattr(self, "gateway_runner", None) + session_db, _, db = _gateway_session_resources(gateway_runner) + if session_db is None or db is None: + raise RuntimeError("Hermes session database is unavailable") + session = await asyncio.to_thread(db.get_session, session_id) + if not session: + return 200, { + **_ack( + request_id, + "rejected", + "The Hermes session is no longer available.", + ) + } + source = str(session.get("source") or "").strip() + thread_id = str(session.get("thread_id") or "").strip() + stored_target_thread_id = str( + _stored_model_config(session).get("_t3agent_target_thread") or "" + ).strip() + if ( + source != "t3agent" + or target_thread_id not in {thread_id, stored_target_thread_id} + ): + return 200, { + **_ack( + request_id, + "rejected", + "The Hermes session is not owned by this T3 Agent thread.", + ) + } + try: + updated = await session_db.set_session_title(session_id, title) + except ValueError as exc: + return 200, _ack(request_id, "rejected", str(exc)) + if updated is False: + return 200, _ack( + request_id, + "rejected", + "The Hermes session is no longer available.", + ) + persisted = await asyncio.to_thread(db.get_session_title, session_id) + canonical_title = str(persisted or "").strip() + if not canonical_title: + raise RuntimeError("Hermes accepted the title without persisting it") + await self._publish_session_title( + chat_id=CANONICAL_CHAT_ID, + thread_id=target_thread_id, + session_id=session_id, + title=canonical_title, + ) + return 200, { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "status": "accepted", + "title": canonical_title, + } + + return await self._run_once(request_id, update) + async def _submit_message(self, request: web.Request) -> web.Response: if not self._authorized(request): return self._unauthorized() @@ -1913,6 +1995,91 @@ async def send_typing( fields["sourceMessageId"] = source_message_id await self._post_event("typing.set", fields, metadata=metadata) + async def _publish_session_title( + self, + *, + chat_id: str, + thread_id: str, + session_id: str, + title: str, + delivery_id: Optional[str] = None, + ) -> bool: + if self._published_titles.get(session_id) == title: + return True + occurrence_id = delivery_id or f"title_{secrets.token_hex(16)}" + ok, _, _ = await self._post_event( + "session.title.updated", + { + "chatId": chat_id, + "threadId": thread_id, + "sessionId": session_id, + "title": title, + }, + metadata={"deliveryId": occurrence_id}, + ) + if ok: + self._published_titles[session_id] = title + return ok + + async def _watch_session_title(self, source: Any) -> None: + gateway_runner = getattr(self, "gateway_runner", None) + _, async_store, db = _gateway_session_resources(gateway_runner) + thread_id = str(getattr(source, "thread_id", None) or "").strip() + if async_store is None or db is None or not thread_id: + return + try: + session_entry = await async_store.get_or_create_session(source) + session_id = str(getattr(session_entry, "session_id", "") or "").strip() + except Exception: + logger.debug("[t3agent] unable to resolve session for title watch", exc_info=True) + return + if not session_id: + return + + loop = asyncio.get_running_loop() + deadline = loop.time() + TITLE_WATCH_TIMEOUT_SECONDS + delivery_id: Optional[str] = None + observed_title: Optional[str] = None + while loop.time() < deadline: + try: + current = await asyncio.to_thread(db.get_session_title, session_id) + except Exception: + logger.debug("[t3agent] unable to read watched session title", exc_info=True) + return + title = str(current or "").strip() + if title and self._published_titles.get(session_id) != title: + if title != observed_title: + observed_title = title + delivery_id = f"title_{secrets.token_hex(16)}" + if await self._publish_session_title( + chat_id=str(source.chat_id), + thread_id=thread_id, + session_id=session_id, + title=title, + delivery_id=delivery_id, + ): + return + await asyncio.sleep(TITLE_WATCH_INTERVAL_SECONDS) + + def _schedule_session_title_watch(self, source: Any) -> None: + thread_id = str(getattr(source, "thread_id", None) or "").strip() + if not thread_id: + return + existing = self._title_watch_tasks.get(thread_id) + if existing is not None and not existing.done(): + return + task = asyncio.create_task( + self._watch_session_title(source), + name=f"t3agent-title-watch:{thread_id}", + ) + self._title_watch_tasks[thread_id] = task + + def forget(completed: asyncio.Task) -> None: + if self._title_watch_tasks.get(thread_id) is completed: + self._title_watch_tasks.pop(thread_id, None) + + task.add_done_callback(forget) + async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None: """Signal the true end of a Hermes turn after all response delivery. @@ -1934,6 +2101,8 @@ async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> Non if thread_id: fields["threadId"] = str(thread_id) await self._enqueue_turn_completion(fields) + if outcome_value == "success": + self._schedule_session_title_watch(source) processing_key = self._destination_key( source.chat_id, {"thread_id": thread_id} ) diff --git a/integrations/hermes/t3agent/tests/test_adapter.py b/integrations/hermes/t3agent/tests/test_adapter.py index 3f06d70bdb1..f03869144ba 100644 --- a/integrations/hermes/t3agent/tests/test_adapter.py +++ b/integrations/hermes/t3agent/tests/test_adapter.py @@ -37,6 +37,19 @@ def fake_platform(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: return platform +@pytest.fixture(autouse=True) +def isolate_t3agent_environment(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "T3_AGENT_INGRESS_TOKEN", + "T3_AGENT_BRIDGE_TOKEN", + "T3_AGENT_BRIDGE_URL", + "T3_AGENT_INSTANCE_ID", + "T3_AGENT_OUTBOX_PATH", + "T3_AGENT_INGRESS_LEDGER_PATH", + ): + monkeypatch.delenv(name, raising=False) + + async def make_ingress_client( adapter: adapter_module.T3AgentAdapter, ) -> TestClient: @@ -46,6 +59,7 @@ async def make_ingress_client( app.router.add_get("/v1/sessions", adapter._list_sessions) app.router.add_post("/v1/sessions/fork", adapter._fork_session) app.router.add_post("/v1/sessions/delete", adapter._delete_session) + app.router.add_post("/v1/sessions/title", adapter._update_session_title) app.router.add_post("/v1/messages", adapter._submit_message) app.router.add_post("/v1/interrupt", adapter._interrupt_turn) app.router.add_post("/v1/approvals", adapter._respond_approval) @@ -502,6 +516,185 @@ def delete_session(self, session_id: str) -> bool: await client.close() +@pytest.mark.asyncio +async def test_session_title_update_uses_hermes_validation_and_returns_canonical_title( + fake_platform: SimpleNamespace, +) -> None: + adapter = adapter_module.T3AgentAdapter(make_config()) + + class FakeDB: + title = "Original" + + def get_session(self, session_id: str) -> Dict[str, Any]: + assert session_id == "session-1" + return { + "id": session_id, + "source": "t3agent", + "thread_id": "thread-1", + } + + def get_session_title(self, session_id: str) -> str: + assert session_id == "session-1" + return self.title + + db = FakeDB() + + class FakeAsyncDB: + _db = db + + async def set_session_title(self, session_id: str, title: str) -> bool: + assert session_id == "session-1" + db.title = " ".join(title.split()) + return True + + adapter.gateway_runner = SimpleNamespace(_session_db=FakeAsyncDB()) + client = await make_ingress_client(adapter) + frame = { + "protocolVersion": 1, + "requestId": "rename-session", + "type": "session.title.update", + "sessionId": "session-1", + "targetThreadId": "thread-1", + "title": " Canonical Hermes title ", + } + try: + first = await client.post( + "/v1/sessions/title", + headers=auth_headers(), + json=frame, + ) + duplicate = await client.post( + "/v1/sessions/title", + headers=auth_headers(), + json=frame, + ) + + assert first.status == duplicate.status == 200 + assert await first.json() == { + "protocolVersion": 1, + "requestId": "rename-session", + "status": "accepted", + "title": "Canonical Hermes title", + } + assert (await duplicate.json())["status"] == "duplicate" + assert db.title == "Canonical Hermes title" + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_session_title_update_surfaces_hermes_rejection( + fake_platform: SimpleNamespace, +) -> None: + adapter = adapter_module.T3AgentAdapter(make_config()) + + class FakeDB: + def get_session(self, _: str) -> Dict[str, Any]: + return { + "id": "session-1", + "source": "t3agent", + "thread_id": "thread-1", + } + + class FakeAsyncDB: + _db = FakeDB() + + async def set_session_title(self, _: str, title: str) -> bool: + raise ValueError(f"Title '{title}' is already in use") + + adapter.gateway_runner = SimpleNamespace(_session_db=FakeAsyncDB()) + client = await make_ingress_client(adapter) + try: + response = await client.post( + "/v1/sessions/title", + headers=auth_headers(), + json={ + "protocolVersion": 1, + "requestId": "rename-duplicate", + "type": "session.title.update", + "sessionId": "session-1", + "targetThreadId": "thread-1", + "title": "Duplicate", + }, + ) + + assert response.status == 200 + body = await response.json() + assert body["status"] == "rejected" + assert "already in use" in body["message"] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_processing_completion_publishes_the_hermes_session_title( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter( + make_config(bridge_url=str(server.make_url("/"))) + ) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + + class FakeDB: + def get_session_title(self, session_id: str) -> str: + assert session_id == "session-1" + return "Hermes automatic title" + + class FakeStore: + async def get_or_create_session(self, _: Any) -> Any: + return SimpleNamespace(session_id="session-1") + + adapter.gateway_runner = SimpleNamespace( + _session_db=SimpleNamespace(_db=FakeDB()), + async_session_store=FakeStore(), + ) + event = SimpleNamespace( + source=SimpleNamespace(chat_id="t3agent", thread_id="thread-1"), + message_id="hermes-user:title-turn", + ) + try: + await adapter.on_processing_complete( + event, + SimpleNamespace(value="success"), + ) + await wait_until( + lambda: any( + frame["type"] == "session.title.updated" for frame in received + ) + ) + + title_event = next( + frame + for frame in received + if frame["type"] == "session.title.updated" + ) + assert title_event["threadId"] == "thread-1" + assert title_event["sessionId"] == "session-1" + assert title_event["title"] == "Hermes automatic title" + finally: + await adapter.disconnect() + await server.close() + + @pytest.mark.asyncio async def test_ingress_idempotency_survives_adapter_restart( fake_platform: SimpleNamespace, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 351955a01b0..cecc56ac175 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -331,6 +331,14 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + hermesConversationRename: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:hermes-conversation-rename", + tag: WS_METHODS.hermesConversationRename, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => `${environmentId}:${input.threadId}`, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/hermesBridge.test.ts b/packages/contracts/src/hermesBridge.test.ts index 1049b99525a..28b1cfe7661 100644 --- a/packages/contracts/src/hermesBridge.test.ts +++ b/packages/contracts/src/hermesBridge.test.ts @@ -13,6 +13,9 @@ import { HermesBridgeSlashConfirmationRequest, HermesBridgeSlashConfirmationResponseRequest, HermesBridgeSessionDeleteRequest, + HermesBridgeSessionTitleUpdatedRequest, + HermesBridgeSessionTitleUpdateRequest, + HermesBridgeSessionTitleUpdateResponse, HermesBridgeT3ToHermesRequest, HermesBridgeThreadCreateRequest, HermesBridgeThreadCreateResponse, @@ -40,6 +43,15 @@ const decodeThreadCreateRequest = Schema.decodeUnknownSync(HermesBridgeThreadCre const decodeThreadCreateResponse = Schema.decodeUnknownSync(HermesBridgeThreadCreateResponse); const decodeConversationForkInput = Schema.decodeUnknownSync(HermesConversationForkInput); const decodeSessionDeleteRequest = Schema.decodeUnknownSync(HermesBridgeSessionDeleteRequest); +const decodeSessionTitleUpdateRequest = Schema.decodeUnknownSync( + HermesBridgeSessionTitleUpdateRequest, +); +const decodeSessionTitleUpdateResponse = Schema.decodeUnknownSync( + HermesBridgeSessionTitleUpdateResponse, +); +const decodeSessionTitleUpdatedRequest = Schema.decodeUnknownSync( + HermesBridgeSessionTitleUpdatedRequest, +); const requestFields = { protocolVersion: 1, @@ -258,6 +270,51 @@ describe("Hermes bridge T3 to Hermes requests", () => { }); }); +describe("Hermes session titles", () => { + it("decodes title updates in both directions", () => { + expect( + decodeSessionTitleUpdateRequest({ + ...requestFields, + type: "session.title.update", + sessionId: "session-1", + targetThreadId: "thread-1", + title: "Renamed in the sidebar", + }).title, + ).toBe("Renamed in the sidebar"); + + expect( + decodeSessionTitleUpdateResponse({ + ...requestFields, + status: "accepted", + title: "Renamed in the sidebar", + }).status, + ).toBe("accepted"); + + expect( + decodeSessionTitleUpdatedRequest({ + ...callbackFields, + type: "session.title.updated", + chatId: "t3agent", + threadId: "thread-1", + sessionId: "session-1", + title: "Renamed with /title", + }).title, + ).toBe("Renamed with /title"); + }); + + it("rejects empty session titles", () => { + expect(() => + decodeSessionTitleUpdateRequest({ + ...requestFields, + type: "session.title.update", + sessionId: "session-1", + targetThreadId: "thread-1", + title: " ", + }), + ).toThrow(); + }); +}); + describe("Hermes bridge Hermes to T3 callbacks", () => { it("decodes send and cumulative edit callbacks with final state", () => { const send = decodeHermesToT3({ diff --git a/packages/contracts/src/hermesBridge.ts b/packages/contracts/src/hermesBridge.ts index 8693ff570df..d220c6502b9 100644 --- a/packages/contracts/src/hermesBridge.ts +++ b/packages/contracts/src/hermesBridge.ts @@ -255,6 +255,16 @@ export const HermesBridgeTurnCompleteRequest = openStruct({ }); export type HermesBridgeTurnCompleteRequest = typeof HermesBridgeTurnCompleteRequest.Type; +export const HermesBridgeSessionTitleUpdatedRequest = openStruct({ + ...CallbackFields, + ...DestinationFields, + type: Schema.Literal("session.title.updated"), + sessionId: HermesBridgeSessionId, + title: TrimmedNonEmptyString, +}); +export type HermesBridgeSessionTitleUpdatedRequest = + typeof HermesBridgeSessionTitleUpdatedRequest.Type; + const InteractiveCallbackFields = { ...CallbackFields, ...DestinationFields, @@ -308,6 +318,7 @@ export const HermesBridgeHermesToT3Request = Schema.Union([ HermesBridgeDeleteMessageRequest, HermesBridgeTypingRequest, HermesBridgeTurnCompleteRequest, + HermesBridgeSessionTitleUpdatedRequest, HermesBridgeApprovalRequest, HermesBridgeClarificationRequest, HermesBridgeSlashConfirmationRequest, @@ -436,6 +447,25 @@ export const HermesBridgeSessionDeleteRequest = openStruct({ }); export type HermesBridgeSessionDeleteRequest = typeof HermesBridgeSessionDeleteRequest.Type; +export const HermesBridgeSessionTitleUpdateRequest = openStruct({ + ...RequestFields, + type: Schema.Literal("session.title.update"), + sessionId: HermesBridgeSessionId, + targetThreadId: ThreadId, + title: TrimmedNonEmptyString, +}); +export type HermesBridgeSessionTitleUpdateRequest = + typeof HermesBridgeSessionTitleUpdateRequest.Type; + +export const HermesBridgeSessionTitleUpdateResponse = openStruct({ + ...RequestFields, + status: Schema.Literals(["accepted", "duplicate", "rejected"]), + title: Schema.optionalKey(TrimmedNonEmptyString), + message: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type HermesBridgeSessionTitleUpdateResponse = + typeof HermesBridgeSessionTitleUpdateResponse.Type; + export const HermesBridgeHistoryMessage = openStruct({ role: Schema.Literals(["user", "assistant", "system"]), content: Schema.String, @@ -477,6 +507,18 @@ export const HermesConversationForkResult = Schema.Struct({ }); export type HermesConversationForkResult = typeof HermesConversationForkResult.Type; +export const HermesConversationRenameInput = Schema.Struct({ + threadId: ThreadId, + title: TrimmedNonEmptyString, +}); +export type HermesConversationRenameInput = typeof HermesConversationRenameInput.Type; + +export const HermesConversationRenameResult = Schema.Struct({ + threadId: ThreadId, + title: TrimmedNonEmptyString, +}); +export type HermesConversationRenameResult = typeof HermesConversationRenameResult.Type; + export const HermesLineageMetadata = Schema.Struct({ kind: Schema.Literals(["fork", "import"]), label: TrimmedNonEmptyString, @@ -489,7 +531,12 @@ export type HermesLineageMetadata = typeof HermesLineageMetadata.Type; export class HermesLifecycleError extends Schema.TaggedErrorClass()( "HermesLifecycleError", { - operation: Schema.Literals(["sessions.list", "conversation.fork"]), + operation: Schema.Literals([ + "sessions.list", + "conversation.fork", + "conversation.rename", + "titles.reconcile", + ]), message: TrimmedNonEmptyString, detail: Schema.optional(TrimmedNonEmptyString), sourceSessionId: Schema.optional(HermesBridgeSessionId), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index de30aa5a5c9..36fbca85743 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -150,6 +150,8 @@ import { HermesBridgeSessionListResponse, HermesConversationForkInput, HermesConversationForkResult, + HermesConversationRenameInput, + HermesConversationRenameResult, HermesLifecycleError, } from "./hermesBridge.ts"; @@ -226,6 +228,7 @@ export const WS_METHODS = { serverSignalProcess: "server.signalProcess", hermesSessionsList: "hermes.sessions.list", hermesConversationFork: "hermes.conversation.fork", + hermesConversationRename: "hermes.conversation.rename", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -354,6 +357,12 @@ export const WsHermesConversationForkRpc = Rpc.make(WS_METHODS.hermesConversatio error: Schema.Union([HermesLifecycleError, EnvironmentAuthorizationError]), }); +export const WsHermesConversationRenameRpc = Rpc.make(WS_METHODS.hermesConversationRename, { + payload: HermesConversationRenameInput, + success: HermesConversationRenameResult, + error: Schema.Union([HermesLifecycleError, EnvironmentAuthorizationError]), +}); + export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -735,6 +744,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerSignalProcessRpc, WsHermesSessionsListRpc, WsHermesConversationForkRpc, + WsHermesConversationRenameRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc,