Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProviderDriverKind,
type ProjectId,
type OrchestrationSession,
ProviderInstanceId,
ThreadId,
type ProviderSession,
type RuntimeMode,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/provider/Layers/HermesAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/provider/Layers/HermesAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/provider/hermes/HermesBridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -223,6 +262,7 @@ describe("HermesBridgeClient", () => {
protocolVersion: 1,
requestId: "request-interrupt",
type: "turn.interrupt",
chatId: "t3agent",
threadId: "thread-1",
}),
],
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/provider/hermes/HermesBridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
HermesBridgeSessionForkRequest,
HermesBridgeSessionForkResponse,
HermesBridgeSessionListResponse,
HermesBridgeSessionTitleUpdateRequest,
HermesBridgeSessionTitleUpdateResponse,
type HermesBridgeT3ToHermesRequest,
} from "@t3tools/contracts/hermesBridge";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -33,6 +35,9 @@ export interface HermesBridgeClient {
readonly deleteSession: (
request: HermesBridgeSessionDeleteRequest,
) => Effect.Effect<HermesBridgeAcknowledgement, ProviderAdapterRequestError>;
readonly updateSessionTitle: (
request: HermesBridgeSessionTitleUpdateRequest,
) => Effect.Effect<HermesBridgeSessionTitleUpdateResponse, ProviderAdapterRequestError>;
}

function requestPath(request: HermesBridgeT3ToHermesRequest): string {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}
7 changes: 5 additions & 2 deletions apps/server/src/provider/hermes/HermesBridgeRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export class HermesBridgeRegistryError extends Schema.TaggedErrorClass<HermesBri
export interface HermesBridgeReceiver {
readonly token: string;
readonly receive: (payload: unknown) => Effect.Effect<unknown, ProviderAdapterError>;
readonly client?: Pick<HermesBridgeClient, "listSessions" | "forkSession" | "deleteSession">;
readonly client?: Pick<
HermesBridgeClient,
"listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle"
>;
}

export interface HermesBridgeRegistration {
Expand Down Expand Up @@ -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<HermesBridgeClient, "listSessions" | "forkSession" | "deleteSession">,
Pick<HermesBridgeClient, "listSessions" | "forkSession" | "deleteSession" | "updateSessionTitle">,
HermesBridgeRegistryError
> {
const receiver = registrations.get(instanceId)?.receiver;
Expand Down
Loading
Loading