Skip to content

Commit 81aab94

Browse files
authored
Sync Hermes session titles with the T3 Agent sidebar (#3)
* Document Hermes-owned session titles * feat(t3-agent): sync Hermes session titles
1 parent 7b0344a commit 81aab94

21 files changed

Lines changed: 946 additions & 34 deletions

File tree

CONTEXT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ _Avoid_: Project thread, workspace, channel
3030
The durable Hermes-owned conversation context that the agent continues across turns.
3131
_Avoid_: T3 transcript, project
3232

33+
**Hermes session title**:
34+
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.
35+
_Avoid_: T3 thread name, synchronized title
36+
3337
**Cross-gateway conversation**:
3438
A Hermes session that originated from the CLI or another messaging surface rather than T3 Agent.
3539
_Avoid_: Synced thread

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,47 @@ describe("ProviderCommandReactor", () => {
637637
expect(thread?.title).toBe("Generated title");
638638
});
639639

640+
it("leaves the provisional title for Hermes to replace", async () => {
641+
const harness = await createHarness({
642+
threadModelSelection: {
643+
instanceId: ProviderInstanceId.make("hermes"),
644+
model: "openai-codex::gpt-5.6-sol",
645+
},
646+
});
647+
const seededTitle = "Please investigate reconnect failures after restar...";
648+
649+
await Effect.runPromise(
650+
harness.engine.dispatch({
651+
type: "thread.meta.update",
652+
commandId: CommandId.make("cmd-hermes-title-seed"),
653+
threadId: ThreadId.make("thread-1"),
654+
title: seededTitle,
655+
}),
656+
);
657+
await Effect.runPromise(
658+
harness.engine.dispatch({
659+
type: "thread.turn.start",
660+
commandId: CommandId.make("cmd-hermes-first-turn"),
661+
threadId: ThreadId.make("thread-1"),
662+
message: {
663+
messageId: asMessageId("user-message-hermes-title"),
664+
role: "user",
665+
text: "Please investigate reconnect failures after restarting the session.",
666+
attachments: [],
667+
},
668+
titleSeed: seededTitle,
669+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
670+
runtimeMode: "approval-required",
671+
createdAt: "2026-01-01T00:00:00.000Z",
672+
}),
673+
);
674+
675+
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
676+
expect(harness.generateThreadTitle).not.toHaveBeenCalled();
677+
const readModel = await harness.readModel();
678+
expect(readModel.threads.find((entry) => entry.id === "thread-1")?.title).toBe(seededTitle);
679+
});
680+
640681
it("does not overwrite an existing custom thread title on the first turn", async () => {
641682
const harness = await createHarness();
642683
const now = "2026-01-01T00:00:00.000Z";

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ProviderDriverKind,
88
type ProjectId,
99
type OrchestrationSession,
10+
ProviderInstanceId,
1011
ThreadId,
1112
type ProviderSession,
1213
type RuntimeMode,
@@ -87,6 +88,7 @@ const HANDLED_TURN_START_KEY_MAX = 10_000;
8788
const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
8889
const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
8990
const DEFAULT_THREAD_TITLE = "New thread";
91+
const HERMES_PROVIDER_INSTANCE_ID = ProviderInstanceId.make("hermes");
9092

9193
export function providerErrorLabel(value: string | undefined): string {
9294
const normalized = value?.trim();
@@ -816,7 +818,10 @@ const make = Effect.gen(function* () {
816818
...generationInput,
817819
}).pipe(Effect.forkScoped);
818820

819-
if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) {
821+
if (
822+
thread.modelSelection.instanceId !== HERMES_PROVIDER_INSTANCE_ID &&
823+
canReplaceThreadTitle(thread.title, event.payload.titleSeed)
824+
) {
820825
yield* maybeGenerateThreadTitleForFirstTurn({
821826
threadId: event.payload.threadId,
822827
cwd: generationCwd,

apps/server/src/provider/Layers/HermesAdapter.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const testLayer = Layer.effect(
7171
listSessions: Effect.die("not used by adapter tests"),
7272
forkSession: () => Effect.die("not used by adapter tests"),
7373
deleteSession: () => Effect.die("not used by adapter tests"),
74+
updateSessionTitle: () => Effect.die("not used by adapter tests"),
7475
};
7576
const adapter = yield* makeHermesAdapter({
7677
instanceId: ProviderInstanceId.make("hermes-test"),
@@ -268,6 +269,41 @@ it.layer(testLayer)("HermesAdapter", (it) => {
268269
}),
269270
);
270271

272+
it.effect("projects Hermes session title callbacks as thread metadata", () =>
273+
Effect.gen(function* () {
274+
const { adapter } = yield* HermesAdapterTestHarness;
275+
const threadId = ThreadId.make("hermes-title-thread");
276+
const eventsFiber = yield* adapter.streamEvents.pipe(
277+
Stream.take(1),
278+
Stream.runCollect,
279+
Effect.forkChild,
280+
);
281+
yield* Effect.yieldNow;
282+
283+
yield* adapter.receiveCallback({
284+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
285+
requestId: "title-callback-request",
286+
deliveryId: "title-callback-delivery",
287+
type: "session.title.updated",
288+
chatId: "t3agent",
289+
threadId,
290+
sessionId: "session-title-thread",
291+
title: "Renamed with /title",
292+
});
293+
294+
const events = Array.from(yield* Fiber.join(eventsFiber));
295+
NodeAssert.deepEqual(events[0], {
296+
eventId: "hermes:title-callback-delivery:title",
297+
provider: "hermes",
298+
providerInstanceId: "hermes-test",
299+
threadId,
300+
createdAt: events[0]?.createdAt,
301+
type: "thread.metadata.updated",
302+
payload: { name: "Renamed with /title" },
303+
});
304+
}),
305+
);
306+
271307
it.effect("round-trips approval and clarification responses through the bridge", () =>
272308
Effect.gen(function* () {
273309
const { adapter, sent } = yield* HermesAdapterTestHarness;

apps/server/src/provider/Layers/HermesAdapter.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (
615615
};
616616
break;
617617
}
618+
case "session.title.updated": {
619+
const base = yield* eventBase(callback, threadId, "title");
620+
yield* publish({
621+
...base,
622+
type: "thread.metadata.updated",
623+
payload: { name: callback.title },
624+
});
625+
break;
626+
}
618627
case "approval.request":
619628
yield* rememberApproval(callback, threadId, context);
620629
break;

apps/server/src/provider/hermes/HermesBridgeClient.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,45 @@ describe("HermesBridgeClient", () => {
166166
});
167167
});
168168

169+
it.effect("updates a Hermes session title through the dedicated endpoint", () => {
170+
const targetThreadId = ThreadId.make("00000000-0000-4000-8000-000000000003");
171+
const { client, execute } = makeClient(() =>
172+
Response.json({
173+
protocolVersion: 1,
174+
requestId: "title-request",
175+
status: "accepted",
176+
title: "Canonical Hermes title",
177+
}),
178+
);
179+
180+
return Effect.gen(function* () {
181+
const result = yield* client.updateSessionTitle({
182+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
183+
requestId: HermesBridgeRequestId.make("title-request"),
184+
type: "session.title.update",
185+
sessionId: HermesBridgeSessionId.make("session-1"),
186+
targetThreadId,
187+
title: " Canonical Hermes title ",
188+
});
189+
190+
assert.strictEqual(result.title, "Canonical Hermes title");
191+
const call = execute.mock.calls[0];
192+
assert.ok(call);
193+
const [request] = call;
194+
assert.strictEqual(request.method, "POST");
195+
assert.strictEqual(new URL(request.url).pathname, "/v1/sessions/title");
196+
assert.strictEqual(request.headers["idempotency-key"], "title-request");
197+
assert.deepStrictEqual(jsonBody(request), {
198+
protocolVersion: 1,
199+
requestId: "title-request",
200+
type: "session.title.update",
201+
sessionId: "session-1",
202+
targetThreadId,
203+
title: " Canonical Hermes title ",
204+
});
205+
});
206+
});
207+
169208
it.effect("deletes only the child session correlated to its T3 thread", () => {
170209
const targetThreadId = ThreadId.make("00000000-0000-4000-8000-000000000002");
171210
const sessionId = HermesBridgeSessionId.make("t3-child");
@@ -223,6 +262,7 @@ describe("HermesBridgeClient", () => {
223262
protocolVersion: 1,
224263
requestId: "request-interrupt",
225264
type: "turn.interrupt",
265+
chatId: "t3agent",
226266
threadId: "thread-1",
227267
}),
228268
],

apps/server/src/provider/hermes/HermesBridgeClient.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
HermesBridgeSessionForkRequest,
77
HermesBridgeSessionForkResponse,
88
HermesBridgeSessionListResponse,
9+
HermesBridgeSessionTitleUpdateRequest,
10+
HermesBridgeSessionTitleUpdateResponse,
911
type HermesBridgeT3ToHermesRequest,
1012
} from "@t3tools/contracts/hermesBridge";
1113
import * as Effect from "effect/Effect";
@@ -33,6 +35,9 @@ export interface HermesBridgeClient {
3335
readonly deleteSession: (
3436
request: HermesBridgeSessionDeleteRequest,
3537
) => Effect.Effect<HermesBridgeAcknowledgement, ProviderAdapterRequestError>;
38+
readonly updateSessionTitle: (
39+
request: HermesBridgeSessionTitleUpdateRequest,
40+
) => Effect.Effect<HermesBridgeSessionTitleUpdateResponse, ProviderAdapterRequestError>;
3641
}
3742

3843
function requestPath(request: HermesBridgeT3ToHermesRequest): string {
@@ -127,6 +132,18 @@ export function makeHermesBridgeClient(input: {
127132
),
128133
);
129134

135+
const updateSessionTitle = Effect.fn("HermesBridgeClient.updateSessionTitle")(
136+
(request: HermesBridgeSessionTitleUpdateRequest) =>
137+
HttpClientRequest.post(`${baseUrl}/v1/sessions/title`).pipe(
138+
authorize,
139+
HttpClientRequest.setHeader("idempotency-key", request.requestId),
140+
HttpClientRequest.bodyJsonUnsafe(request),
141+
execute.execute,
142+
Effect.flatMap(HttpClientResponse.schemaBodyJson(HermesBridgeSessionTitleUpdateResponse)),
143+
Effect.mapError(mapRequestError("session.title.update")),
144+
),
145+
);
146+
130147
const send: HermesBridgeClient["send"] = (request) =>
131148
HttpClientRequest.post(`${baseUrl}${requestPath(request)}`).pipe(
132149
authorize,
@@ -137,5 +154,12 @@ export function makeHermesBridgeClient(input: {
137154
Effect.mapError(mapRequestError(request.type)),
138155
);
139156

140-
return { getCapabilities, listSessions, forkSession, deleteSession, send };
157+
return {
158+
getCapabilities,
159+
listSessions,
160+
forkSession,
161+
deleteSession,
162+
updateSessionTitle,
163+
send,
164+
};
141165
}

apps/server/src/provider/hermes/HermesBridgeRegistry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ export class HermesBridgeRegistryError extends Schema.TaggedErrorClass<HermesBri
1717
export interface HermesBridgeReceiver {
1818
readonly token: string;
1919
readonly receive: (payload: unknown) => Effect.Effect<unknown, ProviderAdapterError>;
20-
readonly client?: Pick<HermesBridgeClient, "listSessions" | "forkSession" | "deleteSession">;
20+
readonly client?: Pick<
21+
HermesBridgeClient,
22+
"listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle"
23+
>;
2124
}
2225

2326
export interface HermesBridgeRegistration {
@@ -60,7 +63,7 @@ export const unregister = Effect.fn("HermesBridgeRegistry.unregister")(function*
6063
export const getClient = Effect.fn("HermesBridgeRegistry.getClient")(function* (
6164
instanceId: ProviderInstanceId,
6265
): Effect.fn.Return<
63-
Pick<HermesBridgeClient, "listSessions" | "forkSession" | "deleteSession">,
66+
Pick<HermesBridgeClient, "listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle">,
6467
HermesBridgeRegistryError
6568
> {
6669
const receiver = registrations.get(instanceId)?.receiver;

0 commit comments

Comments
 (0)