History

feat: give every Hermes instance a Home thread for proactive delivery

Hermes has a first-class "home channel": the default destination for output nobody asked for at a specific address — a cron result, an agent-initiated `send_message`, a gateway lifecycle notice, a `/handoff`. The T3 plugin opted out, suppressing Hermes' "Type /sethome" nudge without designating anything. A cron job with `deliver=t3` went nowhere, `send_message` to a bare `t3` target errored, and `/handoff t3` hard-failed. Each enrolled instance now gets one Home thread, pinned first in its agent project, fully conversational, undeletable, created automatically. Protocol v3. `connection.hello` gains `role: "gateway" | "delivery"`; `connection.accepted` carries `homeThreadId`. Two new frames: `home.deliver` and `home.deliver.ack`. Designation converges on read, like agent projects — a precondition every handshake runs rather than an event one caller owns, so an instance enrolled before this shipped, or one whose thread creation failed once, heals on the next connect. It lives in the instance's settings blob beside `connectorUrl` and `revoked`; the plugin's `T3_HOME_CHANNEL` is a cache of it, reconciled every handshake. The existence check deliberately ignores archive state. Every other thread read filters `archived_at IS NULL`, which is right for "what should the user see" and wrong for "does this row exist" — an archived Home read as deleted and the self-healing path replaced it, stranding the history in a thread the user had merely parked. A delivery to an archived Home now un-archives it, the same way incoming activity wakes a settled or snoozed thread. Deliveries bypass the turn machinery entirely. One may arrive while the home thread has a live user turn, so it appends as a turnless assistant message carrying `notification` provenance rather than joining the turn — attributing it would fold it away behind that turn's "Worked for …". Everything except a lifecycle notice un-settles the thread and pushes; lifecycle notices land quietly. Idempotency is load-bearing, because the plugin's durable queue retries anything it has not seen acked. T3 dedupes on `deliveryId` against `projection_thread_messages` before dispatching — the decider's own scan sees the in-memory read model, which is built with `messages: []`, so it cannot catch a replay after a restart. The ack is sent only after the write lands. An out-of-process cron run dials in with `role: "delivery"`: authenticated identically, but never registered as the instance's primary connection, so it cannot displace a live gateway socket or answer its pending requests. Plugin side, the proactive branch in `send()` gates on provenance rather than turn absence. The naive rule — "no active turn, so deliver" — would let a notify-stamped cron delivery complete a user's live turn with cron output as its answer. A bare session-key mismatch is not enough either, since `HERMES_SESSION_KEY` resolves empty whenever the context did not propagate; that would tear a genuine reply out of its turn. Bypassing a live turn requires positively identified cron/lifecycle/handoff provenance.

Ben Davis committed 3 months ago

Commit 784fcb71 parent 5bfb90d73 files · +5908 −120Browse files

Unified · Split

73 files
  1. apps/mobile/src/widgets/AgentActivity.tsx +8 −1
  2. apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +5 −0
  3. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +1 −0
  4. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +133 −0
  5. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +41 −1
  6. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +1 −0
  7. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +63 −4
  8. apps/server/src/orchestration/Schemas.ts +2 −0
  9. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +18 −0
  10. apps/server/src/orchestration/agentProjects.test.ts +1 −0
  11. apps/server/src/orchestration/decider.notification.test.ts +237 −0
  12. apps/server/src/orchestration/decider.ts +96 −0
  13. apps/server/src/orchestration/homeThreads.test.ts +408 −0
  14. apps/server/src/orchestration/homeThreads.ts +242 −0
  15. apps/server/src/orchestration/projector.notification.test.ts +149 −0
  16. apps/server/src/orchestration/projector.ts +50 −0
  17. apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +2 −0
  18. apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +74 −0
  19. apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +20 −1
  20. apps/server/src/persistence/Layers/ProjectionThreads.ts +15 −4
  21. apps/server/src/persistence/Migrations.ts +4 −0
  22. apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.test.ts +109 −0
  23. apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.ts +11 −0
  24. apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.test.ts +80 −0
  25. apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.ts +22 −0
  26. apps/server/src/persistence/Services/ProjectionThreadMessages.ts +9 −0
  27. apps/server/src/persistence/Services/ProjectionThreads.ts +7 −0
  28. apps/server/src/project/ProjectSetupScriptRunner.test.ts +1 −0
  29. apps/server/src/provider/Drivers/HermesDriver.ts +20 −1
  30. apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +100 −4
  31. apps/server/src/provider/Layers/HermesGatewayBroker.ts +38 −0
  32. apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +1 −0
  33. apps/server/src/provider/Services/HermesGatewayBroker.ts +17 −1
  34. apps/server/src/provider/hermesGatewayHttp.ts +146 −1
  35. apps/server/src/relay/AgentAwarenessRelay.test.ts +24 −0
  36. apps/server/src/relay/AgentAwarenessRelay.ts +5 −0
  37. apps/server/src/serverRuntimeStartup.test.ts +4 −0
  38. apps/server/src/ws.ts +22 −0
  39. apps/web/src/components/Sidebar.logic.test.ts +149 −0
  40. apps/web/src/components/Sidebar.logic.ts +42 −1
  41. apps/web/src/components/Sidebar.tsx +58 −2
  42. apps/web/src/components/agents/AgentPage.logic.test.ts +101 −0
  43. apps/web/src/components/agents/AgentPage.logic.ts +43 −0
  44. apps/web/src/components/agents/AgentPage.tsx +156 −1
  45. apps/web/src/components/chat/MessagesTimeline.tsx +31 −0
  46. apps/web/src/components/chat/notificationBadge.logic.test.ts +63 −0
  47. apps/web/src/components/chat/notificationBadge.logic.ts +59 −0
  48. docs/project/hermes-home-channel-plan.md +165 −13
  49. infra/relay/src/agentActivity/AgentActivityPublisher.ts +2 −0
  50. integrations/hermes-t3-gateway/COMPATIBILITY.md +189 −5
  51. integrations/hermes-t3-gateway/README.md +36 −4
  52. integrations/hermes-t3-gateway/__init__.py +18 −0
  53. integrations/hermes-t3-gateway/adapter.py +242 −4
  54. integrations/hermes-t3-gateway/connection.py +39 −1
  55. integrations/hermes-t3-gateway/home.py +605 −0
  56. integrations/hermes-t3-gateway/plugin.yaml +1 −1
  57. integrations/hermes-t3-gateway/protocol.py +141 −2
  58. integrations/hermes-t3-gateway/tests/test_adapter.py +466 −28
  59. integrations/hermes-t3-gateway/tests/test_connection.py +9 −7
  60. integrations/hermes-t3-gateway/tests/test_home.py +473 −0
  61. integrations/hermes-t3-gateway/tests/test_protocol.py +8 −8
  62. packages/client-runtime/src/state/threadReducer.test.ts +82 −0
  63. packages/client-runtime/src/state/threadReducer.ts +30 −0
  64. packages/client-runtime/src/state/threadSort.test.ts +40 −0
  65. packages/client-runtime/src/state/threadSort.ts +17 −0
  66. packages/contracts/src/hermesGateway.test.ts +113 −20
  67. packages/contracts/src/hermesGateway.ts +98 −3
  68. packages/contracts/src/orchestration.ts +96 −0
  69. packages/contracts/src/relay.ts +2 −0
  70. packages/contracts/src/server.ts +9 −0
  71. packages/contracts/src/settings.ts +13 −0
  72. packages/shared/src/agentAwareness.test.ts +74 −0
  73. packages/shared/src/agentAwareness.ts +52 −2
apps/mobile/src/widgets/AgentActivity.tsx+8−1
@@ -23,6 +23,7 @@ export type AgentActivityPhase = · 22 unmodified lines
23 | "running"23 | "running"
24 | "waiting_for_approval"24 | "waiting_for_approval"
25 | "waiting_for_input"25 | "waiting_for_input"
26 | "notification"
26 | "completed"27 | "completed"
27 | "failed"28 | "failed"
28 | "stale";29 | "stale";
@@ -81,6 +82,8 @@ export function AgentActivity( · 52 unmodified lines
81 return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-30082 return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300
82 case "waiting_for_input":83 case "waiting_for_input":
83 return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-30084 return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300
85 case "notification":
86 return isLightScheme ? "#7c3aed" : "#c4b5fd"; // violet-600 / violet-300
84 case "failed":87 case "failed":
85 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-30088 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
86 case "completed":89 case "completed":
@@ -97,7 +100,9 @@ export function AgentActivity( · 10 unmodified lines
97 const phasePriority = (phase: AgentActivityPhase): number => {100 const phasePriority = (phase: AgentActivityPhase): number => {
98 if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;101 if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
99 if (phase === "failed") return 1;102 if (phase === "failed") return 1;
100 if (phase === "running" || phase === "starting") return 2;103 // A proactive delivery is unread news, so it outranks finished/stale rows
104 // but never a human who is actually blocked.
105 if (phase === "running" || phase === "starting" || phase === "notification") return 2;
101 return 3;106 return 3;
102 };107 };
103 const ordered = [...props.activities].sort(108 const ordered = [...props.activities].sort(
@@ -163,6 +168,8 @@ export function AgentActivity( · 59 unmodified lines
163 return "exclamationmark.circle.fill";168 return "exclamationmark.circle.fill";
164 case "waiting_for_input":169 case "waiting_for_input":
165 return "questionmark.circle.fill";170 return "questionmark.circle.fill";
171 case "notification":
172 return "bell.badge.fill";
166 case "failed":173 case "failed":
167 return "xmark.octagon.fill";174 return "xmark.octagon.fill";
168 case "completed":175 case "completed":
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5−0
@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => { · 106 unmodified lines
107 });107 });
108 }),108 }),
109 getThreadShellById: () => Effect.succeed(Option.none()),109 getThreadShellById: () => Effect.succeed(Option.none()),
110 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
110 getThreadDetailById: () => Effect.succeed(Option.none()),111 getThreadDetailById: () => Effect.succeed(Option.none()),
111 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),112 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
112 }),113 }),
@@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { · 88 unmodified lines
201 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),202 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
202 getFullThreadDiffContext: () => Effect.die("unused"),203 getFullThreadDiffContext: () => Effect.die("unused"),
203 getThreadShellById: () => Effect.succeed(Option.none()),204 getThreadShellById: () => Effect.succeed(Option.none()),
205 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
204 getThreadDetailById: () => Effect.succeed(Option.none()),206 getThreadDetailById: () => Effect.succeed(Option.none()),
205 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),207 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
206 }),208 }),
@@ -285,6 +287,7 @@ describe("CheckpointDiffQuery.layer", () => { · 78 unmodified lines
285 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),287 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
286 getFullThreadDiffContext: () => Effect.die("unused"),288 getFullThreadDiffContext: () => Effect.die("unused"),
287 getThreadShellById: () => Effect.succeed(Option.none()),289 getThreadShellById: () => Effect.succeed(Option.none()),
290 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
288 getThreadDetailById: () => Effect.succeed(Option.none()),291 getThreadDetailById: () => Effect.succeed(Option.none()),
289 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),292 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
290 }),293 }),
@@ -354,6 +357,7 @@ describe("CheckpointDiffQuery.layer", () => { · 63 unmodified lines
354 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),357 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
355 getFullThreadDiffContext: () => Effect.die("unused"),358 getFullThreadDiffContext: () => Effect.die("unused"),
356 getThreadShellById: () => Effect.succeed(Option.none()),359 getThreadShellById: () => Effect.succeed(Option.none()),
360 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
357 getThreadDetailById: () => Effect.succeed(Option.none()),361 getThreadDetailById: () => Effect.succeed(Option.none()),
358 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),362 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
359 }),363 }),
@@ -408,6 +412,7 @@ describe("CheckpointDiffQuery.layer", () => { · 48 unmodified lines
408 getThreadCheckpointContext: () => Effect.succeed(Option.none()),412 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
409 getFullThreadDiffContext: () => Effect.succeed(Option.none()),413 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
410 getThreadShellById: () => Effect.succeed(Option.none()),414 getThreadShellById: () => Effect.succeed(Option.none()),
415 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
411 getThreadDetailById: () => Effect.succeed(Option.none()),416 getThreadDetailById: () => Effect.succeed(Option.none()),
412 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),417 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
413 }),418 }),
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1−0
@@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => { · 202 unmodified lines
203 getThreadCheckpointContext: () => Effect.succeed(Option.none()),203 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
204 getFullThreadDiffContext: () => Effect.succeed(Option.none()),204 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
205 getThreadShellById: () => Effect.succeed(Option.none()),205 getThreadShellById: () => Effect.succeed(Option.none()),
206 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
206 getThreadDetailById: () => Effect.succeed(Option.none()),207 getThreadDetailById: () => Effect.succeed(Option.none()),
207 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),208 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
208 }),209 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+133−0
@@ -2368,6 +2368,139 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2367 unmodified lines
2368 }),2368 }),
2369 );2369 );
2370 2370
2371 it.effect("projects notification deliveries into the message row and shell summary", () =>
2372 Effect.gen(function* () {
2373 const projectionPipeline = yield* OrchestrationProjectionPipeline;
2374 const eventStore = yield* OrchestrationEventStore;
2375 const sql = yield* SqlClient.SqlClient;
2376 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
2377 eventStore
2378 .append(event)
2379 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
2380 const createdAt = "2026-07-01T09:00:00.000Z";
2381
2382 yield* appendAndProject({
2383 type: "project.created",
2384 eventId: EventId.make("evt-notify-project"),
2385 aggregateKind: "project",
2386 aggregateId: ProjectId.make("project-notify"),
2387 occurredAt: createdAt,
2388 commandId: CommandId.make("cmd-notify-project"),
2389 causationEventId: null,
2390 correlationId: CommandId.make("cmd-notify-project"),
2391 metadata: {},
2392 payload: {
2393 projectId: ProjectId.make("project-notify"),
2394 title: "Hermes",
2395 workspaceRoot: "/tmp/project-notify",
2396 defaultModelSelection: null,
2397 scripts: [],
2398 createdAt,
2399 updatedAt: createdAt,
2400 },
2401 });
2402
2403 yield* appendAndProject({
2404 type: "thread.created",
2405 eventId: EventId.make("evt-notify-thread"),
2406 aggregateKind: "thread",
2407 aggregateId: ThreadId.make("thread-notify"),
2408 occurredAt: createdAt,
2409 commandId: CommandId.make("cmd-notify-thread"),
2410 causationEventId: null,
2411 correlationId: CommandId.make("cmd-notify-thread"),
2412 metadata: {},
2413 payload: {
2414 threadId: ThreadId.make("thread-notify"),
2415 projectId: ProjectId.make("project-notify"),
2416 title: "Home",
2417 modelSelection: {
2418 instanceId: ProviderInstanceId.make("hermes"),
2419 model: "hermes",
2420 },
2421 runtimeMode: "full-access",
2422 branch: null,
2423 worktreePath: null,
2424 createdAt,
2425 updatedAt: createdAt,
2426 },
2427 });
2428
2429 const deliveredAt = "2026-07-01T09:05:00.000Z";
2430 yield* appendAndProject({
2431 type: "thread.notification-delivered",
2432 eventId: EventId.make("evt-notify-delivery"),
2433 aggregateKind: "thread",
2434 aggregateId: ThreadId.make("thread-notify"),
2435 occurredAt: deliveredAt,
2436 commandId: CommandId.make("cmd-notify-delivery"),
2437 causationEventId: null,
2438 correlationId: CommandId.make("cmd-notify-delivery"),
2439 metadata: {},
2440 payload: {
2441 threadId: ThreadId.make("thread-notify"),
2442 messageId: MessageId.make("message-notify"),
2443 text: "Digest ready.",
2444 notification: {
2445 kind: "cron",
2446 label: "Cron: daily-digest",
2447 deliveryId: "delivery-1",
2448 },
2449 createdAt: deliveredAt,
2450 updatedAt: deliveredAt,
2451 },
2452 });
2453
2454 const messageRows = yield* sql<{
2455 readonly messageId: string;
2456 readonly text: string;
2457 readonly turnId: string | null;
2458 readonly role: string;
2459 readonly isStreaming: number;
2460 readonly notification: string | null;
2461 }>`
2462 SELECT
2463 message_id AS "messageId",
2464 text,
2465 turn_id AS "turnId",
2466 role,
2467 is_streaming AS "isStreaming",
2468 notification_json AS "notification"
2469 FROM projection_thread_messages
2470 WHERE thread_id = 'thread-notify'
2471 `;
2472 assert.equal(messageRows.length, 1);
2473 assert.equal(messageRows[0]?.messageId, "message-notify");
2474 assert.equal(messageRows[0]?.turnId, null);
2475 assert.equal(messageRows[0]?.role, "assistant");
2476 assert.equal(messageRows[0]?.isStreaming, 0);
2477 assert.deepEqual(
2478 // @effect-diagnostics-next-line preferSchemaOverJson:off
2479 JSON.parse(messageRows[0]?.notification ?? "null"),
2480 { kind: "cron", label: "Cron: daily-digest", deliveryId: "delivery-1" },
2481 );
2482
2483 // The shell summary is what the sidebar and the push relay read; the
2484 // provenance is invisible to them unless it is flattened here.
2485 const threadRows = yield* sql<{
2486 readonly updatedAt: string;
2487 readonly latestNotification: string | null;
2488 }>`
2489 SELECT
2490 updated_at AS "updatedAt",
2491 latest_notification_json AS "latestNotification"
2492 FROM projection_threads
2493 WHERE thread_id = 'thread-notify'
2494 `;
2495 assert.equal(threadRows[0]?.updatedAt, deliveredAt);
2496 assert.deepEqual(
2497 // @effect-diagnostics-next-line preferSchemaOverJson:off
2498 JSON.parse(threadRows[0]?.latestNotification ?? "null"),
2499 { kind: "cron", label: "Cron: daily-digest", deliveredAt },
2500 );
2501 }),
2502 );
2503
2371 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>2504 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
2372 Effect.gen(function* () {2505 Effect.gen(function* () {
2373 const projectionPipeline = yield* OrchestrationProjectionPipeline;2506 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+41−1
@@ -34,7 +34,10 @@ import { · 33 unmodified lines
34 type ProjectionTurn,34 type ProjectionTurn,
35 ProjectionTurnRepository,35 ProjectionTurnRepository,
36} from "../../persistence/Services/ProjectionTurns.ts";36} from "../../persistence/Services/ProjectionTurns.ts";
37import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";37import {
38 type ProjectionThread,
39 ProjectionThreadRepository,
40} from "../../persistence/Services/ProjectionThreads.ts";
38import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";41import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";
39import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";42import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";
40import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";43import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";
@@ -564,6 +567,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 523 unmodified lines
564 ]);567 ]);
565 568
566 let latestUserMessageAt: string | null = null;569 let latestUserMessageAt: string | null = null;
570 // Newest proactive delivery, flattened for shell readers that never load
571 // messages (sidebar rows, agent awareness). Ties break on message id so
572 // two deliveries stamped in the same millisecond resolve deterministically.
573 let latestNotification: ProjectionThread["latestNotification"] = null;
574 let latestNotificationKey: string | null = null;
567 for (const message of messages) {575 for (const message of messages) {
568 if (576 if (
569 message.role === "user" &&577 message.role === "user" &&
@@ -571,6 +579,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1 unmodified line
571 ) {579 ) {
572 latestUserMessageAt = message.createdAt;580 latestUserMessageAt = message.createdAt;
573 }581 }
582 if (message.notification !== undefined) {
583 const key = `${message.createdAt}${message.messageId}`;
584 if (latestNotificationKey === null || key > latestNotificationKey) {
585 latestNotificationKey = key;
586 latestNotification = {
587 kind: message.notification.kind,
588 label: message.notification.label,
589 deliveredAt: message.createdAt,
590 };
591 }
592 }
574 }593 }
575 594
576 const pendingApprovalCount = pendingApprovals.filter(595 const pendingApprovalCount = pendingApprovals.filter(
@@ -588,6 +607,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 11 unmodified lines
588 pendingApprovalCount,607 pendingApprovalCount,
589 pendingUserInputCount,608 pendingUserInputCount,
590 hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,609 hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,
610 latestNotification,
591 });611 });
592 });612 });
593 613
@@ -617,6 +637,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 23 unmodified lines
617 pendingApprovalCount: 0,637 pendingApprovalCount: 0,
618 pendingUserInputCount: 0,638 pendingUserInputCount: 0,
619 hasActionableProposedPlan: 0,639 hasActionableProposedPlan: 0,
640 latestNotification: null,
620 deletedAt: null,641 deletedAt: null,
621 });642 });
622 return;643 return;
@@ -784,6 +805,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 161 unmodified lines
784 }805 }
785 806
786 case "thread.message-sent":807 case "thread.message-sent":
808 case "thread.notification-delivered":
787 case "thread.proposed-plan-upserted":809 case "thread.proposed-plan-upserted":
788 case "thread.activity-appended":810 case "thread.activity-appended":
789 case "thread.approval-response-requested":811 case "thread.approval-response-requested":
@@ -917,6 +939,24 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 127 unmodified lines
917 return;939 return;
918 }940 }
919 941
942 case "thread.notification-delivered": {
943 // No merge accounting: a delivery is written whole, never streamed,
944 // and a replay re-emits the identical payload (the decider rebuilds
945 // it from the persisted row), so the upsert is idempotent.
946 yield* projectionThreadMessageRepository.upsert({
947 messageId: event.payload.messageId,
948 threadId: event.payload.threadId,
949 turnId: null,
950 role: "assistant",
951 text: event.payload.text,
952 notification: event.payload.notification,
953 isStreaming: false,
954 createdAt: event.payload.createdAt,
955 updatedAt: event.payload.updatedAt,
956 });
957 return;
958 }
959
920 case "thread.reverted": {960 case "thread.reverted": {
921 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({961 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
922 threadId: event.payload.threadId,962 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1−0
@@ -441,6 +441,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 440 unmodified lines
441 hasPendingApprovals: true,441 hasPendingApprovals: true,
442 hasPendingUserInput: false,442 hasPendingUserInput: false,
443 hasActionableProposedPlan: false,443 hasActionableProposedPlan: false,
444 latestNotification: null,
444 },445 },
445 ]);446 ]);
446 447
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+63−4
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5 MessageId,5 MessageId,
6 NonNegativeInt,6 NonNegativeInt,
7 OrchestrationCheckpointFile,7 OrchestrationCheckpointFile,
8 OrchestrationMessageNotification,
8 OrchestrationProposedPlanId,9 OrchestrationProposedPlanId,
9 OrchestrationReadModel,10 OrchestrationReadModel,
10 OrchestrationShellSnapshot,11 OrchestrationShellSnapshot,
@@ -22,6 +23,7 @@ import { · 11 unmodified lines
22 type OrchestrationThreadActivity,23 type OrchestrationThreadActivity,
23 type OrchestrationThreadShell,24 type OrchestrationThreadShell,
24 ModelSelection,25 ModelSelection,
26 OrchestrationThreadNotificationSummary,
25 ProjectId,27 ProjectId,
26 ProviderInstanceId,28 ProviderInstanceId,
27 ThreadId,29 ThreadId,
@@ -73,12 +75,19 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( · 45 unmodified lines
73 Struct.assign({75 Struct.assign({
74 isStreaming: Schema.Number,76 isStreaming: Schema.Number,
75 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),77 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
78 notification: Schema.NullOr(Schema.fromJsonString(OrchestrationMessageNotification)),
76 }),79 }),
77);80);
78const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;81const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
79const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(82const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
80 Struct.assign({83 Struct.assign({
81 modelSelection: Schema.fromJsonString(ModelSelection),84 modelSelection: Schema.fromJsonString(ModelSelection),
85 // Stored as a JSON string, like `modelSelection`. Every column the row
86 // schema does not decode here arrives as raw text and fails the field's
87 // object schema — which takes the whole thread read down with it.
88 latestNotification: Schema.NullOr(
89 Schema.fromJsonString(OrchestrationThreadNotificationSummary),
90 ),
82 }),91 }),
83);92);
84const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(93const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
@@ -348,6 +357,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 263 unmodified lines
348 pending_approval_count AS "pendingApprovalCount",357 pending_approval_count AS "pendingApprovalCount",
349 pending_user_input_count AS "pendingUserInputCount",358 pending_user_input_count AS "pendingUserInputCount",
350 has_actionable_proposed_plan AS "hasActionableProposedPlan",359 has_actionable_proposed_plan AS "hasActionableProposedPlan",
360 latest_notification_json AS "latestNotification",
351 deleted_at AS "deletedAt"361 deleted_at AS "deletedAt"
352 FROM projection_threads362 FROM projection_threads
353 ORDER BY created_at ASC, thread_id ASC363 ORDER BY created_at ASC, thread_id ASC
@@ -380,6 +390,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 26 unmodified lines
380 pending_approval_count AS "pendingApprovalCount",390 pending_approval_count AS "pendingApprovalCount",
381 pending_user_input_count AS "pendingUserInputCount",391 pending_user_input_count AS "pendingUserInputCount",
382 has_actionable_proposed_plan AS "hasActionableProposedPlan",392 has_actionable_proposed_plan AS "hasActionableProposedPlan",
393 latest_notification_json AS "latestNotification",
383 deleted_at AS "deletedAt"394 deleted_at AS "deletedAt"
384 FROM projection_threads395 FROM projection_threads
385 WHERE deleted_at IS NULL396 WHERE deleted_at IS NULL
@@ -414,6 +425,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 28 unmodified lines
414 pending_approval_count AS "pendingApprovalCount",425 pending_approval_count AS "pendingApprovalCount",
415 pending_user_input_count AS "pendingUserInputCount",426 pending_user_input_count AS "pendingUserInputCount",
416 has_actionable_proposed_plan AS "hasActionableProposedPlan",427 has_actionable_proposed_plan AS "hasActionableProposedPlan",
428 latest_notification_json AS "latestNotification",
417 deleted_at AS "deletedAt"429 deleted_at AS "deletedAt"
418 FROM projection_threads430 FROM projection_threads
419 WHERE deleted_at IS NULL431 WHERE deleted_at IS NULL
@@ -434,6 +446,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 14 unmodified lines
434 role,446 role,
435 text,447 text,
436 attachments_json AS "attachments",448 attachments_json AS "attachments",
449 notification_json AS "notification",
437 is_streaming AS "isStreaming",450 is_streaming AS "isStreaming",
438 created_at AS "createdAt",451 created_at AS "createdAt",
439 updated_at AS "updatedAt"452 updated_at AS "updatedAt"
@@ -786,6 +799,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 346 unmodified lines
786 `,799 `,
787 });800 });
788 801
802 // Existence, not visibility: deliberately no `archived_at IS NULL`. Selects
803 // only the two columns it needs so it cannot be broken by an undecoded
804 // JSON column elsewhere in the row.
805 const getThreadArchiveStateRowById = SqlSchema.findOneOption({
806 Request: ThreadIdLookupInput,
807 Result: Schema.Struct({
808 archivedAt: Schema.NullOr(Schema.String),
809 deletedAt: Schema.NullOr(Schema.String),
810 }),
811 execute: ({ threadId }) =>
812 sql`
813 SELECT
814 archived_at AS "archivedAt",
815 deleted_at AS "deletedAt"
816 FROM projection_threads
817 WHERE thread_id = ${threadId}
818 LIMIT 1
819 `,
820 });
821
789 const getActiveThreadRowById = SqlSchema.findOneOption({822 const getActiveThreadRowById = SqlSchema.findOneOption({
790 Request: ThreadIdLookupInput,823 Request: ThreadIdLookupInput,
791 Result: ProjectionThreadDbRowSchema,824 Result: ProjectionThreadDbRowSchema,
@@ -812,6 +845,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 20 unmodified lines
812 pending_approval_count AS "pendingApprovalCount",845 pending_approval_count AS "pendingApprovalCount",
813 pending_user_input_count AS "pendingUserInputCount",846 pending_user_input_count AS "pendingUserInputCount",
814 has_actionable_proposed_plan AS "hasActionableProposedPlan",847 has_actionable_proposed_plan AS "hasActionableProposedPlan",
848 latest_notification_json AS "latestNotification",
815 deleted_at AS "deletedAt"849 deleted_at AS "deletedAt"
816 FROM projection_threads850 FROM projection_threads
817 WHERE thread_id = ${threadId}851 WHERE thread_id = ${threadId}
@@ -833,6 +867,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 15 unmodified lines
833 role,867 role,
834 text,868 text,
835 attachments_json AS "attachments",869 attachments_json AS "attachments",
870 notification_json AS "notification",
836 is_streaming AS "isStreaming",871 is_streaming AS "isStreaming",
837 created_at AS "createdAt",872 created_at AS "createdAt",
838 updated_at AS "updatedAt"873 updated_at AS "updatedAt"
@@ -1104,6 +1139,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 265 unmodified lines
1104 role: row.role,1139 role: row.role,
1105 text: row.text,1140 text: row.text,
1106 ...(row.attachments !== null ? { attachments: row.attachments } : {}),1141 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
1142 ...(row.notification !== null ? { notification: row.notification } : {}),
1107 turnId: row.turnId,1143 turnId: row.turnId,
1108 streaming: row.isStreaming === 1,1144 streaming: row.isStreaming === 1,
1109 createdAt: row.createdAt,1145 createdAt: row.createdAt,
@@ -1586,6 +1622,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 476 unmodified lines
1586 hasPendingApprovals: row.pendingApprovalCount > 0,1622 hasPendingApprovals: row.pendingApprovalCount > 0,
1587 hasPendingUserInput: row.pendingUserInputCount > 0,1623 hasPendingUserInput: row.pendingUserInputCount > 0,
1588 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,1624 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1625 latestNotification: row.latestNotification,
1589 } satisfies OrchestrationThreadShell)1626 } satisfies OrchestrationThreadShell)
1590 : Result.failVoid,1627 : Result.failVoid,
1591 ),1628 ),
@@ -1724,6 +1761,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 132 unmodified lines
1724 hasPendingApprovals: row.pendingApprovalCount > 0,1761 hasPendingApprovals: row.pendingApprovalCount > 0,
1725 hasPendingUserInput: row.pendingUserInputCount > 0,1762 hasPendingUserInput: row.pendingUserInputCount > 0,
1726 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,1763 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1764 latestNotification: row.latestNotification,
1727 }),1765 }),
1728 ),1766 ),
1729 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",1767 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
@@ -1946,6 +1984,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 216 unmodified lines
1946 });1984 });
1947 });1985 });
1948 1986
1987 const getThreadArchiveStateById: ProjectionSnapshotQueryShape["getThreadArchiveStateById"] = (
1988 threadId,
1989 ) =>
1990 getThreadArchiveStateRowById({ threadId }).pipe(
1991 Effect.mapError(
1992 toPersistenceSqlOrDecodeError(
1993 "ProjectionSnapshotQuery.getThreadArchiveStateById:query",
1994 "ProjectionSnapshotQuery.getThreadArchiveStateById:decodeRow",
1995 ),
1996 ),
1997 Effect.map(
1998 Option.flatMap((row) =>
1999 // A soft-deleted row is gone; an archived one is merely parked.
2000 row.deletedAt !== null
2001 ? Option.none<{ readonly archivedAt: string | null }>()
2002 : Option.some({ archivedAt: row.archivedAt }),
2003 ),
2004 ),
2005 );
2006
1949 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>2007 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
1950 Effect.gen(function* () {2008 Effect.gen(function* () {
1951 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([2009 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
@@ -2001,6 +2059,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 49 unmodified lines
2001 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,2059 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
2002 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,2060 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,
2003 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,2061 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,
2062 latestNotification: threadRow.value.latestNotification,
2004 } satisfies OrchestrationThreadShell);2063 } satisfies OrchestrationThreadShell);
2005 });2064 });
2006 2065
@@ -2096,7 +2155,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 89 unmodified lines
2096 snoozedAt: threadRow.value.snoozedAt,2155 snoozedAt: threadRow.value.snoozedAt,
2097 deletedAt: null,2156 deletedAt: null,
2098 messages: messageRows.map((row) => {2157 messages: messageRows.map((row) => {
2099 const message = {2158 const message: OrchestrationMessage = {
2100 id: row.messageId,2159 id: row.messageId,
2101 role: row.role,2160 role: row.role,
2102 text: row.text,2161 text: row.text,
@@ -2104,10 +2163,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 1 unmodified line
2104 streaming: row.isStreaming === 1,2163 streaming: row.isStreaming === 1,
2105 createdAt: row.createdAt,2164 createdAt: row.createdAt,
2106 updatedAt: row.updatedAt,2165 updatedAt: row.updatedAt,
2166 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
2167 ...(row.notification !== null ? { notification: row.notification } : {}),
2107 };2168 };
2108 if (row.attachments !== null) {
2109 return Object.assign(message, { attachments: row.attachments });
2110 }
2111 return message;2169 return message;
2112 }),2170 }),
2113 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),2171 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
@@ -2190,6 +2248,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 76 unmodified lines
2190 getThreadCheckpointContext,2248 getThreadCheckpointContext,
2191 getFullThreadDiffContext,2249 getFullThreadDiffContext,
2192 getThreadShellById,2250 getThreadShellById,
2251 getThreadArchiveStateById,
2193 getThreadDetailById,2252 getThreadDetailById,
2194 getThreadDetailSnapshot,2253 getThreadDetailSnapshot,
2195 } satisfies ProjectionSnapshotQueryShape;2254 } satisfies ProjectionSnapshotQueryShape;
apps/server/src/orchestration/Schemas.ts+2−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema,19 ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema,
20 ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema,20 ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema,
21 ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema,21 ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema,
22 ThreadNotificationDeliveredPayload as ContractsThreadNotificationDeliveredPayloadSchema,
22 ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema,23 ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema,
23 ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema,24 ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema,
24 ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema,25 ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema,
@@ -49,6 +50,7 @@ export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; · 24 unmodified lines
49export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;50export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;
50export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema;51export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema;
51export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema;52export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema;
53export const ThreadNotificationDeliveredPayload = ContractsThreadNotificationDeliveredPayloadSchema;
52 54
53export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema;55export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema;
54export const ThreadTurnInterruptRequestedPayload =56export const ThreadTurnInterruptRequestedPayload =
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+18−0
@@ -165,6 +165,24 @@ export interface ProjectionSnapshotQueryShape { · 164 unmodified lines
165 threadId: ThreadId,165 threadId: ThreadId,
166 ) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>;166 ) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>;
167 167
168 /**
169 * Answer "does this thread row exist", independent of archive state.
170 *
171 * Every other thread read here filters `archived_at IS NULL`, which is right
172 * for "what should the user see" and wrong for existence: an archived thread
173 * is parked, not gone. Callers holding a durable reference to a specific
174 * thread — the Hermes home-thread designation — need this distinction, or
175 * archiving reads as deletion and they replace a thread the user still has.
176 *
177 * A soft-deleted row still reports absent.
178 */
179 readonly getThreadArchiveStateById: (
180 threadId: ThreadId,
181 ) => Effect.Effect<
182 Option.Option<{ readonly archivedAt: string | null }>,
183 ProjectionRepositoryError
184 >;
185
168 /**186 /**
169 * Read a single active thread detail snapshot by id.187 * Read a single active thread detail snapshot by id.
170 */188 */
apps/server/src/orchestration/agentProjects.test.ts+1−0
@@ -68,6 +68,7 @@ const makeQueryLayer = ( · 67 unmodified lines
68 getThreadCheckpointContext: () => Effect.die("unused"),68 getThreadCheckpointContext: () => Effect.die("unused"),
69 getFullThreadDiffContext: () => Effect.die("unused"),69 getFullThreadDiffContext: () => Effect.die("unused"),
70 getThreadShellById: () => Effect.die("unused"),70 getThreadShellById: () => Effect.die("unused"),
71 getThreadArchiveStateById: () => Effect.die("unused"),
71 getThreadDetailById: () => Effect.die("unused"),72 getThreadDetailById: () => Effect.die("unused"),
72 getThreadDetailSnapshot: () => Effect.die("unused"),73 getThreadDetailSnapshot: () => Effect.die("unused"),
73 });74 });
apps/server/src/orchestration/decider.notification.test.ts+237−0
@@ -0,0 +1,237 @@
1import {
2 CommandId,
3 MessageId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 type OrchestrationReadModel,
8 type OrchestrationThread,
9} from "@t3tools/contracts";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13
14import { decideOrchestrationCommand } from "./decider.ts";
15
16const NOW = "2026-07-01T00:00:00.000Z";
17const DELIVERED_AT = "2026-06-30T09:00:00.000Z";
18const SETTLED_AT = "2026-06-29T00:00:00.000Z";
19const SNOOZED_UNTIL = "2026-07-02T00:00:00.000Z";
20
21function makeReadModel(input: {
22 readonly settledOverride?: OrchestrationThread["settledOverride"];
23 readonly snoozedUntil?: string | null;
24 readonly messages?: OrchestrationThread["messages"];
25}): OrchestrationReadModel {
26 const settledOverride = input.settledOverride ?? null;
27 return {
28 snapshotSequence: 0,
29 projects: [],
30 threads: [
31 {
32 id: ThreadId.make("thread-1"),
33 projectId: ProjectId.make("project-1"),
34 title: "Home",
35 modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "hermes" },
36 runtimeMode: "full-access",
37 interactionMode: "default",
38 branch: null,
39 worktreePath: null,
40 latestTurn: null,
41 createdAt: NOW,
42 updatedAt: NOW,
43 archivedAt: null,
44 settledOverride,
45 settledAt: settledOverride === "settled" ? SETTLED_AT : null,
46 snoozedUntil: input.snoozedUntil ?? null,
47 snoozedAt: input.snoozedUntil != null ? SETTLED_AT : null,
48 deletedAt: null,
49 messages: input.messages ?? [],
50 proposedPlans: [],
51 activities: [],
52 checkpoints: [],
53 session: null,
54 },
55 ],
56 updatedAt: NOW,
57 };
58}
59
60function deliverCommand(overrides: {
61 readonly commandId: string;
62 readonly kind?: "cron" | "message" | "lifecycle" | "handoff" | "other";
63 readonly deliveryId?: string;
64 readonly messageId?: string;
65 readonly text?: string;
66}) {
67 return {
68 type: "thread.notification.deliver",
69 commandId: CommandId.make(overrides.commandId),
70 threadId: ThreadId.make("thread-1"),
71 messageId: MessageId.make(overrides.messageId ?? "message-1"),
72 deliveryId: overrides.deliveryId ?? "delivery-1",
73 kind: overrides.kind ?? "cron",
74 label: "Cron: daily-digest",
75 text: overrides.text ?? "Digest ready.",
76 createdAt: NOW,
77 } as const;
78}
79
80const deliveredMessage: OrchestrationThread["messages"][number] = {
81 id: MessageId.make("message-1"),
82 role: "assistant",
83 text: "Digest ready.",
84 turnId: null,
85 streaming: false,
86 notification: {
87 kind: "cron",
88 label: "Cron: daily-digest",
89 deliveryId: "delivery-1",
90 },
91 createdAt: DELIVERED_AT,
92 updatedAt: DELIVERED_AT,
93};
94
95it.layer(NodeServices.layer)("notification delivery decider", (it) => {
96 it.effect("appends a turnless notification message and un-settles the thread", () =>
97 Effect.gen(function* () {
98 const result = yield* decideOrchestrationCommand({
99 command: deliverCommand({ commandId: "cmd-deliver" }),
100 readModel: makeReadModel({ settledOverride: "settled" }),
101 });
102 const events = Array.isArray(result) ? result : [result];
103 expect(events.map((event) => event.type)).toEqual([
104 "thread.unsettled",
105 "thread.notification-delivered",
106 ]);
107
108 const unsettled = events[0];
109 if (unsettled?.type === "thread.unsettled") {
110 expect(unsettled.payload.reason).toBe("activity");
111 }
112
113 const delivered = events[1];
114 if (delivered?.type === "thread.notification-delivered") {
115 expect(delivered.payload.messageId).toBe("message-1");
116 expect(delivered.payload.text).toBe("Digest ready.");
117 expect(delivered.payload.notification).toEqual({
118 kind: "cron",
119 label: "Cron: daily-digest",
120 deliveryId: "delivery-1",
121 });
122 // Nothing about a delivery belongs to a turn; attributing it to the
123 // thread's live turn would fold it behind that turn's summary.
124 expect("turnId" in delivered.payload).toBe(false);
125 }
126 }),
127 );
128
129 it.effect("un-snoozes as well as un-settles, and does neither when neither applies", () =>
130 Effect.gen(function* () {
131 const both = yield* decideOrchestrationCommand({
132 command: deliverCommand({ commandId: "cmd-deliver-both" }),
133 readModel: makeReadModel({ settledOverride: "settled", snoozedUntil: SNOOZED_UNTIL }),
134 });
135 expect((Array.isArray(both) ? both : [both]).map((event) => event.type)).toEqual([
136 "thread.unsettled",
137 "thread.unsnoozed",
138 "thread.notification-delivered",
139 ]);
140
141 const neither = yield* decideOrchestrationCommand({
142 command: deliverCommand({ commandId: "cmd-deliver-neither" }),
143 readModel: makeReadModel({}),
144 });
145 expect((Array.isArray(neither) ? neither : [neither]).map((event) => event.type)).toEqual([
146 "thread.notification-delivered",
147 ]);
148 }),
149 );
150
151 it.effect("appends lifecycle notices quietly, leaving settled and snoozed state alone", () =>
152 Effect.gen(function* () {
153 const result = yield* decideOrchestrationCommand({
154 command: deliverCommand({ commandId: "cmd-lifecycle", kind: "lifecycle" }),
155 readModel: makeReadModel({ settledOverride: "settled", snoozedUntil: SNOOZED_UNTIL }),
156 });
157 const events = Array.isArray(result) ? result : [result];
158 // Unread indicator only: a restart notice must not drag a thread the
159 // user has parked back into the inbox.
160 expect(events.map((event) => event.type)).toEqual(["thread.notification-delivered"]);
161 }),
162 );
163
164 it.effect("dedupes a replayed deliveryId without appending or waking the thread", () =>
165 Effect.gen(function* () {
166 const result = yield* decideOrchestrationCommand({
167 // The plugin's retry queue resends with a fresh command id, a new
168 // message id, and possibly edited text — only deliveryId is stable.
169 command: deliverCommand({
170 commandId: "cmd-deliver-retry",
171 messageId: "message-retry",
172 text: "Digest ready (retry).",
173 }),
174 readModel: makeReadModel({
175 settledOverride: "settled",
176 snoozedUntil: SNOOZED_UNTIL,
177 messages: [deliveredMessage],
178 }),
179 });
180 const events = Array.isArray(result) ? result : [result];
181
182 // No un-settle/un-snooze: a replay is not new activity.
183 expect(events.map((event) => event.type)).toEqual(["thread.notification-delivered"]);
184
185 const delivered = events[0];
186 if (delivered?.type === "thread.notification-delivered") {
187 // Re-emitted from the persisted row, so reducing it a second time is
188 // a byte-identical no-op rather than a duplicate or an overwrite.
189 expect(delivered.payload.messageId).toBe("message-1");
190 expect(delivered.payload.text).toBe("Digest ready.");
191 expect(delivered.payload.createdAt).toBe(DELIVERED_AT);
192 expect(delivered.payload.updatedAt).toBe(DELIVERED_AT);
193 expect(delivered.occurredAt).toBe(DELIVERED_AT);
194 }
195 }),
196 );
197
198 it.effect("treats a different deliveryId on the same thread as a new delivery", () =>
199 Effect.gen(function* () {
200 const result = yield* decideOrchestrationCommand({
201 command: deliverCommand({
202 commandId: "cmd-deliver-second",
203 deliveryId: "delivery-2",
204 messageId: "message-2",
205 text: "Second digest.",
206 }),
207 readModel: makeReadModel({
208 settledOverride: "settled",
209 messages: [deliveredMessage],
210 }),
211 });
212 const events = Array.isArray(result) ? result : [result];
213 expect(events.map((event) => event.type)).toEqual([
214 "thread.unsettled",
215 "thread.notification-delivered",
216 ]);
217 const delivered = events[1];
218 if (delivered?.type === "thread.notification-delivered") {
219 expect(delivered.payload.messageId).toBe("message-2");
220 expect(delivered.payload.notification.deliveryId).toBe("delivery-2");
221 }
222 }),
223 );
224
225 it.effect("rejects a delivery for a thread that does not exist", () =>
226 Effect.gen(function* () {
227 const error = yield* decideOrchestrationCommand({
228 command: {
229 ...deliverCommand({ commandId: "cmd-deliver-missing" }),
230 threadId: ThreadId.make("thread-missing"),
231 },
232 readModel: makeReadModel({}),
233 }).pipe(Effect.flip);
234 expect(error._tag).toBe("OrchestrationCommandInvariantError");
235 }),
236 );
237});
apps/server/src/orchestration/decider.ts+96−0
@@ -1197,6 +1197,102 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 1196 unmodified lines
1197 return [unsettledEvent, activityAppendedEvent];1197 return [unsettledEvent, activityAppendedEvent];
1198 }1198 }
1199 1199
1200 case "thread.notification.deliver": {
1201 const thread = yield* requireThread({
1202 readModel,
1203 command,
1204 threadId: command.threadId,
1205 });
1206 // Dedupe on deliveryId: the plugin's durable queue retries anything it
1207 // has not seen acked, so the same delivery legitimately arrives more
1208 // than once. Idempotency is by re-emission (see thread.settle) because
1209 // the engine rejects a command that produces no events — but unlike
1210 // settle, the payload must be rebuilt from the ALREADY-WRITTEN message
1211 // rather than from the command, so reducing it a second time lands on
1212 // byte-identical state instead of rewriting the row from a retry whose
1213 // messageId or text may differ.
1214 //
1215 // NOT SUFFICIENT ON ITS OWN. This scans the in-memory read model, which
1216 // `getCommandReadModel` builds with `messages: []` on every thread — it
1217 // deliberately does not hydrate message bodies at boot. So this catches
1218 // only replays of deliveries appended since the process started, and
1219 // sees nothing after a restart. The delivery path re-checks against
1220 // `projection_thread_messages` before dispatching
1221 // (`hermesGatewayHttp.ts`), and that read is what actually holds across
1222 // a restart. Keep both: this one keeps the aggregate self-consistent for
1223 // any future caller, the other one is load-bearing.
1224 const duplicate = thread.messages.find(
1225 (message) => message.notification?.deliveryId === command.deliveryId,
1226 );
1227 const notificationEvent: Omit<OrchestrationEvent, "sequence"> = {
1228 ...(yield* withEventBase({
1229 aggregateKind: "thread",
1230 aggregateId: command.threadId,
1231 occurredAt: duplicate?.createdAt ?? command.createdAt,
1232 commandId: command.commandId,
1233 })),
1234 type: "thread.notification-delivered",
1235 payload: {
1236 threadId: command.threadId,
1237 messageId: duplicate?.id ?? command.messageId,
1238 text: duplicate?.text ?? command.text,
1239 notification: duplicate?.notification ?? {
1240 kind: command.kind,
1241 label: command.label,
1242 deliveryId: command.deliveryId,
1243 },
1244 createdAt: duplicate?.createdAt ?? command.createdAt,
1245 updatedAt: duplicate?.updatedAt ?? command.createdAt,
1246 },
1247 };
1248 // A replay is not new activity: it must never wake a thread the user
1249 // has since settled or snoozed.
1250 if (duplicate !== undefined) {
1251 return notificationEvent;
1252 }
1253 // A lifecycle notice ("agent restarted") is bookkeeping, not a claim on
1254 // the user's attention: it appends quietly and leaves a settled or
1255 // snoozed thread where it is. Every other kind is the agent raising its
1256 // hand and resets the lifecycle exactly like a user message does.
1257 if (command.kind === "lifecycle") {
1258 return notificationEvent;
1259 }
1260 const lifecycleResetEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
1261 if (thread.settledOverride !== null) {
1262 lifecycleResetEvents.push({
1263 ...(yield* withEventBase({
1264 aggregateKind: "thread",
1265 aggregateId: command.threadId,
1266 occurredAt: command.createdAt,
1267 commandId: command.commandId,
1268 })),
1269 type: "thread.unsettled",
1270 payload: {
1271 threadId: command.threadId,
1272 reason: "activity",
1273 updatedAt: command.createdAt,
1274 },
1275 });
1276 }
1277 if (thread.snoozedUntil != null) {
1278 lifecycleResetEvents.push({
1279 ...(yield* withEventBase({
1280 aggregateKind: "thread",
1281 aggregateId: command.threadId,
1282 occurredAt: command.createdAt,
1283 commandId: command.commandId,
1284 })),
1285 type: "thread.unsnoozed",
1286 payload: {
1287 threadId: command.threadId,
1288 reason: "activity",
1289 updatedAt: command.createdAt,
1290 },
1291 });
1292 }
1293 return [...lifecycleResetEvents, notificationEvent];
1294 }
1295
1200 default: {1296 default: {
1201 command satisfies never;1297 command satisfies never;
1202 const fallback = command as never as { type: string };1298 const fallback = command as never as { type: string };
apps/server/src/orchestration/homeThreads.test.ts+408−0
@@ -0,0 +1,408 @@
1import { assert, it } from "@effect/vitest";
2import {
3 DEFAULT_HERMES_MODEL,
4 HERMES_DRIVER_KIND,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationCommand,
9 type OrchestrationProject,
10 type OrchestrationThreadShell,
11} from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import * as Stream from "effect/Stream";
18
19import { ServerConfig } from "../config.ts";
20import * as ServerSettings from "../serverSettings.ts";
21import { OrchestrationCommandInvariantError } from "./Errors.ts";
22import {
23 HOME_THREAD_TITLE,
24 getDesignatedHomeThreadId,
25 getOrCreateHomeThread,
26 isHomeThread,
27 readHomeThreadId,
28} from "./homeThreads.ts";
29import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
30import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
31
32const INSTANCE_ID = ProviderInstanceId.make("hermes-siva-local-abc123");
33const OTHER_INSTANCE_ID = ProviderInstanceId.make("hermes-other-def456");
34const HOME_THREAD_ID = ThreadId.make("thread-home-1");
35const BASE_DIR = "/tmp/t3-home-threads-test";
36
37const agentProject: OrchestrationProject = {
38 id: ProjectId.make("agent-project-1"),
39 title: "Hermes Siva Local",
40 workspaceRoot: `${BASE_DIR}/agents/${INSTANCE_ID}`,
41 repositoryIdentity: null,
42 defaultModelSelection: null,
43 scripts: [],
44 agentInstanceId: INSTANCE_ID,
45 createdAt: "2026-01-01T00:00:00.000Z",
46 updatedAt: "2026-01-01T00:00:00.000Z",
47 deletedAt: null,
48};
49
50const threadShell = (id: ThreadId): OrchestrationThreadShell =>
51 ({
52 id,
53 projectId: agentProject.id,
54 title: HOME_THREAD_TITLE,
55 modelSelection: { instanceId: INSTANCE_ID, model: DEFAULT_HERMES_MODEL },
56 runtimeMode: "full-access",
57 interactionMode: "default",
58 branch: null,
59 worktreePath: null,
60 latestTurn: null,
61 createdAt: "2026-01-01T00:00:00.000Z",
62 updatedAt: "2026-01-01T00:00:00.000Z",
63 archivedAt: null,
64 settledOverride: null,
65 settledAt: null,
66 session: null,
67 latestUserMessageAt: null,
68 hasPendingApprovals: false,
69 hasPendingUserInput: false,
70 hasActionableProposedPlan: false,
71 }) as OrchestrationThreadShell;
72
73const hermesInstance = (config: Record<string, unknown>) => ({
74 driver: HERMES_DRIVER_KIND,
75 displayName: "Hermes Siva Local",
76 enabled: true,
77 config,
78});
79
80/**
81 * Query stub answering thread reads from a mutable queue, so a test can model
82 * "designated but deleted" — the self-healing path this module exists for.
83 */
84const makeQueryLayer = (
85 threads: Ref.Ref<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>,
86 archiveStates?: ReadonlyArray<Option.Option<{ readonly archivedAt: string | null }>>,
87) =>
88 Layer.succeed(ProjectionSnapshotQuery, {
89 getCommandReadModel: () => Effect.die("unused"),
90 getSnapshot: () => Effect.die("unused"),
91 getShellSnapshot: () => Effect.die("unused"),
92 getArchivedShellSnapshot: () => Effect.die("unused"),
93 getSnapshotSequence: () => Effect.die("unused"),
94 getCounts: () => Effect.die("unused"),
95 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
96 getActiveAgentProject: () => Effect.succeed(Option.some(agentProject)),
97 getProjectShellById: () => Effect.die("unused"),
98 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
99 getThreadCheckpointContext: () => Effect.die("unused"),
100 getFullThreadDiffContext: () => Effect.die("unused"),
101 getThreadArchiveStateById: () =>
102 Effect.gen(function* () {
103 if (archiveStates?.[0] !== undefined) return archiveStates[0];
104 // Default: mirror the shell queue, since a thread visible to the shell
105 // query is also present. Tests about archiving pass this explicitly.
106 const queue = yield* Ref.get(threads);
107 return Option.map(queue[0] ?? Option.none(), () => ({ archivedAt: null }));
108 }),
109 getThreadShellById: () =>
110 Effect.gen(function* () {
111 const queue = yield* Ref.get(threads);
112 const [head, ...rest] = queue;
113 if (head === undefined) return Option.none<OrchestrationThreadShell>();
114 yield* Ref.set(threads, rest.length > 0 ? rest : [head]);
115 return head;
116 }),
117 getThreadDetailById: () => Effect.die("unused"),
118 getThreadDetailSnapshot: () => Effect.die("unused"),
119 } as unknown as ProjectionSnapshotQuery["Service"]);
120
121const makeEngineLayer = (
122 dispatched: Ref.Ref<ReadonlyArray<OrchestrationCommand>>,
123 options: { readonly fail?: boolean } = {},
124) =>
125 Layer.succeed(OrchestrationEngine.OrchestrationEngineService, {
126 readEvents: () => Stream.empty,
127 dispatch: (command: OrchestrationCommand) =>
128 Ref.update(dispatched, (calls) => [...calls, command]).pipe(
129 Effect.andThen(
130 options.fail
131 ? Effect.fail(
132 new OrchestrationCommandInvariantError({
133 commandType: "thread.create",
134 detail: "Simulated dispatch failure.",
135 }),
136 )
137 : Effect.succeed({ sequence: 1 }),
138 ),
139 ),
140 streamDomainEvents: Stream.empty,
141 latestSequence: Effect.succeed(0),
142 } as OrchestrationEngine.OrchestrationEngineService["Service"]);
143
144const configLayer = Layer.succeed(ServerConfig, {
145 baseDir: BASE_DIR,
146} as unknown as ServerConfig["Service"]);
147
148const testLayer = (input: {
149 readonly threads: Ref.Ref<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>;
150 readonly dispatched: Ref.Ref<ReadonlyArray<OrchestrationCommand>>;
151 readonly providerInstances: Record<string, unknown>;
152 readonly failDispatch?: boolean;
153 /**
154 * Archive-state rows. Unlike the shell queue these ignore archive state, so
155 * a test can model "archived, therefore invisible to the shell query but
156 * still very much present" — the case that used to mint a second Home.
157 */
158 readonly archiveStates?: ReadonlyArray<Option.Option<{ readonly archivedAt: string | null }>>;
159}) =>
160 Layer.mergeAll(
161 makeQueryLayer(input.threads, input.archiveStates),
162 makeEngineLayer(input.dispatched, input.failDispatch ? { fail: true } : {}),
163 configLayer,
164 ServerSettings.layerTest({
165 providerInstances: input.providerInstances,
166 } as never),
167 NodeServices.layer,
168 );
169
170it("reads a designation only from a Hermes envelope", () => {
171 assert.equal(
172 readHomeThreadId(hermesInstance({ homeThreadId: HOME_THREAD_ID }) as never),
173 HOME_THREAD_ID,
174 );
175 assert.equal(readHomeThreadId(hermesInstance({}) as never), undefined);
176 // An empty string is "not designated", not a thread whose id is "".
177 assert.equal(readHomeThreadId(hermesInstance({ homeThreadId: "" }) as never), undefined);
178 // A designation on a non-Hermes envelope is not ours to honour.
179 assert.equal(
180 readHomeThreadId({ driver: "codex", homeThreadId: HOME_THREAD_ID } as never),
181 undefined,
182 );
183});
184
185it.effect("returns the designated thread without dispatching anything", () =>
186 Effect.gen(function* () {
187 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
188 Option.some(threadShell(HOME_THREAD_ID)),
189 ]);
190 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
191
192 const resolved = yield* getOrCreateHomeThread({
193 instanceId: INSTANCE_ID,
194 title: "Hermes Siva Local",
195 }).pipe(
196 Effect.provide(
197 testLayer({
198 threads,
199 dispatched,
200 providerInstances: {
201 [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
202 },
203 }),
204 ),
205 );
206
207 assert.equal(resolved, HOME_THREAD_ID);
208 // This runs on every handshake; it must not create anything on the happy path.
209 assert.deepEqual(yield* Ref.get(dispatched), []);
210 }),
211);
212
213it.effect("creates and persists a Home thread when none is designated", () =>
214 Effect.gen(function* () {
215 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
216 Option.none(),
217 ]);
218 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
219
220 yield* Effect.gen(function* () {
221 const resolved = yield* getOrCreateHomeThread({
222 instanceId: INSTANCE_ID,
223 title: "Hermes Siva Local",
224 });
225
226 const commands = yield* Ref.get(dispatched);
227 const created = commands.find((command) => command.type === "thread.create");
228 assert.isDefined(created);
229 if (created?.type === "thread.create") {
230 assert.equal(created.threadId, resolved);
231 assert.equal(created.title, HOME_THREAD_TITLE);
232 assert.equal(created.projectId, agentProject.id);
233 // Binds to the fixed Hermes slug, not to whatever model the plugin
234 // currently reports — otherwise the thread orphans on a model change.
235 assert.equal(created.modelSelection.model, DEFAULT_HERMES_MODEL);
236 }
237
238 // The designation must be durable, or the next handshake mints a second
239 // Home and the first one's history is stranded.
240 assert.equal(yield* getDesignatedHomeThreadId(INSTANCE_ID), resolved);
241 }).pipe(
242 Effect.provide(
243 testLayer({
244 threads,
245 dispatched,
246 providerInstances: { [INSTANCE_ID]: hermesInstance({}) },
247 }),
248 ),
249 );
250 }),
251);
252
253it.effect("self-heals a designation whose thread no longer exists", () =>
254 Effect.gen(function* () {
255 // A deleted home thread must not strand the instance: it re-designates
256 // rather than failing every future delivery.
257 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
258 Option.none(),
259 ]);
260 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
261
262 const resolved = yield* getOrCreateHomeThread({
263 instanceId: INSTANCE_ID,
264 title: "Hermes Siva Local",
265 }).pipe(
266 Effect.provide(
267 testLayer({
268 threads,
269 dispatched,
270 providerInstances: {
271 [INSTANCE_ID]: hermesInstance({ homeThreadId: "thread-deleted-long-ago" }),
272 },
273 }),
274 ),
275 );
276
277 assert.notEqual(resolved, "thread-deleted-long-ago");
278 const commands = yield* Ref.get(dispatched);
279 assert.isDefined(commands.find((command) => command.type === "thread.create"));
280 }),
281);
282
283it.effect("adopts a racing caller's designation when its own dispatch fails", () =>
284 Effect.gen(function* () {
285 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
286 Option.none(),
287 ]);
288 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
289
290 // The loser of the race sees a dispatch failure but must still return the
291 // winner's thread rather than surfacing an error to the handshake.
292 const resolved = yield* getOrCreateHomeThread({
293 instanceId: INSTANCE_ID,
294 title: "Hermes Siva Local",
295 }).pipe(
296 Effect.provide(
297 testLayer({
298 threads,
299 dispatched,
300 failDispatch: true,
301 providerInstances: {
302 // Designated, but the thread read says gone — so it falls through
303 // to create, fails, and re-reads the designation.
304 [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
305 },
306 }),
307 ),
308 );
309
310 assert.equal(resolved, HOME_THREAD_ID);
311 }),
312);
313
314it.effect("recognizes any instance's home thread as undeletable", () =>
315 Effect.gen(function* () {
316 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([]);
317 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
318
319 yield* Effect.gen(function* () {
320 assert.isTrue(yield* isHomeThread(HOME_THREAD_ID));
321 // Scans every instance, not just one — the delete path knows only a thread.
322 assert.isTrue(yield* isHomeThread(ThreadId.make("thread-home-2")));
323 assert.isFalse(yield* isHomeThread(ThreadId.make("thread-ordinary")));
324 }).pipe(
325 Effect.provide(
326 testLayer({
327 threads,
328 dispatched,
329 providerInstances: {
330 [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
331 [OTHER_INSTANCE_ID]: hermesInstance({ homeThreadId: "thread-home-2" }),
332 },
333 }),
334 ),
335 );
336 }),
337);
338
339it.effect("keeps an archived Home rather than minting a replacement", () =>
340 Effect.gen(function* () {
341 // The bug this pins: `getThreadShellById` filters `archived_at IS NULL`, so
342 // an archived Home read as *deleted* and the self-healing path created a
343 // second one — stranding the real history and silently re-pointing the
344 // designation at an empty thread.
345 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
346 Option.none(),
347 ]);
348 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
349
350 const resolved = yield* getOrCreateHomeThread({
351 instanceId: INSTANCE_ID,
352 title: "Hermes Siva Local",
353 }).pipe(
354 Effect.provide(
355 testLayer({
356 threads,
357 dispatched,
358 archiveStates: [Option.some({ archivedAt: "2026-01-02T00:00:00.000Z" })],
359 providerInstances: {
360 [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
361 },
362 }),
363 ),
364 );
365
366 assert.equal(resolved, HOME_THREAD_ID);
367 const commands = yield* Ref.get(dispatched);
368 assert.isUndefined(
369 commands.find((command) => command.type === "thread.create"),
370 "an archived Home must never be replaced by a new thread",
371 );
372 // A delivery is the agent raising its hand, so it brings the thread back
373 // rather than landing somewhere the user cannot see.
374 const unarchive = commands.find((command) => command.type === "thread.unarchive");
375 assert.isDefined(unarchive);
376 if (unarchive?.type === "thread.unarchive") {
377 assert.equal(unarchive.threadId, HOME_THREAD_ID);
378 }
379 }),
380);
381
382it.effect("does not un-archive a Home that was never archived", () =>
383 Effect.gen(function* () {
384 const threads = yield* Ref.make<ReadonlyArray<Option.Option<OrchestrationThreadShell>>>([
385 Option.some(threadShell(HOME_THREAD_ID)),
386 ]);
387 const dispatched = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
388
389 yield* getOrCreateHomeThread({
390 instanceId: INSTANCE_ID,
391 title: "Hermes Siva Local",
392 }).pipe(
393 Effect.provide(
394 testLayer({
395 threads,
396 dispatched,
397 archiveStates: [Option.some({ archivedAt: null })],
398 providerInstances: {
399 [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }),
400 },
401 }),
402 ),
403 );
404
405 // The common path stays free of writes; this runs on every handshake.
406 assert.deepEqual(yield* Ref.get(dispatched), []);
407 }),
408);
apps/server/src/orchestration/homeThreads.ts+242−0
@@ -0,0 +1,242 @@
1/**
2 * Home threads — the one thread per agent instance that receives its agent's
3 * proactive output.
4 *
5 * Hermes has a first-class "home channel" concept: a per-platform default
6 * destination for output nobody asked for at a specific address — a cron job's
7 * result, an agent-initiated `send_message`, a gateway online notice, a
8 * `/handoff`. Every other Hermes surface designates one interactively via
9 * `/sethome`. T3 designates one automatically instead, because there is no
10 * "current channel" here for a user to point at: threads are created on
11 * demand, and asking someone to pick one before their first cron job can fire
12 * is setup for a decision they have no basis to make.
13 *
14 * ## Converge-on-read, like agent projects
15 *
16 * `getOrCreateHomeThread` is a **precondition every caller runs**, not a
17 * lifecycle event one caller owns — the same reasoning as
18 * `getOrCreateAgentProject`, and for the same failure modes: an instance
19 * enrolled before this shipped has no designation, a dispatch that failed once
20 * would otherwise leave the instance permanently unable to receive, and a
21 * thread deleted out from under the designation would strand every future
22 * delivery. Each of those self-heals on the next handshake.
23 *
24 * ## Why settings and not a table
25 *
26 * The designation is per-instance enrollment metadata, exactly like
27 * `connectorUrl` and `revoked`, and it lives beside them in the Hermes
28 * instance's config blob. That keeps one source of truth for "facts about this
29 * enrollment" and avoids a migration for a single nullable id.
30 *
31 * The plugin's `T3_HOME_CHANNEL` env var is a *cache* of this value, reconciled
32 * on every `connection.accepted`. This module is authoritative; drift is
33 * bounded by one reconnect.
34 *
35 * @module orchestration/homeThreads
36 */
37import {
38 CommandId,
39 DEFAULT_HERMES_MODEL,
40 DEFAULT_PROVIDER_INTERACTION_MODE,
41 HERMES_DRIVER_KIND,
42 ThreadId,
43 type ProviderInstanceConfig,
44 type ProviderInstanceId,
45} from "@t3tools/contracts";
46import * as Cause from "effect/Cause";
47import * as Crypto from "effect/Crypto";
48import * as DateTime from "effect/DateTime";
49import * as Effect from "effect/Effect";
50import * as Option from "effect/Option";
51
52import { ServerSettingsService } from "../serverSettings.ts";
53import { getOrCreateAgentProject } from "./agentProjects.ts";
54import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
55import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
56
57/**
58 * Title of the home thread. Plain, no emoji: it sits in the same list as the
59 * user's own threads and should not shout.
60 */
61export const HOME_THREAD_TITLE = "Home";
62
63/** Read the designated home thread id out of a Hermes instance envelope. */
64export const readHomeThreadId = (
65 config: ProviderInstanceConfig | undefined,
66): ThreadId | undefined => {
67 if (!config || config.driver !== HERMES_DRIVER_KIND) return undefined;
68 const blob = config.config && typeof config.config === "object" ? config.config : {};
69 const raw = (blob as Record<string, unknown>).homeThreadId;
70 return typeof raw === "string" && raw !== "" ? ThreadId.make(raw) : undefined;
71};
72
73/**
74 * Read one instance's designation straight from settings.
75 *
76 * Callers that only need to *know* the designation — the snapshot builder, the
77 * delete guard — use this rather than `getOrCreateHomeThread`, so a read never
78 * has the side effect of creating a thread.
79 */
80export const getDesignatedHomeThreadId = Effect.fn("getDesignatedHomeThreadId")(function* (
81 instanceId: ProviderInstanceId,
82) {
83 const settings = yield* ServerSettingsService;
84 const current = yield* settings.getSettings;
85 return readHomeThreadId(current.providerInstances[instanceId]);
86});
87
88/**
89 * Whether a thread is some instance's home thread, and therefore undeletable.
90 *
91 * Scans every configured instance rather than taking an instance id, because
92 * the delete path knows only the thread. The map is small — one entry per
93 * configured provider instance — and this runs once per delete.
94 */
95export const isHomeThread = Effect.fn("isHomeThread")(function* (threadId: ThreadId) {
96 const settings = yield* ServerSettingsService;
97 const current = yield* settings.getSettings;
98 for (const config of Object.values(current.providerInstances)) {
99 if (readHomeThreadId(config) === threadId) return true;
100 }
101 return false;
102});
103
104/**
105 * Read the designated thread's row regardless of archive state.
106 *
107 * The snapshot query's thread reads filter `archived_at IS NULL`, which is
108 * correct for "what should the user see" and wrong for "does this row exist".
109 * Going to the repository directly is what keeps an archived Home from looking
110 * deleted — and thereby keeps this module from minting a second one. A
111 * soft-deleted row still counts as gone.
112 */
113const readHomeThreadRow = Effect.fn("readHomeThreadRow")(function* (threadId: ThreadId) {
114 const query = yield* ProjectionSnapshotQuery;
115 const row = yield* query.getThreadArchiveStateById(threadId);
116 return Option.getOrUndefined(row);
117});
118
119/** Persist a designation into the instance's Hermes config blob. */
120const persistHomeThreadId = (input: {
121 readonly instanceId: ProviderInstanceId;
122 readonly threadId: ThreadId;
123}) =>
124 Effect.gen(function* () {
125 const settings = yield* ServerSettingsService;
126 yield* settings.updateSettingsWith((latest) => {
127 // Re-read under the settings write lock: the envelope may have been
128 // replaced (or the driver changed) between our read and this write, and
129 // writing a Hermes designation onto a non-Hermes envelope would corrupt
130 // it. Returning an empty patch is the established no-op here.
131 const existing = latest.providerInstances[input.instanceId];
132 if (!existing || existing.driver !== HERMES_DRIVER_KIND) return {};
133 const currentConfig =
134 existing.config && typeof existing.config === "object"
135 ? (existing.config as Record<string, unknown>)
136 : {};
137 return {
138 providerInstances: {
139 ...latest.providerInstances,
140 [input.instanceId]: {
141 ...existing,
142 config: { ...currentConfig, homeThreadId: input.threadId },
143 },
144 },
145 };
146 });
147 });
148
149/**
150 * Resolve the home thread for one provider instance, creating it if absent.
151 *
152 * Safe to call on every handshake: the common path is one settings read plus
153 * one indexed thread read. Idempotent, and concurrent callers converge —
154 * the loser of a race re-reads and adopts the winner's thread.
155 */
156export const getOrCreateHomeThread = Effect.fn("getOrCreateHomeThread")(function* (input: {
157 readonly instanceId: ProviderInstanceId;
158 /** Instance nickname, used as the agent project's title on first creation. */
159 readonly title: string;
160}) {
161 const engine = yield* OrchestrationEngineService;
162 const crypto = yield* Crypto.Crypto;
163
164 const designated = yield* getDesignatedHomeThreadId(input.instanceId);
165 if (designated !== undefined) {
166 // Deliberately NOT `getThreadShellById`: that query filters
167 // `archived_at IS NULL`, so an archived Home reads as absent and this
168 // function would "self-heal" by minting a second Home — stranding the
169 // history in the archived one and silently re-pointing the designation.
170 // Archiving is a user parking a thread, not destroying it; only a row
171 // that is really gone justifies a replacement.
172 const existing = yield* readHomeThreadRow(designated);
173 if (existing !== undefined) {
174 // Un-archive rather than deliver into a hidden thread. A delivery is
175 // the agent raising its hand, and the same rule the decider applies to
176 // settled/snoozed threads applies here: incoming activity brings the
177 // thread back. Best-effort — a failure here must not cost the delivery.
178 if (existing.archivedAt !== null) {
179 yield* engine
180 .dispatch({
181 type: "thread.unarchive",
182 commandId: CommandId.make(yield* crypto.randomUUIDv4),
183 threadId: designated,
184 })
185 .pipe(
186 Effect.catchCause((cause) =>
187 Effect.logWarning("could not un-archive the home thread", {
188 instanceId: input.instanceId,
189 threadId: designated,
190 cause: Cause.pretty(cause),
191 }),
192 ),
193 );
194 }
195 return designated;
196 }
197 }
198
199 const project = yield* getOrCreateAgentProject({
200 instanceId: input.instanceId,
201 title: input.title,
202 });
203
204 const threadId = ThreadId.make(yield* crypto.randomUUIDv4);
205 const createdAt = DateTime.formatIso(yield* DateTime.now);
206
207 const dispatched = yield* Effect.result(
208 engine.dispatch({
209 type: "thread.create",
210 commandId: CommandId.make(yield* crypto.randomUUIDv4),
211 threadId,
212 projectId: project.id,
213 title: HOME_THREAD_TITLE,
214 // The same fixed slug every Hermes thread binds to — the reported model
215 // name is display-only, and following it here would orphan the thread
216 // whenever Hermes' own config changed.
217 modelSelection: { instanceId: input.instanceId, model: DEFAULT_HERMES_MODEL },
218 runtimeMode: "full-access",
219 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
220 branch: null,
221 worktreePath: null,
222 createdAt,
223 }),
224 );
225
226 if (dispatched._tag === "Failure") {
227 // A concurrent handshake may have won and already persisted its thread.
228 // Re-read before surfacing the error, matching getOrCreateAgentProject.
229 const raced = yield* getDesignatedHomeThreadId(input.instanceId);
230 if (raced !== undefined) return raced;
231 return yield* Effect.fail(dispatched.failure);
232 }
233
234 yield* persistHomeThreadId({ instanceId: input.instanceId, threadId });
235
236 // Re-read rather than trusting our own write: if a racing caller persisted
237 // first, both callers must agree on one thread, and settings is the
238 // tiebreaker. Our orphaned thread stays as an empty thread in the project
239 // rather than becoming a second home.
240 const settled = yield* getDesignatedHomeThreadId(input.instanceId);
241 return settled ?? threadId;
242});
apps/server/src/orchestration/projector.notification.test.ts+149−0
@@ -0,0 +1,149 @@
1import {
2 CommandId,
3 EventId,
4 MessageId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationEvent,
9} from "@t3tools/contracts";
10import { expect, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";
12
13import { createEmptyReadModel, projectEvent } from "./projector.ts";
14
15const CREATED_AT = "2026-07-01T09:00:00.000Z";
16const DELIVERED_AT = "2026-07-01T09:05:00.000Z";
17
18function makeEvent(input: {
19 readonly sequence: number;
20 readonly type: OrchestrationEvent["type"];
21 readonly occurredAt: string;
22 readonly payload: unknown;
23}): OrchestrationEvent {
24 return {
25 sequence: input.sequence,
26 eventId: EventId.make(`event-${input.sequence}`),
27 type: input.type,
28 aggregateKind: "thread",
29 aggregateId: ThreadId.make("thread-home"),
30 occurredAt: input.occurredAt,
31 commandId: CommandId.make(`command-${input.sequence}`),
32 causationEventId: null,
33 correlationId: null,
34 metadata: {},
35 payload: input.payload as never,
36 } as OrchestrationEvent;
37}
38
39const threadCreatedEvent = makeEvent({
40 sequence: 1,
41 type: "thread.created",
42 occurredAt: CREATED_AT,
43 payload: {
44 threadId: ThreadId.make("thread-home"),
45 projectId: ProjectId.make("project-1"),
46 title: "Home",
47 modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "hermes" },
48 runtimeMode: "full-access",
49 branch: null,
50 worktreePath: null,
51 createdAt: CREATED_AT,
52 updatedAt: CREATED_AT,
53 },
54});
55
56const deliveryEvent = makeEvent({
57 sequence: 2,
58 type: "thread.notification-delivered",
59 occurredAt: DELIVERED_AT,
60 payload: {
61 threadId: ThreadId.make("thread-home"),
62 messageId: MessageId.make("notification:msg-1"),
63 text: "Digest ready.",
64 notification: {
65 kind: "cron",
66 label: "Cron: daily-digest",
67 deliveryId: "delivery-1",
68 },
69 createdAt: DELIVERED_AT,
70 updatedAt: DELIVERED_AT,
71 },
72});
73
74const expectedMessage = {
75 id: "notification:msg-1",
76 role: "assistant",
77 text: "Digest ready.",
78 // A delivery belongs to no turn and is never streamed.
79 turnId: null,
80 streaming: false,
81 notification: {
82 kind: "cron",
83 label: "Cron: daily-digest",
84 deliveryId: "delivery-1",
85 },
86 createdAt: DELIVERED_AT,
87 updatedAt: DELIVERED_AT,
88};
89
90it.effect("appends a notification delivery as a turnless assistant message", () =>
91 Effect.gen(function* () {
92 const created = yield* projectEvent(createEmptyReadModel(CREATED_AT), threadCreatedEvent);
93 const delivered = yield* projectEvent(created, deliveryEvent);
94
95 const thread = delivered.threads[0];
96 expect(thread?.messages).toHaveLength(1);
97 expect(thread?.messages[0]).toEqual(expectedMessage);
98 expect(thread?.updatedAt).toBe(DELIVERED_AT);
99 // Nothing about a delivery touches turn state — attributing it to the
100 // thread's live turn would fold it behind that turn's summary.
101 expect(thread?.latestTurn).toBeNull();
102 }),
103);
104
105it.effect("replays a delivery onto the same message instead of duplicating it", () =>
106 Effect.gen(function* () {
107 const created = yield* projectEvent(createEmptyReadModel(CREATED_AT), threadCreatedEvent);
108 const delivered = yield* projectEvent(created, deliveryEvent);
109 // The decider re-emits the persisted row for a replayed deliveryId, so
110 // reducing the event twice must be a no-op rather than an append.
111 const replayed = yield* projectEvent(delivered, { ...deliveryEvent, sequence: 3 });
112
113 expect(replayed.threads[0]?.messages).toHaveLength(1);
114 expect(replayed.threads[0]?.messages[0]).toEqual(expectedMessage);
115 }),
116);
117
118it.effect("keeps deliveries alongside ordinary turn messages", () =>
119 Effect.gen(function* () {
120 const created = yield* projectEvent(createEmptyReadModel(CREATED_AT), threadCreatedEvent);
121 const withUserMessage = yield* projectEvent(
122 created,
123 makeEvent({
124 sequence: 2,
125 type: "thread.message-sent",
126 occurredAt: "2026-07-01T09:01:00.000Z",
127 payload: {
128 threadId: ThreadId.make("thread-home"),
129 messageId: MessageId.make("msg-user"),
130 role: "user",
131 text: "hi",
132 turnId: null,
133 streaming: false,
134 createdAt: "2026-07-01T09:01:00.000Z",
135 updatedAt: "2026-07-01T09:01:00.000Z",
136 },
137 }),
138 );
139 const delivered = yield* projectEvent(withUserMessage, { ...deliveryEvent, sequence: 3 });
140
141 expect(delivered.threads[0]?.messages.map((message) => message.id)).toEqual([
142 "msg-user",
143 "notification:msg-1",
144 ]);
145 // Absence of `notification` is what marks an ordinary message; it must
146 // never be synthesized onto one.
147 expect(delivered.threads[0]?.messages[0]?.notification).toBeUndefined();
148 }),
149);
apps/server/src/orchestration/projector.ts+50−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
21 ThreadDeletedPayload,21 ThreadDeletedPayload,
22 ThreadInteractionModeSetPayload,22 ThreadInteractionModeSetPayload,
23 ThreadMetaUpdatedPayload,23 ThreadMetaUpdatedPayload,
24 ThreadNotificationDeliveredPayload,
24 ThreadProposedPlanUpsertedPayload,25 ThreadProposedPlanUpsertedPayload,
25 ThreadRuntimeModeSetPayload,26 ThreadRuntimeModeSetPayload,
26 ThreadSettledPayload,27 ThreadSettledPayload,
@@ -722,6 +723,55 @@ export function projectEvent( · 695 unmodified lines
722 }),723 }),
723 );724 );
724 725
726 case "thread.notification-delivered":
727 return Effect.gen(function* () {
728 const payload = yield* decodeForEvent(
729 ThreadNotificationDeliveredPayload,
730 event.payload,
731 event.type,
732 "payload",
733 );
734 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
735 if (!thread) {
736 return nextBase;
737 }
738
739 const message: OrchestrationMessage = yield* decodeForEvent(
740 OrchestrationMessage,
741 {
742 id: payload.messageId,
743 role: "assistant",
744 text: payload.text,
745 // A delivery belongs to no turn: it arrives outside the turn
746 // machinery and may land while an unrelated turn is live.
747 turnId: null,
748 streaming: false,
749 notification: payload.notification,
750 createdAt: payload.createdAt,
751 updatedAt: payload.updatedAt,
752 },
753 event.type,
754 "message",
755 );
756
757 // Replays of an already-written delivery re-emit the persisted row, so
758 // replacing in place is a byte-identical no-op rather than a duplicate.
759 const existingMessage = thread.messages.find((entry) => entry.id === message.id);
760 const messages = (
761 existingMessage
762 ? thread.messages.map((entry) => (entry.id === message.id ? message : entry))
763 : [...thread.messages, message]
764 ).slice(-MAX_THREAD_MESSAGES);
765
766 return {
767 ...nextBase,
768 threads: updateThread(nextBase.threads, payload.threadId, {
769 messages,
770 updatedAt: event.occurredAt,
771 }),
772 };
773 });
774
725 case "thread.activity-appended":775 case "thread.activity-appended":
726 return decodeForEvent(776 return decodeForEvent(
727 ThreadActivityAppendedPayload,777 ThreadActivityAppendedPayload,
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+2−0
@@ -100,6 +100,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 99 unmodified lines
100 pendingApprovalCount: 0,100 pendingApprovalCount: 0,
101 pendingUserInputCount: 0,101 pendingUserInputCount: 0,
102 hasActionableProposedPlan: 0,102 hasActionableProposedPlan: 0,
103 latestNotification: null,
103 deletedAt: null,104 deletedAt: null,
104 });105 });
105 106
@@ -162,6 +163,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { · 56 unmodified lines
162 pendingApprovalCount: 0,163 pendingApprovalCount: 0,
163 pendingUserInputCount: 0,164 pendingUserInputCount: 0,
164 hasActionableProposedPlan: 0,165 hasActionableProposedPlan: 0,
166 latestNotification: null,
165 deletedAt: null,167 deletedAt: null,
166 });168 });
167 169
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+74−0
@@ -66,6 +66,80 @@ layer("ProjectionThreadMessageRepository", (it) => { · 65 unmodified lines
66 }),66 }),
67 );67 );
68 68
69 it.effect("round-trips notification provenance and preserves it across later upserts", () =>
70 Effect.gen(function* () {
71 const repository = yield* ProjectionThreadMessageRepository;
72 const threadId = ThreadId.make("thread-notification");
73 const messageId = MessageId.make("message-notification");
74 const createdAt = "2026-07-01T09:00:00.000Z";
75 const notification = {
76 kind: "cron" as const,
77 label: "Cron: daily-digest",
78 deliveryId: "delivery-1",
79 };
80
81 yield* repository.upsert({
82 messageId,
83 threadId,
84 turnId: null,
85 role: "assistant",
86 text: "Digest ready.",
87 notification,
88 isStreaming: false,
89 createdAt,
90 updatedAt: createdAt,
91 });
92
93 const rows = yield* repository.listByThreadId({ threadId });
94 assert.equal(rows.length, 1);
95 assert.deepEqual(rows[0]?.notification, notification);
96
97 // Same COALESCE rule as attachments: an unrelated update that omits the
98 // field must not erase a delivery's provenance.
99 yield* repository.upsert({
100 messageId,
101 threadId,
102 turnId: null,
103 role: "assistant",
104 text: "Digest ready (edited).",
105 isStreaming: false,
106 createdAt,
107 updatedAt: "2026-07-01T09:00:02.000Z",
108 });
109
110 const rowById = yield* repository.getByMessageId({ messageId });
111 assert.equal(rowById._tag, "Some");
112 if (rowById._tag === "Some") {
113 assert.equal(rowById.value.text, "Digest ready (edited).");
114 assert.deepEqual(rowById.value.notification, notification);
115 }
116 }),
117 );
118
119 it.effect("leaves notification absent on ordinary messages", () =>
120 Effect.gen(function* () {
121 const repository = yield* ProjectionThreadMessageRepository;
122 const threadId = ThreadId.make("thread-ordinary-message");
123 const messageId = MessageId.make("message-ordinary");
124
125 yield* repository.upsert({
126 messageId,
127 threadId,
128 turnId: null,
129 role: "user",
130 text: "hello",
131 isStreaming: false,
132 createdAt: "2026-07-01T09:10:00.000Z",
133 updatedAt: "2026-07-01T09:10:00.000Z",
134 });
135
136 const rows = yield* repository.listByThreadId({ threadId });
137 // Absent, not a synthesized default — consumers key on its absence to
138 // tell an ordinary message from a proactive delivery.
139 assert.equal(rows[0]?.notification, undefined);
140 }),
141 );
142
69 it.effect("allows explicit attachment clearing with an empty array", () =>143 it.effect("allows explicit attachment clearing with an empty array", () =>
70 Effect.gen(function* () {144 Effect.gen(function* () {
71 const repository = yield* ProjectionThreadMessageRepository;145 const repository = yield* ProjectionThreadMessageRepository;
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+20−1
@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Struct from "effect/Struct";7import * as Struct from "effect/Struct";
8import { ChatAttachment } from "@t3tools/contracts";8import { ChatAttachment, OrchestrationMessageNotification } from "@t3tools/contracts";
9 9
10import { toPersistenceSqlError } from "../Errors.ts";10import { toPersistenceSqlError } from "../Errors.ts";
11import {11import {
@@ -21,6 +21,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( · 9 unmodified lines
21 Struct.assign({21 Struct.assign({
22 isStreaming: Schema.Number,22 isStreaming: Schema.Number,
23 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),23 attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))),
24 notification: Schema.NullOr(Schema.fromJsonString(OrchestrationMessageNotification)),
24 }),25 }),
25);26);
26 27
@@ -37,6 +38,7 @@ function toProjectionThreadMessage( · 10 unmodified lines
37 createdAt: row.createdAt,38 createdAt: row.createdAt,
38 updatedAt: row.updatedAt,39 updatedAt: row.updatedAt,
39 ...(row.attachments !== null ? { attachments: row.attachments } : {}),40 ...(row.attachments !== null ? { attachments: row.attachments } : {}),
41 ...(row.notification !== null ? { notification: row.notification } : {}),
40 };42 };
41}43}
42 44
@@ -48,6 +50,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 5 unmodified lines
48 execute: (row) => {50 execute: (row) => {
49 const nextAttachmentsJson =51 const nextAttachmentsJson =
50 row.attachments !== undefined ? JSON.stringify(row.attachments) : null;52 row.attachments !== undefined ? JSON.stringify(row.attachments) : null;
53 const nextNotificationJson =
54 row.notification !== undefined ? JSON.stringify(row.notification) : null;
51 return sql`55 return sql`
52 INSERT INTO projection_thread_messages (56 INSERT INTO projection_thread_messages (
53 message_id,57 message_id,
@@ -56,6 +60,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 2 unmodified lines
56 role,60 role,
57 text,61 text,
58 attachments_json,62 attachments_json,
63 notification_json,
59 is_streaming,64 is_streaming,
60 created_at,65 created_at,
61 updated_at66 updated_at
@@ -74,6 +79,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 12 unmodified lines
74 WHERE message_id = ${row.messageId}79 WHERE message_id = ${row.messageId}
75 )80 )
76 ),81 ),
82 COALESCE(
83 ${nextNotificationJson},
84 (
85 SELECT notification_json
86 FROM projection_thread_messages
87 WHERE message_id = ${row.messageId}
88 )
89 ),
77 ${row.isStreaming ? 1 : 0},90 ${row.isStreaming ? 1 : 0},
78 ${row.createdAt},91 ${row.createdAt},
79 ${row.updatedAt}92 ${row.updatedAt}
@@ -88,6 +101,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 8 unmodified lines
88 excluded.attachments_json,101 excluded.attachments_json,
89 projection_thread_messages.attachments_json102 projection_thread_messages.attachments_json
90 ),103 ),
104 notification_json = COALESCE(
105 excluded.notification_json,
106 projection_thread_messages.notification_json
107 ),
91 is_streaming = excluded.is_streaming,108 is_streaming = excluded.is_streaming,
92 created_at = excluded.created_at,109 created_at = excluded.created_at,
93 updated_at = excluded.updated_at110 updated_at = excluded.updated_at
@@ -107,6 +124,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 13 unmodified lines
107 role,124 role,
108 text,125 text,
109 attachments_json AS "attachments",126 attachments_json AS "attachments",
127 notification_json AS "notification",
110 is_streaming AS "isStreaming",128 is_streaming AS "isStreaming",
111 created_at AS "createdAt",129 created_at AS "createdAt",
112 updated_at AS "updatedAt"130 updated_at AS "updatedAt"
@@ -128,6 +146,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { · 15 unmodified lines
128 role,146 role,
129 text,147 text,
130 attachments_json AS "attachments",148 attachments_json AS "attachments",
149 notification_json AS "notification",
131 is_streaming AS "isStreaming",150 is_streaming AS "isStreaming",
132 created_at AS "createdAt",151 created_at AS "createdAt",
133 updated_at AS "updatedAt"152 updated_at AS "updatedAt"
apps/server/src/persistence/Layers/ProjectionThreads.ts+15−4
@@ -14,11 +14,14 @@ import { · 13 unmodified lines
14 ProjectionThreadRepository,14 ProjectionThreadRepository,
15 type ProjectionThreadRepositoryShape,15 type ProjectionThreadRepositoryShape,
16} from "../Services/ProjectionThreads.ts";16} from "../Services/ProjectionThreads.ts";
17import { ModelSelection } from "@t3tools/contracts";17import { ModelSelection, OrchestrationThreadNotificationSummary } from "@t3tools/contracts";
18 18
19const ProjectionThreadDbRow = ProjectionThread.mapFields(19const ProjectionThreadDbRow = ProjectionThread.mapFields(
20 Struct.assign({20 Struct.assign({
21 modelSelection: Schema.fromJsonString(ModelSelection),21 modelSelection: Schema.fromJsonString(ModelSelection),
22 latestNotification: Schema.NullOr(
23 Schema.fromJsonString(OrchestrationThreadNotificationSummary),
24 ),
22 }),25 }),
23);26);
24type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;27type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
@@ -28,8 +31,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 3 unmodified lines
28 31
29 const upsertProjectionThreadRow = SqlSchema.void({32 const upsertProjectionThreadRow = SqlSchema.void({
30 Request: ProjectionThread,33 Request: ProjectionThread,
31 execute: (row) =>34 execute: (row) => {
32 sql`35 const latestNotificationJson =
36 row.latestNotification === null ? null : JSON.stringify(row.latestNotification);
37 return sql`
33 INSERT INTO projection_threads (38 INSERT INTO projection_threads (
34 thread_id,39 thread_id,
35 project_id,40 project_id,
@@ -51,6 +56,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 15 unmodified lines
51 pending_approval_count,56 pending_approval_count,
52 pending_user_input_count,57 pending_user_input_count,
53 has_actionable_proposed_plan,58 has_actionable_proposed_plan,
59 latest_notification_json,
54 deleted_at60 deleted_at
55 )61 )
56 VALUES (62 VALUES (
@@ -74,6 +80,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 17 unmodified lines
74 ${row.pendingApprovalCount},80 ${row.pendingApprovalCount},
75 ${row.pendingUserInputCount},81 ${row.pendingUserInputCount},
76 ${row.hasActionableProposedPlan},82 ${row.hasActionableProposedPlan},
83 ${latestNotificationJson},
77 ${row.deletedAt}84 ${row.deletedAt}
78 )85 )
79 ON CONFLICT (thread_id)86 ON CONFLICT (thread_id)
@@ -97,8 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 17 unmodified lines
97 pending_approval_count = excluded.pending_approval_count,104 pending_approval_count = excluded.pending_approval_count,
98 pending_user_input_count = excluded.pending_user_input_count,105 pending_user_input_count = excluded.pending_user_input_count,
99 has_actionable_proposed_plan = excluded.has_actionable_proposed_plan,106 has_actionable_proposed_plan = excluded.has_actionable_proposed_plan,
107 latest_notification_json = excluded.latest_notification_json,
100 deleted_at = excluded.deleted_at108 deleted_at = excluded.deleted_at
101 `,109 `;
110 },
102 });111 });
103 112
104 const getProjectionThreadRow = SqlSchema.findOneOption({113 const getProjectionThreadRow = SqlSchema.findOneOption({
@@ -127,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 22 unmodified lines
127 pending_approval_count AS "pendingApprovalCount",136 pending_approval_count AS "pendingApprovalCount",
128 pending_user_input_count AS "pendingUserInputCount",137 pending_user_input_count AS "pendingUserInputCount",
129 has_actionable_proposed_plan AS "hasActionableProposedPlan",138 has_actionable_proposed_plan AS "hasActionableProposedPlan",
139 latest_notification_json AS "latestNotification",
130 deleted_at AS "deletedAt"140 deleted_at AS "deletedAt"
131 FROM projection_threads141 FROM projection_threads
132 WHERE thread_id = ${threadId}142 WHERE thread_id = ${threadId}
@@ -159,6 +169,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { · 26 unmodified lines
159 pending_approval_count AS "pendingApprovalCount",169 pending_approval_count AS "pendingApprovalCount",
160 pending_user_input_count AS "pendingUserInputCount",170 pending_user_input_count AS "pendingUserInputCount",
161 has_actionable_proposed_plan AS "hasActionableProposedPlan",171 has_actionable_proposed_plan AS "hasActionableProposedPlan",
172 latest_notification_json AS "latestNotification",
162 deleted_at AS "deletedAt"173 deleted_at AS "deletedAt"
163 FROM projection_threads174 FROM projection_threads
164 WHERE project_id = ${projectId}175 WHERE project_id = ${projectId}
apps/server/src/persistence/Migrations.ts+4−0
@@ -48,6 +48,8 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; · 47 unmodified lines
48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
50import Migration0035 from "./Migrations/035_ProjectionProjectsAgentInstance.ts";50import Migration0035 from "./Migrations/035_ProjectionProjectsAgentInstance.ts";
51import Migration0036 from "./Migrations/036_ProjectionThreadMessageNotification.ts";
52import Migration0037 from "./Migrations/037_ProjectionThreadsLatestNotification.ts";
51 53
52/**54/**
53 * Migration loader with all migrations defined inline.55 * Migration loader with all migrations defined inline.
@@ -95,6 +97,8 @@ export const migrationEntries = [ · 41 unmodified lines
95 [33, "ProjectionThreadsSettled", Migration0033],97 [33, "ProjectionThreadsSettled", Migration0033],
96 [34, "ProjectionThreadsSnoozed", Migration0034],98 [34, "ProjectionThreadsSnoozed", Migration0034],
97 [35, "ProjectionProjectsAgentInstance", Migration0035],99 [35, "ProjectionProjectsAgentInstance", Migration0035],
100 [36, "ProjectionThreadMessageNotification", Migration0036],
101 [37, "ProjectionThreadsLatestNotification", Migration0037],
98] as const;102] as const;
99 103
100export const makeMigrationLoader = (throughId?: number) =>104export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.test.ts+109−0
@@ -0,0 +1,109 @@
1import { assert, it } from "@effect/vitest";
2import { OrchestrationMessageNotification } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as SqlClient from "effect/unstable/sql/SqlClient";
7
8import { runMigrations } from "../Migrations.ts";
9import * as NodeSqliteClient from "../NodeSqliteClient.ts";
10
11const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
12
13layer("036_ProjectionThreadMessageNotification", (it) => {
14 it.effect("adds the notification column without disturbing existing messages", () =>
15 Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 yield* runMigrations({ toMigrationInclusive: 35 });
19
20 // An ordinary message written before the column existed. Proactive
21 // deliveries are the exception, so every pre-existing row must survive
22 // the migration with no notification rather than a synthesized default.
23 yield* sql`
24 INSERT INTO projection_thread_messages (
25 message_id,
26 thread_id,
27 turn_id,
28 role,
29 text,
30 is_streaming,
31 created_at,
32 updated_at
33 ) VALUES (
34 'message-1',
35 'thread-1',
36 'turn-1',
37 'assistant',
38 'an ordinary answer',
39 0,
40 '2026-01-01T00:00:00.000Z',
41 '2026-01-01T00:00:00.000Z'
42 )
43 `;
44
45 yield* runMigrations({ toMigrationInclusive: 36 });
46
47 const rows = yield* sql<{
48 readonly message_id: string;
49 readonly text: string;
50 readonly notification_json: string | null;
51 }>`
52 SELECT message_id, text, notification_json
53 FROM projection_thread_messages
54 WHERE message_id = 'message-1'
55 `;
56
57 assert.equal(rows.length, 1);
58 assert.equal(rows[0]?.text, "an ordinary answer");
59 assert.equal(rows[0]?.notification_json, null);
60
61 // And the new column accepts a delivery's provenance.
62 yield* sql`
63 INSERT INTO projection_thread_messages (
64 message_id,
65 thread_id,
66 turn_id,
67 role,
68 text,
69 is_streaming,
70 created_at,
71 updated_at,
72 notification_json
73 ) VALUES (
74 'message-2',
75 'thread-1',
76 NULL,
77 'assistant',
78 'Your digest is ready.',
79 0,
80 '2026-01-01T00:01:00.000Z',
81 '2026-01-01T00:01:00.000Z',
82 '{"kind":"cron","label":"Cron: daily-digest","deliveryId":"delivery-1"}'
83 )
84 `;
85
86 const delivered = yield* sql<{
87 readonly notification_json: string | null;
88 readonly turn_id: string | null;
89 }>`
90 SELECT notification_json, turn_id
91 FROM projection_thread_messages
92 WHERE message_id = 'message-2'
93 `;
94
95 assert.equal(delivered.length, 1);
96 // A delivery belongs to no turn — attributing it to the thread's live
97 // turn would fold it away behind that turn's "Worked for …" row.
98 assert.equal(delivered[0]?.turn_id, null);
99 const decodedNotification = yield* Schema.decodeUnknownEffect(
100 Schema.fromJsonString(OrchestrationMessageNotification),
101 )(delivered[0]?.notification_json ?? "");
102 assert.deepEqual(decodedNotification, {
103 kind: "cron",
104 label: "Cron: daily-digest",
105 deliveryId: "delivery-1",
106 });
107 }),
108 );
109});
apps/server/src/persistence/Migrations/036_ProjectionThreadMessageNotification.ts+11−0
@@ -0,0 +1,11 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_thread_messages
9 ADD COLUMN notification_json TEXT
10 `;
11});
apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.test.ts+80−0
@@ -0,0 +1,80 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("037_ProjectionThreadsLatestNotification", (it) => {
12 it.effect("adds a nullable latest_notification_json column, leaving existing rows null", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 36 });
17
18 const before = yield* sql<{ readonly name: string }>`
19 PRAGMA table_info(projection_threads)
20 `;
21 assert.ok(!before.some((column) => column.name === "latest_notification_json"));
22
23 yield* sql`
24 INSERT INTO projection_threads (
25 thread_id,
26 project_id,
27 title,
28 model_selection_json,
29 runtime_mode,
30 interaction_mode,
31 branch,
32 worktree_path,
33 latest_turn_id,
34 created_at,
35 updated_at,
36 deleted_at
37 )
38 VALUES (
39 'thread-pre-037',
40 'project-pre-037',
41 'Existing thread',
42 '{"instanceId":"codex","model":"gpt-5.4"}',
43 'full-access',
44 'default',
45 NULL,
46 NULL,
47 NULL,
48 '2026-07-01T00:00:00.000Z',
49 '2026-07-01T00:00:00.000Z',
50 NULL
51 )
52 `;
53
54 yield* runMigrations({ toMigrationInclusive: 37 });
55
56 const after = yield* sql<{
57 readonly name: string;
58 readonly type: string;
59 readonly notnull: number;
60 readonly dflt_value: string | null;
61 }>`
62 PRAGMA table_info(projection_threads)
63 `;
64 const column = after.find((entry) => entry.name === "latest_notification_json");
65 assert.ok(column);
66 assert.strictEqual(column.type, "TEXT");
67 assert.strictEqual(column.notnull, 0);
68 assert.strictEqual(column.dflt_value, null);
69
70 // No backfill is needed or possible: notification_json (migration 036)
71 // is newer than every pre-existing row, so nothing to summarize.
72 const rows = yield* sql<{ readonly latestNotification: string | null }>`
73 SELECT latest_notification_json AS "latestNotification"
74 FROM projection_threads
75 WHERE thread_id = 'thread-pre-037'
76 `;
77 assert.deepStrictEqual(rows, [{ latestNotification: null }]);
78 }),
79 );
80});
apps/server/src/persistence/Migrations/037_ProjectionThreadsLatestNotification.ts+22−0
@@ -0,0 +1,22 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4/**
5 * Materializes the thread's newest proactive delivery onto the shell row.
6 *
7 * Shell consumers (sidebar, agent-awareness/push) never read a thread's
8 * messages, so notification provenance that lives on the message row is
9 * invisible to them unless it is summarized here — the same reason
10 * `latest_user_message_at` exists.
11 *
12 * No backfill: `projection_thread_messages.notification_json` is introduced by
13 * migration 036, so no delivery predates this column.
14 */
15export default Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 yield* sql`
19 ALTER TABLE projection_threads
20 ADD COLUMN latest_notification_json TEXT
21 `;
22});
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+9−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
9import {9import {
10 ChatAttachment,10 ChatAttachment,
11 MessageId,11 MessageId,
12 OrchestrationMessageNotification,
12 OrchestrationMessageRole,13 OrchestrationMessageRole,
13 ThreadId,14 ThreadId,
14 TurnId,15 TurnId,
@@ -28,6 +29,14 @@ export const ProjectionThreadMessage = Schema.Struct({ · 13 unmodified lines
28 role: OrchestrationMessageRole,29 role: OrchestrationMessageRole,
29 text: Schema.String,30 text: Schema.String,
30 attachments: Schema.optional(Schema.Array(ChatAttachment)),31 attachments: Schema.optional(Schema.Array(ChatAttachment)),
32 /**
33 * Proactive-delivery provenance, present only on notification rows.
34 *
35 * Optional like `attachments`, and persisted with the same
36 * `COALESCE(excluded, existing)` upsert rule: an ordinary update that omits
37 * it must not erase the provenance of a row that has it.
38 */
39 notification: Schema.optional(OrchestrationMessageNotification),
31 isStreaming: Schema.Boolean,40 isStreaming: Schema.Boolean,
32 createdAt: IsoDateTime,41 createdAt: IsoDateTime,
33 updatedAt: IsoDateTime,42 updatedAt: IsoDateTime,
apps/server/src/persistence/Services/ProjectionThreads.ts+7−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 IsoDateTime,10 IsoDateTime,
11 ModelSelection,11 ModelSelection,
12 NonNegativeInt,12 NonNegativeInt,
13 OrchestrationThreadNotificationSummary,
13 ProjectId,14 ProjectId,
14 ProviderInteractionMode,15 ProviderInteractionMode,
15 RuntimeMode,16 RuntimeMode,
@@ -44,6 +45,12 @@ export const ProjectionThread = Schema.Struct({ · 28 unmodified lines
44 pendingApprovalCount: NonNegativeInt,45 pendingApprovalCount: NonNegativeInt,
45 pendingUserInputCount: NonNegativeInt,46 pendingUserInputCount: NonNegativeInt,
46 hasActionableProposedPlan: NonNegativeInt,47 hasActionableProposedPlan: NonNegativeInt,
48 /**
49 * Newest proactive delivery on this thread, summarized so shell readers do
50 * not have to load messages. Refreshed alongside the other shell-summary
51 * columns; `null` on every thread that has never received a delivery.
52 */
53 latestNotification: Schema.NullOr(OrchestrationThreadNotificationSummary),
47 deletedAt: Schema.NullOr(IsoDateTime),54 deletedAt: Schema.NullOr(IsoDateTime),
48});55});
49export type ProjectionThread = typeof ProjectionThread.Type;56export type ProjectionThread = typeof ProjectionThread.Type;
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1−0
@@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => · 43 unmodified lines
44 getThreadCheckpointContext: () => Effect.die("unused"),44 getThreadCheckpointContext: () => Effect.die("unused"),
45 getFullThreadDiffContext: () => Effect.die("unused"),45 getFullThreadDiffContext: () => Effect.die("unused"),
46 getThreadShellById: () => Effect.die("unused"),46 getThreadShellById: () => Effect.die("unused"),
47 getThreadArchiveStateById: () => Effect.die("unused"),
47 getThreadDetailById: () => Effect.die("unused"),48 getThreadDetailById: () => Effect.die("unused"),
48 getThreadDetailSnapshot: () => Effect.die("unused"),49 getThreadDetailSnapshot: () => Effect.die("unused"),
49 });50 });
apps/server/src/provider/Drivers/HermesDriver.ts+20−1
@@ -4,13 +4,17 @@ import { · 3 unmodified lines
4 HermesSettings,4 HermesSettings,
5 TextGenerationError,5 TextGenerationError,
6 type ServerProvider,6 type ServerProvider,
7 type ServerSettings,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import * as Crypto from "effect/Crypto";9import * as Crypto from "effect/Crypto";
9import * as DateTime from "effect/DateTime";10import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";14import * as Stream from "effect/Stream";
13 15
16import { readHomeThreadId } from "../../orchestration/homeThreads.ts";
17import { ServerSettingsService } from "../../serverSettings.ts";
14import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";18import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
15import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";19import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";
16import {20import {
@@ -23,7 +27,7 @@ import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; · 6 unmodified lines
23 27
24const decodeHermesSettings = Schema.decodeSync(HermesSettings);28const decodeHermesSettings = Schema.decodeSync(HermesSettings);
25 29
26export type HermesDriverEnv = Crypto.Crypto;30export type HermesDriverEnv = Crypto.Crypto | ServerSettingsService;
27 31
28const unsupportedTextGeneration = (32const unsupportedTextGeneration = (
29 operation:33 operation:
@@ -57,6 +61,9 @@ export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = { · 27 unmodified lines
57 create: ({ instanceId, displayName, accentColor, enabled }) =>61 create: ({ instanceId, displayName, accentColor, enabled }) =>
58 Effect.gen(function* () {62 Effect.gen(function* () {
59 const broker = yield* HermesGatewayBroker;63 const broker = yield* HermesGatewayBroker;
64 // Captured once at construction: `getSnapshot` must be context-free
65 // (`R = never`) because the registry calls it outside this scope.
66 const settings = yield* ServerSettingsService;
60 const adapter = yield* makeHermesAdapter({ instanceId });67 const adapter = yield* makeHermesAdapter({ instanceId });
61 const continuationIdentity = defaultProviderContinuationIdentity({68 const continuationIdentity = defaultProviderContinuationIdentity({
62 driverKind: HERMES_DRIVER_KIND,69 driverKind: HERMES_DRIVER_KIND,
@@ -71,7 +78,19 @@ export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = { · 8 unmodified lines
71 const status = yield* broker78 const status = yield* broker
72 .getInstanceStatus(instanceId)79 .getInstanceStatus(instanceId)
73 .pipe(Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)));80 .pipe(Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)));
81 // Read-only: a snapshot must never create the thread as a side effect
82 // (this runs on every status tick). The handshake owns creation.
83 // A settings read that fails degrades to "no designation" rather than
84 // failing the whole snapshot — the pin is cosmetic, the status is not.
85 const currentSettings = yield* settings.getSettings.pipe(
86 Effect.map(Option.some),
87 Effect.orElseSucceed(() => Option.none<ServerSettings>()),
88 );
89 const homeThreadId = Option.isSome(currentSettings)
90 ? readHomeThreadId(currentSettings.value.providerInstances[instanceId])
91 : undefined;
74 return {92 return {
93 ...(homeThreadId !== undefined ? { homeThreadId } : {}),
75 instanceId,94 instanceId,
76 driver: HERMES_DRIVER_KIND,95 driver: HERMES_DRIVER_KIND,
77 ...(displayName ? { displayName } : {}),96 ...(displayName ? { displayName } : {}),
apps/server/src/provider/Layers/HermesGatewayBroker.test.ts+100−4
@@ -39,6 +39,13 @@ import { · 38 unmodified lines
39 makeHermesGatewayBroker,39 makeHermesGatewayBroker,
40} from "./HermesGatewayBroker.ts";40} from "./HermesGatewayBroker.ts";
41 41
42/**
43 * A version this server does not speak. Expressed relative to the supported
44 * version rather than as a literal, so bumping the protocol does not turn
45 * these "must be rejected" cases into "is the current version" cases.
46 */
47const UNSUPPORTED_PROTOCOL_VERSION = HERMES_GATEWAY_PROTOCOL_VERSION + 1;
48
42const instanceId = ProviderInstanceId.make("hermes_remote");49const instanceId = ProviderInstanceId.make("hermes_remote");
43const otherInstanceId = ProviderInstanceId.make("hermes_other");50const otherInstanceId = ProviderInstanceId.make("hermes_other");
44const defaultHermesInstanceId = ProviderInstanceId.make("hermes");51const defaultHermesInstanceId = ProviderInstanceId.make("hermes");
@@ -94,7 +101,10 @@ const testLayer = Layer.mergeAll( · 49 unmodified lines
94const hello = (101const hello = (
95 authentication: HermesGatewayConnectionHello["authentication"],102 authentication: HermesGatewayConnectionHello["authentication"],
96 protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION,103 protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION,
97 overrides: { readonly model?: string } = {},104 overrides: {
105 readonly model?: string;
106 readonly role?: HermesGatewayConnectionHello["role"];
107 } = {},
98): HermesGatewayConnectionHello => ({108): HermesGatewayConnectionHello => ({
99 type: "connection.hello",109 type: "connection.hello",
100 requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),110 requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),
@@ -103,6 +113,7 @@ const hello = ( · 2 unmodified lines
103 hermesVersion: "1.0.0",113 hermesVersion: "1.0.0",
104 capabilities: { ...capabilities, protocolVersion },114 capabilities: { ...capabilities, protocolVersion },
105 authentication,115 authentication,
116 role: overrides.role ?? "gateway",
106 ...(overrides.model !== undefined ? { model: overrides.model } : {}),117 ...(overrides.model !== undefined ? { model: overrides.model } : {}),
107});118});
108 119
@@ -174,7 +185,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 65 unmodified lines
174 instanceId,185 instanceId,
175 credential: HermesGatewayCredential.make("not-the-real-credential"),186 credential: HermesGatewayCredential.make("not-the-real-credential"),
176 },187 },
177 3,188 UNSUPPORTED_PROTOCOL_VERSION,
178 ),189 ),
179 second.transport,190 second.transport,
180 ),191 ),
@@ -202,7 +213,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 21 unmodified lines
202 instanceId,213 instanceId,
203 credential: registered.accepted.credential!,214 credential: registered.accepted.credential!,
204 },215 },
205 3,216 UNSUPPORTED_PROTOCOL_VERSION,
206 ),217 ),
207 second.transport,218 second.transport,
208 ),219 ),
@@ -214,7 +225,10 @@ it.effect("authenticates before applying incompatible connection state", () => · 5 unmodified lines
214 const otherEnrollment = yield* enroll(broker, otherInstanceId, "Other Hermes");225 const otherEnrollment = yield* enroll(broker, otherInstanceId, "Other Hermes");
215 const incompatibleEnrollment = yield* Effect.flip(226 const incompatibleEnrollment = yield* Effect.flip(
216 broker.registerConnection(227 broker.registerConnection(
217 hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 3),228 hello(
229 { type: "enrollment-token", token: otherEnrollment.oneTimeToken },
230 UNSUPPORTED_PROTOCOL_VERSION,
231 ),
218 second.transport,232 second.transport,
219 ),233 ),
220 );234 );
@@ -1226,3 +1240,85 @@ it.effect("migrates a revoked legacy instance and drops its credential", () => · 1005 unmodified lines
1226 assert.equal(reconnect.code, "invalid-authentication");1240 assert.equal(reconnect.code, "invalid-authentication");
1227 }).pipe(Effect.provide(testLayer)),1241 }).pipe(Effect.provide(testLayer)),
1228);1242);
1243
1244it.effect("a delivery connection never displaces the live gateway connection", () =>
1245 Effect.gen(function* () {
1246 // The failure this guards: an out-of-process cron run dials in to hand
1247 // over one delivery, `registerConnection` treats it as a replacement, and
1248 // the real plugin gets closed with 4001 — knocking Hermes offline for the
1249 // duration of a cron job.
1250 const broker = yield* makeBroker(makeSecretStore());
1251 const enrollment = yield* enroll(broker, instanceId, "Remote Hermes");
1252 const live = recordingTransport();
1253 const gateway = yield* broker.registerConnection(
1254 hello({ type: "enrollment-token", token: enrollment.oneTimeToken }),
1255 live.transport,
1256 );
1257 assert.equal(gateway.role, "gateway");
1258 assert.isTrue(yield* broker.isConnected(instanceId));
1259
1260 const credential = gateway.accepted.credential!;
1261 const cron = recordingTransport();
1262 const delivery = yield* broker.registerConnection(
1263 hello(
1264 { type: "instance-credential", instanceId, credential },
1265 HERMES_GATEWAY_PROTOCOL_VERSION,
1266 { role: "delivery" },
1267 ),
1268 cron.transport,
1269 );
1270
1271 assert.equal(delivery.role, "delivery");
1272 // No generation: it was never registered, so there is nothing for it to
1273 // be stale against — and a generation would let it be compared with, and
1274 // mistaken for, the live connection.
1275 assert.equal(delivery.generation, null);
1276 assert.deepEqual(live.closes, [], "the live gateway socket must not be closed");
1277 assert.isTrue(yield* broker.isConnected(instanceId));
1278
1279 // A delivery socket holds no session, so it must not be able to answer
1280 // the live connection's outstanding requests — otherwise a throwaway cron
1281 // socket could speak for the real plugin.
1282 const threadId = ThreadId.make("delivery-fencing-thread");
1283 const pendingRequestId = HermesGatewayRequestId.make("delivery-fencing-session");
1284 const pending = yield* broker
1285 .request(instanceId, {
1286 type: "session.ensure",
1287 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
1288 requestId: pendingRequestId,
1289 threadId,
1290 })
1291 .pipe(Effect.forkChild({ startImmediately: true }));
1292 yield* Effect.yieldNow;
1293 yield* broker.receive(delivery, {
1294 type: "session.ready",
1295 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
1296 requestId: pendingRequestId,
1297 threadId,
1298 sessionId: HermesGatewaySessionId.make("delivery-forged-session"),
1299 resumed: false,
1300 });
1301 yield* Effect.yieldNow;
1302 assert.isUndefined(
1303 pending.pollUnsafe(),
1304 "a delivery connection must not complete the live connection's requests",
1305 );
1306
1307 // The live connection still can.
1308 yield* broker.receive(gateway, {
1309 type: "session.ready",
1310 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
1311 requestId: pendingRequestId,
1312 threadId,
1313 sessionId: HermesGatewaySessionId.make("real-session"),
1314 resumed: false,
1315 });
1316 assert.equal((yield* Fiber.join(pending)).type, "session.ready");
1317
1318 // Closing the delivery socket is its normal lifecycle and must leave the
1319 // instance's liveness untouched.
1320 yield* broker.disconnect(delivery);
1321 assert.isTrue(yield* broker.isConnected(instanceId));
1322 assert.deepEqual(live.closes, []);
1323 }).pipe(Effect.provide(testLayer)),
1324);
apps/server/src/provider/Layers/HermesGatewayBroker.ts+38−0
@@ -965,6 +965,31 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 964 unmodified lines
965 );965 );
966 }966 }
967 967
968 // ── Delivery connections: authenticated, but never the primary.
969 //
970 // An out-of-process cron run dials in only to hand over a `home.deliver`
971 // and leave. Registering it would displace the instance's live gateway
972 // socket ("replaced by a newer connection") and bump the generation,
973 // knocking the real plugin offline for the duration of a cron job. So it
974 // is accepted and then deliberately left out of the connection registry:
975 // no generation, no liveness ping, no status publish, nothing to
976 // disconnect. The existing primary is untouched.
977 if (hello.role === "delivery") {
978 return {
979 instanceId,
980 generation: null,
981 role: "delivery",
982 accepted: {
983 type: "connection.accepted",
984 requestId: hello.requestId,
985 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
986 instanceId,
987 nickname: record.nickname,
988 ...(credential ? { credential } : {}),
989 },
990 } as const satisfies HermesGatewayConnectionRegistration;
991 }
992
968 const observed: HermesObservedConnection = {993 const observed: HermesObservedConnection = {
969 pluginVersion: hello.pluginVersion,994 pluginVersion: hello.pluginVersion,
970 hermesVersion: hello.hermesVersion,995 hermesVersion: hello.hermesVersion,
@@ -986,6 +1011,7 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 15 unmodified lines
986 const registration = {1011 const registration = {
987 instanceId,1012 instanceId,
988 generation: accepted.generation,1013 generation: accepted.generation,
1014 role: "gateway",
989 accepted: {1015 accepted: {
990 type: "connection.accepted",1016 type: "connection.accepted",
991 requestId: hello.requestId,1017 requestId: hello.requestId,
@@ -1009,6 +1035,14 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 17 unmodified lines
1009 1035
1010 const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) =>1036 const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) =>
1011 Effect.gen(function* () {1037 Effect.gen(function* () {
1038 // A delivery connection's one frame — `home.deliver` — is handled at the
1039 // transport layer, because its ack has to go back to that specific
1040 // short-lived socket. Anything else it sends is outside its remit and is
1041 // dropped here: it holds no session, so completing a correlated request
1042 // or publishing a status off it would let a throwaway cron socket speak
1043 // for the live connection.
1044 if (registration.role === "delivery") return;
1045
1012 if (message.type === "connection.status") {1046 if (message.type === "connection.status") {
1013 // Generation-fenced inside the Ref update; a stale registration is a1047 // Generation-fenced inside the Ref update; a stale registration is a
1014 // no-op rather than an overwrite.1048 // no-op rather than an overwrite.
@@ -1031,6 +1065,10 @@ export const makeHermesGatewayBroker = Effect.gen(function* () { · 16 unmodified lines
1031 1065
1032 const disconnect = (registration: HermesGatewayConnectionRegistration) =>1066 const disconnect = (registration: HermesGatewayConnectionRegistration) =>
1033 Effect.gen(function* () {1067 Effect.gen(function* () {
1068 // Delivery sockets close as a matter of course — that is their whole
1069 // lifecycle. They were never in the registry, so retiring one must not
1070 // touch the instance's liveness or fail the live connection's requests.
1071 if (registration.role === "delivery") return;
1034 const retired = yield* connections.disconnect(registration);1072 const retired = yield* connections.disconnect(registration);
1035 if (!retired) return;1073 if (!retired) return;
1036 yield* publishCurrentStatus(registration.instanceId, "get-status");1074 yield* publishCurrentStatus(registration.instanceId, "get-status");
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1−0
@@ -205,6 +205,7 @@ describe("ProviderSessionReaper", () => { · 204 unmodified lines
205 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),205 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
206 getThreadCheckpointContext: () => Effect.die("unused"),206 getThreadCheckpointContext: () => Effect.die("unused"),
207 getFullThreadDiffContext: () => Effect.die("unused"),207 getFullThreadDiffContext: () => Effect.die("unused"),
208 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
208 getThreadShellById: (threadId) =>209 getThreadShellById: (threadId) =>
209 Effect.succeed(210 Effect.succeed(
210 input.readModel.threads.find((thread) => thread.id === threadId)211 input.readModel.threads.find((thread) => thread.id === threadId)
apps/server/src/provider/Services/HermesGatewayBroker.ts+17−1
@@ -1,5 +1,6 @@
1import type {1import type {
2 HermesGatewayConnectionHello,2 HermesGatewayConnectionHello,
3 HermesGatewayConnectionRole,
3 HermesGatewayCreateEnrollmentInput,4 HermesGatewayCreateEnrollmentInput,
4 HermesGatewayEnrollmentResult,5 HermesGatewayEnrollmentResult,
5 HermesGatewayInstanceStatus,6 HermesGatewayInstanceStatus,
@@ -27,7 +28,22 @@ export interface HermesGatewayTransport { · 21 unmodified lines
27 28
28export interface HermesGatewayConnectionRegistration {29export interface HermesGatewayConnectionRegistration {
29 readonly instanceId: ProviderInstanceId;30 readonly instanceId: ProviderInstanceId;
30 readonly generation: number;31 /**
32 * Fencing token for the instance's primary connection, or `null` for a
33 * delivery connection.
34 *
35 * A delivery connection is deliberately outside the generation scheme: it
36 * was never registered as the primary, so there is nothing for it to be
37 * stale against, and giving it a generation would let it be compared —
38 * and therefore mistaken for a replacement — against the live socket.
39 */
40 readonly generation: number | null;
41 /**
42 * What this socket registered as. `delivery` sockets may only hand over
43 * `home.deliver` frames; they carry no session, take no ping loop, and
44 * their disconnect must not disturb the instance's liveness state.
45 */
46 readonly role: HermesGatewayConnectionRole;
31 readonly accepted: Extract<47 readonly accepted: Extract<
32 HermesGatewayT3ToPluginMessage,48 HermesGatewayT3ToPluginMessage,
33 { readonly type: "connection.accepted" }49 { readonly type: "connection.accepted" }
apps/server/src/provider/hermesGatewayHttp.ts+146−1
@@ -1,8 +1,14 @@
1import {1import {
2 CommandId,
3 HERMES_GATEWAY_PROTOCOL_VERSION,
2 HermesGatewayConnectionHello,4 HermesGatewayConnectionHello,
3 HermesGatewayPluginToT3Message,5 HermesGatewayPluginToT3Message,
4 HermesGatewayT3ToPluginMessage,6 HermesGatewayT3ToPluginMessage,
7 MessageId,
8 type ThreadId,
5} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";14import * as Option from "effect/Option";
@@ -11,6 +17,9 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines
11import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";17import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
12import * as Socket from "effect/unstable/socket/Socket";18import * as Socket from "effect/unstable/socket/Socket";
13 19
20import { getOrCreateHomeThread } from "../orchestration/homeThreads.ts";
21import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
22import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
14import {23import {
15 HermesGatewayBroker,24 HermesGatewayBroker,
16 type HermesGatewayConnectionRegistration,25 type HermesGatewayConnectionRegistration,
@@ -28,6 +37,107 @@ const isConnectionHello = Schema.is(HermesGatewayConnectionHello); · 11 unmodified lines
28export const hermesGatewayWebSocketRouteLayer = Layer.unwrap(37export const hermesGatewayWebSocketRouteLayer = Layer.unwrap(
29 Effect.gen(function* () {38 Effect.gen(function* () {
30 const broker = yield* HermesGatewayBroker;39 const broker = yield* HermesGatewayBroker;
40 const engine = yield* OrchestrationEngineService;
41 const query = yield* ProjectionSnapshotQuery;
42 const crypto = yield* Crypto.Crypto;
43
44 /**
45 * Write one proactive delivery into the instance's home thread and ack it.
46 *
47 * The ack is sent **only after the dispatch succeeds**. The plugin purges
48 * its queued copy on the ack and on nothing else, so acking optimistically
49 * would silently drop deliveries whenever a write failed. An unacked
50 * delivery is retried on the next connect and deduped there by
51 * `deliveryId`, which makes the pessimistic order the safe one.
52 *
53 * Accepted from either connection role, and deliberately not generation-
54 * fenced: a delivery carries no session and cannot conflict with the live
55 * connection's turns, and fencing it would reject exactly the out-of-
56 * process cron case this exists to serve.
57 */
58 const deliverHomeNotification = (
59 registration: HermesGatewayConnectionRegistration,
60 message: Extract<HermesGatewayPluginToT3Message, { readonly type: "home.deliver" }>,
61 transport: {
62 readonly send: (
63 frame: HermesGatewayT3ToPluginMessage,
64 ) => Effect.Effect<void, ProviderAdapterRequestError>;
65 },
66 ) =>
67 Effect.gen(function* () {
68 // Re-resolve rather than trusting the plugin's threadId: its cached
69 // T3_HOME_CHANNEL may name a thread from a previous designation, and a
70 // delivery must never land in an arbitrary caller-named thread.
71 const homeThreadId = yield* getOrCreateHomeThread({
72 instanceId: registration.instanceId,
73 title: registration.accepted.nickname,
74 });
75
76 // Dedupe here, against the projection, before dispatching.
77 //
78 // The decider also dedupes, but it can only see the in-memory command
79 // read model — which is built with `messages: []` on every thread
80 // (`getCommandReadModel`, deliberately, to avoid hydrating message
81 // bodies at boot). Its scan therefore matches nothing, and a replay
82 // after a server restart appends a second copy. This read hits
83 // `projection_thread_messages`, where the delivery actually lives, so
84 // it is the check that holds across a restart — exactly the case the
85 // plugin's durable queue exists to produce.
86 const alreadyDelivered = (threadId: ThreadId) =>
87 query.getThreadDetailById(threadId).pipe(
88 Effect.map(
89 Option.match({
90 onNone: () => false,
91 onSome: (thread) =>
92 thread.messages.some(
93 (candidate) => candidate.notification?.deliveryId === message.deliveryId,
94 ),
95 }),
96 ),
97 Effect.orElseSucceed(() => false),
98 );
99
100 if (!(yield* alreadyDelivered(homeThreadId))) {
101 const dispatched = yield* Effect.result(
102 engine.dispatch({
103 type: "thread.notification.deliver",
104 commandId: CommandId.make(yield* crypto.randomUUIDv4),
105 threadId: homeThreadId,
106 messageId: MessageId.make(yield* crypto.randomUUIDv4),
107 deliveryId: message.deliveryId,
108 kind: message.kind,
109 label: message.label,
110 text: message.text,
111 createdAt: message.createdAt,
112 }),
113 );
114
115 // A dispatch failure is not proof the delivery is absent — a racing
116 // socket may have won between the read above and this write. Re-read
117 // before giving up, or that delivery goes unacked forever and the
118 // plugin replays it on every single reconnect.
119 if (dispatched._tag === "Failure" && !(yield* alreadyDelivered(homeThreadId))) {
120 return yield* Effect.fail(dispatched.failure);
121 }
122 }
123
124 yield* transport.send({
125 type: "home.deliver.ack",
126 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
127 deliveryId: message.deliveryId,
128 });
129 }).pipe(
130 Effect.catchCause((cause) =>
131 // Deliberately no ack: the plugin keeps the delivery queued and
132 // retries it on the next connect.
133 Effect.logWarning("Hermes home delivery failed", {
134 instanceId: registration.instanceId,
135 deliveryId: message.deliveryId,
136 cause: Cause.pretty(cause),
137 }),
138 ),
139 );
140
31 return HttpRouter.add(141 return HttpRouter.add(
32 "GET",142 "GET",
33 HERMES_GATEWAY_WEBSOCKET_PATH,143 HERMES_GATEWAY_WEBSOCKET_PATH,
@@ -76,7 +186,31 @@ export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( · 42 unmodified lines
76 ),186 ),
77 );187 );
78 yield* Ref.set(registration, Option.some(registered));188 yield* Ref.set(registration, Option.some(registered));
79 yield* transport.send(registered.accepted);189
190 // Resolve the home thread here rather than inside
191 // `registerConnection`, whose error channel is exactly
192 // `connection.rejected`: a thread-creation failure is not a
193 // reason to refuse an authenticated plugin. On failure the
194 // handshake completes without `homeThreadId`, the plugin keeps
195 // whatever designation it already had, and the next connect
196 // converges — which is the whole point of converge-on-read.
197 const homeThreadId = yield* getOrCreateHomeThread({
198 instanceId: registered.instanceId,
199 title: registered.accepted.nickname,
200 }).pipe(
201 Effect.map(Option.some),
202 Effect.catchCause((cause) =>
203 Effect.logWarning("home thread resolution failed", {
204 instanceId: registered.instanceId,
205 cause: Cause.pretty(cause),
206 }).pipe(Effect.as(Option.none<ThreadId>())),
207 ),
208 );
209
210 yield* transport.send({
211 ...registered.accepted,
212 ...(Option.isSome(homeThreadId) ? { homeThreadId: homeThreadId.value } : {}),
213 });
80 return;214 return;
81 }215 }
82 216
@@ -84,6 +218,17 @@ export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( · 1 unmodified line
84 yield* transport.close(4002, "connection.hello may only be sent once");218 yield* transport.close(4002, "connection.hello may only be sent once");
85 return;219 return;
86 }220 }
221
222 // Deliveries are handled on the socket that carried them rather
223 // than through the broker's event stream, because the ack has to
224 // go back to *this* connection — which for an out-of-process cron
225 // run is a short-lived delivery socket that no stream subscriber
226 // can address.
227 if (message.type === "home.deliver") {
228 yield* deliverHomeNotification(current.value, message, transport);
229 return;
230 }
231
87 yield* broker.receive(current.value, message);232 yield* broker.receive(current.value, message);
88 }).pipe(233 }).pipe(
89 Effect.catch((cause) =>234 Effect.catch((cause) =>
apps/server/src/relay/AgentAwarenessRelay.test.ts+24−0
@@ -201,6 +201,30 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 200 unmodified lines
201 },201 },
202 } as unknown as OrchestrationEvent),202 } as unknown as OrchestrationEvent),
203 ).toBe(false);203 ).toBe(false);
204 // A cron result or agent-initiated message is exactly the news push
205 // exists for; a lifecycle notice appends quietly and must never ring.
206 for (const kind of ["cron", "message", "handoff", "other"] as const) {
207 expect(
208 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
209 ...base,
210 type: "thread.notification-delivered",
211 payload: {
212 threadId: "thread-1" as ThreadId,
213 notification: { kind, label: "Cron: daily-digest", deliveryId: "delivery-1" },
214 },
215 } as unknown as OrchestrationEvent),
216 ).toBe(true);
217 }
218 expect(
219 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
220 ...base,
221 type: "thread.notification-delivered",
222 payload: {
223 threadId: "thread-1" as ThreadId,
224 notification: { kind: "lifecycle", label: "Agent restarted", deliveryId: "delivery-2" },
225 },
226 } as unknown as OrchestrationEvent),
227 ).toBe(false);
204 });228 });
205 229
206 it("deduplicates awareness state updates whose only change is their event timestamp", () => {230 it("deduplicates awareness state updates whose only change is their event timestamp", () => {
apps/server/src/relay/AgentAwarenessRelay.ts+5−0
@@ -78,6 +78,11 @@ export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boo · 77 unmodified lines
78 case "thread.runtime-mode-set":78 case "thread.runtime-mode-set":
79 case "thread.interaction-mode-set":79 case "thread.interaction-mode-set":
80 return false;80 return false;
81 case "thread.notification-delivered":
82 // A cron result or agent-initiated message is exactly the kind of news
83 // push exists for. A lifecycle notice ("agent restarted") is not: it
84 // appends quietly and must never ring a device.
85 return event.payload.notification.kind !== "lifecycle";
81 case "thread.activity-appended":86 case "thread.activity-appended":
82 return (87 return (
83 event.payload.activity.kind === "approval.requested" ||88 event.payload.activity.kind === "approval.requested" ||
apps/server/src/serverRuntimeStartup.test.ts+4−0
@@ -96,6 +96,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa · 95 unmodified lines
96 getThreadCheckpointContext: () => Effect.succeed(Option.none()),96 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
97 getFullThreadDiffContext: () => Effect.succeed(Option.none()),97 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
98 getThreadShellById: () => Effect.succeed(Option.none()),98 getThreadShellById: () => Effect.succeed(Option.none()),
99 getThreadArchiveStateById: () => Effect.succeed(Option.none()),
99 getThreadDetailById: () => Effect.succeed(Option.none()),100 getThreadDetailById: () => Effect.succeed(Option.none()),
100 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),101 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
101 }),102 }),
@@ -161,6 +162,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa · 59 unmodified lines
161 getThreadCheckpointContext: () => Effect.succeed(Option.none()),162 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
162 getFullThreadDiffContext: () => Effect.succeed(Option.none()),163 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
163 getThreadShellById: () => Effect.die("unused"),164 getThreadShellById: () => Effect.die("unused"),
165 getThreadArchiveStateById: () => Effect.die("unused"),
164 getThreadDetailById: () => Effect.die("unused"),166 getThreadDetailById: () => Effect.die("unused"),
165 getThreadDetailSnapshot: () => Effect.die("unused"),167 getThreadDetailSnapshot: () => Effect.die("unused"),
166 }),168 }),
@@ -206,6 +208,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when · 39 unmodified lines
206 getThreadCheckpointContext: () => Effect.succeed(Option.none()),208 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
207 getFullThreadDiffContext: () => Effect.succeed(Option.none()),209 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
208 getThreadShellById: () => Effect.die("unused"),210 getThreadShellById: () => Effect.die("unused"),
211 getThreadArchiveStateById: () => Effect.die("unused"),
209 getThreadDetailById: () => Effect.die("unused"),212 getThreadDetailById: () => Effect.die("unused"),
210 getThreadDetailSnapshot: () => Effect.die("unused"),213 getThreadDetailSnapshot: () => Effect.die("unused"),
211 }),214 }),
@@ -257,6 +260,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa · 45 unmodified lines
257 getThreadCheckpointContext: () => Effect.succeed(Option.none()),260 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
258 getFullThreadDiffContext: () => Effect.succeed(Option.none()),261 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
259 getThreadShellById: () => Effect.die("unused"),262 getThreadShellById: () => Effect.die("unused"),
263 getThreadArchiveStateById: () => Effect.die("unused"),
260 getThreadDetailById: () => Effect.die("unused"),264 getThreadDetailById: () => Effect.die("unused"),
261 getThreadDetailSnapshot: () => Effect.die("unused"),265 getThreadDetailSnapshot: () => Effect.die("unused"),
262 }),266 }),
apps/server/src/ws.ts+22−0
@@ -73,6 +73,7 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; · 72 unmodified lines
73import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";73import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
74import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";74import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
75import { getOrCreateAgentProject } from "./orchestration/agentProjects.ts";75import { getOrCreateAgentProject } from "./orchestration/agentProjects.ts";
76import { isHomeThread } from "./orchestration/homeThreads.ts";
76import {77import {
77 observeRpcEffect as instrumentRpcEffect,78 observeRpcEffect as instrumentRpcEffect,
78 observeRpcStream as instrumentRpcStream,79 observeRpcStream as instrumentRpcStream,
@@ -262,6 +263,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< · 183 unmodified lines
262 {263 {
263 type:264 type:
264 | "thread.message-sent"265 | "thread.message-sent"
266 | "thread.notification-delivered"
265 | "thread.proposed-plan-upserted"267 | "thread.proposed-plan-upserted"
266 | "thread.activity-appended"268 | "thread.activity-appended"
267 | "thread.turn-diff-completed"269 | "thread.turn-diff-completed"
@@ -271,6 +273,9 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< · 3 unmodified lines
271> {273> {
272 return (274 return (
273 event.type === "thread.message-sent" ||275 event.type === "thread.message-sent" ||
276 // A proactive delivery appends a transcript message, so an open detail
277 // subscription has to receive it or the row only appears on reload.
278 event.type === "thread.notification-delivered" ||
274 event.type === "thread.proposed-plan-upserted" ||279 event.type === "thread.proposed-plan-upserted" ||
275 event.type === "thread.activity-appended" ||280 event.type === "thread.activity-appended" ||
276 event.type === "thread.turn-diff-completed" ||281 event.type === "thread.turn-diff-completed" ||
@@ -1124,6 +1129,23 @@ const makeWsRpcLayer = ( · 847 unmodified lines
1124 ORCHESTRATION_WS_METHODS.dispatchCommand,1129 ORCHESTRATION_WS_METHODS.dispatchCommand,
1125 Effect.gen(function* () {1130 Effect.gen(function* () {
1126 const normalizedCommand = yield* normalizeDispatchCommand(command);1131 const normalizedCommand = yield* normalizeDispatchCommand(command);
1132
1133 // A home thread is a fixed designation, not a movable one:
1134 // deleting it would leave its agent with nowhere to deliver, and
1135 // the next handshake would silently mint a replacement, orphaning
1136 // the history in the old one. This is the real guard — the UI
1137 // only declines to offer the action.
1138 if (
1139 normalizedCommand.type === "thread.delete" &&
1140 (yield* isHomeThread(normalizedCommand.threadId))
1141 ) {
1142 return yield* Effect.fail(
1143 new OrchestrationDispatchCommandError({
1144 message: "This agent's Home thread cannot be deleted.",
1145 }),
1146 );
1147 }
1148
1127 const shouldStopSessionAfterArchive =1149 const shouldStopSessionAfterArchive =
1128 normalizedCommand.type === "thread.archive"1150 normalizedCommand.type === "thread.archive"
1129 ? yield* projectionSnapshotQuery1151 ? yield* projectionSnapshotQuery
apps/web/src/components/Sidebar.logic.test.ts+149−0
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" · 1 unmodified line
2import {2import {
3 archiveSelectedThreadEntries,3 archiveSelectedThreadEntries,
4 buildAgentSidebarEntries,4 buildAgentSidebarEntries,
5 buildHomeThreadIdByProjectKey,
5 buildMultiSelectThreadContextMenuItems,6 buildMultiSelectThreadContextMenuItems,
6 createThreadJumpHintVisibilityController,7 createThreadJumpHintVisibilityController,
7 formatAgentSidebarLabel,8 formatAgentSidebarLabel,
@@ -42,6 +43,7 @@ import { · 34 unmodified lines
42 ThreadId,43 ThreadId,
43} from "@t3tools/contracts";44} from "@t3tools/contracts";
44 45
46import { sortThreads, type ThreadSortInput } from "../lib/threadSort";
45import {47import {
46 DEFAULT_INTERACTION_MODE,48 DEFAULT_INTERACTION_MODE,
47 DEFAULT_RUNTIME_MODE,49 DEFAULT_RUNTIME_MODE,
@@ -1557,4 +1559,151 @@ describe("agents sidebar section", () => { · 1509 unmodified lines
1557 expect(entries).toHaveLength(1);1559 expect(entries).toHaveLength(1);
1558 expect(entries[0]?.instanceId).toBe(instanceId);1560 expect(entries[0]?.instanceId).toBe(instanceId);
1559 });1561 });
1562
1563 it("carries the provider's designated home thread onto the entry", () => {
1564 const entries = buildAgentSidebarEntries({
1565 projects: [makeAgentProject()],
1566 providerByInstanceId: new Map([
1567 [String(instanceId), makeServerProvider({ homeThreadId: "thread-home" as never })],
1568 ]),
1569 });
1570
1571 expect(entries[0]?.homeThreadId).toBe("thread-home");
1572 });
1573
1574 it("reports no home thread for a driver that designates none", () => {
1575 // Every non-Hermes driver, and a Hermes instance before its first
1576 // handshake — both must read as "this list has no pinned row".
1577 const entries = buildAgentSidebarEntries({
1578 projects: [makeAgentProject()],
1579 providerByInstanceId: new Map([[String(instanceId), makeServerProvider()]]),
1580 });
1581
1582 expect(entries[0]?.homeThreadId).toBeNull();
1583 });
1584});
1585
1586describe("buildHomeThreadIdByProjectKey", () => {
1587 function makeEntry(input: {
1588 readonly instanceId: string;
1589 readonly projectKey: string;
1590 readonly homeThreadId: string | null;
1591 }) {
1592 return {
1593 instanceId: input.instanceId as never,
1594 project: {
1595 agentInstanceId: input.instanceId as never,
1596 title: input.instanceId,
1597 displayName: input.instanceId,
1598 projectKey: input.projectKey,
1599 },
1600 label: input.instanceId,
1601 statusKey: "ready" as const,
1602 statusLabel: "Connected",
1603 statusPulse: false,
1604 homeThreadId: input.homeThreadId as never,
1605 };
1606 }
1607
1608 it("maps each agent's project key to its home thread", () => {
1609 const map = buildHomeThreadIdByProjectKey([
1610 makeEntry({ instanceId: "hermes-a", projectKey: "project-a", homeThreadId: "thread-home-a" }),
1611 makeEntry({ instanceId: "hermes-b", projectKey: "project-b", homeThreadId: "thread-home-b" }),
1612 ]);
1613
1614 expect(map.get("project-a")).toBe("thread-home-a");
1615 expect(map.get("project-b")).toBe("thread-home-b");
1616 });
1617
1618 it("omits agents with no designation", () => {
1619 const map = buildHomeThreadIdByProjectKey([
1620 makeEntry({ instanceId: "hermes-a", projectKey: "project-a", homeThreadId: null }),
1621 ]);
1622
1623 expect(map.size).toBe(0);
1624 });
1625
1626 it("keeps the first entry when two agents collapse onto one project key", () => {
1627 // `visibleSidebarSections` de-duplicates by `projectKey` and keeps the
1628 // first section, so the pin must resolve to the same one.
1629 const map = buildHomeThreadIdByProjectKey([
1630 makeEntry({ instanceId: "hermes-a", projectKey: "shared", homeThreadId: "thread-home-a" }),
1631 makeEntry({ instanceId: "hermes-b", projectKey: "shared", homeThreadId: "thread-home-b" }),
1632 ]);
1633
1634 expect(map.get("shared")).toBe("thread-home-a");
1635 });
1636});
1637
1638describe("home thread pinning across both sidebar sort sites", () => {
1639 // `SidebarProjectItem` (the rendered rows) and `visibleSidebarThreadKeys`
1640 // (jump shortcuts, prev/next, prewarming) each call `sortThreads` with the
1641 // same pinned id. These assert the shared helper produces one ordering, and
1642 // that the ordering survives the prewarm slice.
1643 function makeSortableThread(input: {
1644 readonly id: string;
1645 readonly updatedAt: string;
1646 }): { id: string } & ThreadSortInput {
1647 return {
1648 id: input.id,
1649 createdAt: input.updatedAt,
1650 updatedAt: input.updatedAt,
1651 latestUserMessageAt: input.updatedAt,
1652 messages: [],
1653 };
1654 }
1655
1656 const threads = [
1657 makeSortableThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
1658 makeSortableThread({ id: "thread-middle", updatedAt: "2026-03-09T10:00:00.000Z" }),
1659 // Deliberately the oldest row in the list: a home thread whose last cron
1660 // ran a month ago must still hold position one.
1661 makeSortableThread({ id: "thread-home", updatedAt: "2026-01-01T00:00:00.000Z" }),
1662 ];
1663
1664 it("sorts the home thread first even when it is the oldest", () => {
1665 expect(sortThreads(threads, "updated_at", "thread-home").map((thread) => thread.id)).toEqual([
1666 "thread-home",
1667 "thread-newest",
1668 "thread-middle",
1669 ]);
1670 });
1671
1672 it("produces an identical ordering at both sort sites", () => {
1673 const renderOrder = sortThreads(threads, "updated_at", "thread-home");
1674 const traversalOrder = sortThreads(threads, "updated_at", "thread-home");
1675
1676 expect(renderOrder.map((thread) => thread.id)).toEqual(
1677 traversalOrder.map((thread) => thread.id),
1678 );
1679 });
1680
1681 it("leaves a provider list with no designation unchanged", () => {
1682 // Non-Hermes drivers and pre-handshake Hermes instances both land here.
1683 expect(sortThreads(threads, "updated_at", null).map((thread) => thread.id)).toEqual([
1684 "thread-newest",
1685 "thread-middle",
1686 "thread-home",
1687 ]);
1688 expect(sortThreads(threads, "updated_at").map((thread) => thread.id)).toEqual([
1689 "thread-newest",
1690 "thread-middle",
1691 "thread-home",
1692 ]);
1693 });
1694
1695 it("ignores a pinned id that matches no thread", () => {
1696 expect(sortThreads(threads, "updated_at", "thread-gone").map((thread) => thread.id)).toEqual([
1697 "thread-newest",
1698 "thread-middle",
1699 "thread-home",
1700 ]);
1701 });
1702
1703 it("puts the home thread in the prewarm slice even when the limit is one", () => {
1704 // Pinning happens before the prewarm slice, so Home is always warm.
1705 const ordered = sortThreads(threads, "updated_at", "thread-home").map((thread) => thread.id);
1706
1707 expect(getSidebarThreadIdsToPrewarm(ordered, 1)).toEqual(["thread-home"]);
1708 });
1560});1709});
apps/web/src/components/Sidebar.logic.ts+42−1
@@ -1,5 +1,10 @@
1import * as React from "react";1import * as React from "react";
2import type { ContextMenuItem, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";2import type {
3 ContextMenuItem,
4 ProviderInstanceId,
5 ServerProvider,
6 ThreadId,
7} from "@t3tools/contracts";
3import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";8import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
4import type { ProviderStatusKey } from "./settings/providerStatus";9import type { ProviderStatusKey } from "./settings/providerStatus";
5import {10import {
@@ -879,6 +884,39 @@ export interface AgentSidebarEntry<TProject extends AgentSidebarProjectInput> { · 873 unmodified lines
879 readonly statusLabel: string;884 readonly statusLabel: string;
880 /** True while the instance is mid-handshake, so the dot can pulse. */885 /** True while the instance is mid-handshake, so the dot can pulse. */
881 readonly statusPulse: boolean;886 readonly statusPulse: boolean;
887 /**
888 * The instance's home thread, pinned first in its thread list and withheld
889 * from Delete.
890 *
891 * `null` for every driver that designates none, and for a Hermes instance
892 * whose first handshake has not landed — both mean "this list has no pinned
893 * row", which is the same rendering.
894 */
895 readonly homeThreadId: ThreadId | null;
896}
897
898/**
899 * Home thread per sidebar section key.
900 *
901 * The two thread-ordering sites (the rendered project row and the flat
902 * `visibleSidebarThreadKeys` used by jump shortcuts, prev/next, and
903 * prewarming) both need this, and they MUST agree — a section whose visual
904 * order differs from the traversal order desyncs the keyboard from the eye.
905 * One map derived once is what keeps them honest.
906 */
907export function buildHomeThreadIdByProjectKey<
908 TProject extends AgentSidebarProjectInput & { readonly projectKey: string },
909>(entries: ReadonlyArray<AgentSidebarEntry<TProject>>): ReadonlyMap<string, ThreadId> {
910 const byProjectKey = new Map<string, ThreadId>();
911 for (const entry of entries) {
912 if (entry.homeThreadId === null) continue;
913 // First writer wins: `visibleSidebarSections` de-duplicates by
914 // `projectKey`, keeping the first section for a key, so the pin has to
915 // resolve to that same one.
916 if (byProjectKey.has(entry.project.projectKey)) continue;
917 byProjectKey.set(entry.project.projectKey, entry.homeThreadId);
918 }
919 return byProjectKey;
882}920}
883 921
884const AGENT_STATUS_LABELS: Record<ProviderStatusKey, string> = {922const AGENT_STATUS_LABELS: Record<ProviderStatusKey, string> = {
@@ -960,6 +998,9 @@ export function buildAgentSidebarEntries<TProject extends AgentSidebarProjectInp · 75 unmodified lines
960 statusKey,998 statusKey,
961 statusLabel: AGENT_STATUS_LABELS[statusKey],999 statusLabel: AGENT_STATUS_LABELS[statusKey],
962 statusPulse: statusKey === "warning" && provider?.auth.status === "unauthenticated",1000 statusPulse: statusKey === "warning" && provider?.auth.status === "unauthenticated",
1001 // Re-emitted on every provider status tick, so a designation made after
1002 // the sidebar mounted pins on the next push without a refetch.
1003 homeThreadId: provider?.homeThreadId ?? null,
963 });1004 });
964 }1005 }
965 1006
apps/web/src/components/Sidebar.tsx+58−2
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 ContainerIcon,6 ContainerIcon,
7 FolderPlusIcon,7 FolderPlusIcon,
8 Globe2Icon,8 Globe2Icon,
9 HouseIcon,
9 LoaderIcon,10 LoaderIcon,
10 SearchIcon,11 SearchIcon,
11 SquarePenIcon,12 SquarePenIcon,
@@ -174,6 +175,7 @@ import { openCommandPalette } from "../commandPaletteBus"; · 162 unmodified lines
174import {175import {
175 archiveSelectedThreadEntries,176 archiveSelectedThreadEntries,
176 buildAgentSidebarEntries,177 buildAgentSidebarEntries,
178 buildHomeThreadIdByProjectKey,
177 buildMultiSelectThreadContextMenuItems,179 buildMultiSelectThreadContextMenuItems,
178 type AgentSidebarEntry,180 type AgentSidebarEntry,
179 getSidebarThreadIdsToPrewarm,181 getSidebarThreadIdsToPrewarm,
@@ -312,6 +314,11 @@ interface SidebarThreadRowProps { · 132 unmodified lines
312 projectCwd: string | null;314 projectCwd: string | null;
313 orderedProjectThreadKeys: readonly string[];315 orderedProjectThreadKeys: readonly string[];
314 isActive: boolean;316 isActive: boolean;
317 /**
318 * The agent's home thread. Pinned first and marked with a small leading
319 * icon; the title stays the plain thread title ("Home"), no emoji.
320 */
321 isHomeThread: boolean;
315 jumpLabel: string | null;322 jumpLabel: string | null;
316 appSettingsConfirmThreadArchive: boolean;323 appSettingsConfirmThreadArchive: boolean;
317 renamingThreadKey: string | null;324 renamingThreadKey: string | null;
@@ -349,6 +356,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 31 unmodified lines
349 const {356 const {
350 orderedProjectThreadKeys,357 orderedProjectThreadKeys,
351 isActive,358 isActive,
359 isHomeThread,
352 jumpLabel,360 jumpLabel,
353 appSettingsConfirmThreadArchive,361 appSettingsConfirmThreadArchive,
354 renamingThreadKey,362 renamingThreadKey,
@@ -705,6 +713,28 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 350 unmodified lines
705 </Tooltip>713 </Tooltip>
706 )}714 )}
707 {threadStatus && <ThreadStatusLabel status={threadStatus} />}715 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
716 {/* Restrained: one muted leading glyph, no emoji and no badge. The
717 row is a normal thread in every other respect — the mark only
718 explains why it never moves out of first position. */}
719 {isHomeThread && (
720 <Tooltip>
721 <TooltipTrigger
722 render={
723 <span
724 role="img"
725 aria-label="Home thread"
726 data-testid={`thread-home-marker-${thread.id}`}
727 className="inline-flex shrink-0 items-center justify-center text-sidebar-muted-foreground/70"
728 />
729 }
730 >
731 <HouseIcon className="size-3" />
732 </TooltipTrigger>
733 <TooltipPopup side="top">
734 Home thread — this agent&rsquo;s proactive messages land here
735 </TooltipPopup>
736 </Tooltip>
737 )}
708 {renamingThreadKey === threadKey ? (738 {renamingThreadKey === threadKey ? (
709 <input739 <input
710 ref={handleRenameInputRef}740 ref={handleRenameInputRef}
@@ -890,6 +920,8 @@ interface SidebarProjectThreadListProps { · 179 unmodified lines
890 hiddenThreadStatus: ThreadStatusPill | null;920 hiddenThreadStatus: ThreadStatusPill | null;
891 orderedProjectThreadKeys: readonly string[];921 orderedProjectThreadKeys: readonly string[];
892 renderedThreads: readonly SidebarThreadSummary[];922 renderedThreads: readonly SidebarThreadSummary[];
923 /** Agent's home thread, or `null` for a workspace row. */
924 homeThreadId: ThreadId | null;
893 showEmptyThreadState: boolean;925 showEmptyThreadState: boolean;
894 shouldShowThreadPanel: boolean;926 shouldShowThreadPanel: boolean;
895 isThreadListExpanded: boolean;927 isThreadListExpanded: boolean;
@@ -941,6 +973,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 45 unmodified lines
941 hiddenThreadStatus,973 hiddenThreadStatus,
942 orderedProjectThreadKeys,974 orderedProjectThreadKeys,
943 renderedThreads,975 renderedThreads,
976 homeThreadId,
944 showEmptyThreadState,977 showEmptyThreadState,
945 shouldShowThreadPanel,978 shouldShowThreadPanel,
946 isThreadListExpanded,979 isThreadListExpanded,
@@ -998,6 +1031,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( · 51 unmodified lines
998 projectCwd={projectCwd}1031 projectCwd={projectCwd}
999 orderedProjectThreadKeys={orderedProjectThreadKeys}1032 orderedProjectThreadKeys={orderedProjectThreadKeys}
1000 isActive={activeRouteThreadKey === threadKey}1033 isActive={activeRouteThreadKey === threadKey}
1034 isHomeThread={homeThreadId !== null && thread.id === homeThreadId}
1001 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}1035 jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null}
1002 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}1036 appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
1003 renamingThreadKey={renamingThreadKey}1037 renamingThreadKey={renamingThreadKey}
@@ -1179,6 +1213,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 175 unmodified lines
1179 },1213 },
1180 });1214 });
1181 const openPrLink = useOpenPrLink();1215 const openPrLink = useOpenPrLink();
1216 // The agent's designated home thread. Pinned first, and Delete is withheld
1217 // from it (the server rejects the delete regardless — the UI just must not
1218 // offer an action that always fails). `null` for workspace rows and for an
1219 // agent that has not completed a handshake.
1220 const homeThreadId = agent?.homeThreadId ?? null;
1182 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);1221 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
1183 const sidebarThreadByKey = useMemo(1222 const sidebarThreadByKey = useMemo(
1184 () =>1223 () =>
@@ -1272,6 +1311,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 87 unmodified lines
1272 const visibleProjectThreads = sortThreads(1311 const visibleProjectThreads = sortThreads(
1273 projectThreads.filter((thread) => thread.archivedAt === null),1312 projectThreads.filter((thread) => thread.archivedAt === null),
1274 threadSortOrder,1313 threadSortOrder,
1314 homeThreadId,
1275 );1315 );
1276 const projectStatus = resolveProjectStatusIndicator(1316 const projectStatus = resolveProjectStatusIndicator(
1277 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),1317 visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
@@ -1283,7 +1323,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 5 unmodified lines
1283 projectStatus,1323 projectStatus,
1284 visibleProjectThreads,1324 visibleProjectThreads,
1285 };1325 };
1286 }, [projectThreads, threadLastVisitedAts, threadSortOrder]);1326 }, [homeThreadId, projectThreads, threadLastVisitedAts, threadSortOrder]);
1287 const pinnedCollapsedThread = useMemo(() => {1327 const pinnedCollapsedThread = useMemo(() => {
1288 const activeThreadKey = activeRouteThreadKey ?? undefined;1328 const activeThreadKey = activeRouteThreadKey ?? undefined;
1289 if (!activeThreadKey || projectExpanded) {1329 if (!activeThreadKey || projectExpanded) {
@@ -2157,6 +2197,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 867 unmodified lines
2157 );2197 );
2158 const threadWorkspacePath =2198 const threadWorkspacePath =
2159 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;2199 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2200 // The agent's home thread is undeletable — the server's decider rejects
2201 // it, so offering the action would only ever produce an error toast.
2202 const isHomeThread = homeThreadId !== null && thread.id === homeThreadId;
2160 const clicked = await api.contextMenu.show(2203 const clicked = await api.contextMenu.show(
2161 [2204 [
2162 ...(thread.branch2205 ...(thread.branch
@@ -2166,7 +2209,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 3 unmodified lines
2166 { id: "mark-unread", label: "Mark unread" },2209 { id: "mark-unread", label: "Mark unread" },
2167 { id: "copy-path", label: "Copy Path" },2210 { id: "copy-path", label: "Copy Path" },
2168 { id: "copy-thread-id", label: "Copy Thread ID" },2211 { id: "copy-thread-id", label: "Copy Thread ID" },
2169 { id: "delete", label: "Delete", destructive: true, icon: "trash" },2212 ...(isHomeThread
2213 ? []
2214 : [{ id: "delete", label: "Delete", destructive: true, icon: "trash" } as const]),
2170 ],2215 ],
2171 position,2216 position,
2172 );2217 );
@@ -2252,6 +2297,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 79 unmodified lines
2252 copyThreadIdToClipboard,2297 copyThreadIdToClipboard,
2253 deleteThread,2298 deleteThread,
2254 handleNewThread,2299 handleNewThread,
2300 homeThreadId,
2255 markThreadUnread,2301 markThreadUnread,
2256 memberProjectByScopedKey,2302 memberProjectByScopedKey,
2257 project.workspaceRoot,2303 project.workspaceRoot,
@@ -2438,6 +2484,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 180 unmodified lines
2438 hiddenThreadStatus={hiddenThreadStatus}2484 hiddenThreadStatus={hiddenThreadStatus}
2439 orderedProjectThreadKeys={orderedProjectThreadKeys}2485 orderedProjectThreadKeys={orderedProjectThreadKeys}
2440 renderedThreads={renderedThreads}2486 renderedThreads={renderedThreads}
2487 homeThreadId={homeThreadId}
2441 showEmptyThreadState={showEmptyThreadState}2488 showEmptyThreadState={showEmptyThreadState}
2442 shouldShowThreadPanel={shouldShowThreadPanel}2489 shouldShowThreadPanel={shouldShowThreadPanel}
2443 isThreadListExpanded={isThreadListExpanded}2490 isThreadListExpanded={isThreadListExpanded}
@@ -3571,6 +3618,13 @@ export default function Sidebar() { · 1127 unmodified lines
3571 }3618 }
3572 return sections;3619 return sections;
3573 }, [agentEntries, sortedProjects]);3620 }, [agentEntries, sortedProjects]);
3621 // Same pin the rendered rows use (`SidebarProjectItem`), keyed the same way.
3622 // These two orderings are the visual list and the traversal list; if they
3623 // disagree, thread-jump numbers land on the wrong row.
3624 const homeThreadIdByProjectKey = useMemo(
3625 () => buildHomeThreadIdByProjectKey(agentEntries),
3626 [agentEntries],
3627 );
3574 const visibleSidebarThreadKeys = useMemo(3628 const visibleSidebarThreadKeys = useMemo(
3575 () =>3629 () =>
3576 visibleSidebarSections.flatMap((project) => {3630 visibleSidebarSections.flatMap((project) => {
@@ -3579,6 +3633,7 @@ export default function Sidebar() { · 2 unmodified lines
3579 (thread) => thread.archivedAt === null,3633 (thread) => thread.archivedAt === null,
3580 ),3634 ),
3581 sidebarThreadSortOrder,3635 sidebarThreadSortOrder,
3636 homeThreadIdByProjectKey.get(project.projectKey) ?? null,
3582 );3637 );
3583 const projectExpanded = resolveProjectExpanded(3638 const projectExpanded = resolveProjectExpanded(
3584 projectExpandedById,3639 projectExpandedById,
@@ -3609,6 +3664,7 @@ export default function Sidebar() { · 24 unmodified lines
3609 );3664 );
3610 }),3665 }),
3611 [3666 [
3667 homeThreadIdByProjectKey,
3612 sidebarThreadSortOrder,3668 sidebarThreadSortOrder,
3613 sidebarThreadPreviewCount,3669 sidebarThreadPreviewCount,
3614 expandedThreadListsByProject,3670 expandedThreadListsByProject,
apps/web/src/components/agents/AgentPage.logic.test.ts+101−0
@@ -1,6 +1,7 @@
1import type {1import type {
2 ProviderInstanceDescription,2 ProviderInstanceDescription,
3 ProviderInstanceId,3 ProviderInstanceId,
4 ServerProvider,
4 ServerProviderSkill,5 ServerProviderSkill,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
@@ -25,8 +26,10 @@ import { · 18 unmodified lines
25 isAgentPageBusy,26 isAgentPageBusy,
26 messageFromAgentPageError,27 messageFromAgentPageError,
27 nextExpandedSkillName,28 nextExpandedSkillName,
29 resolveAgentHomeThreadId,
28 resolveAgentRouteInstanceId,30 resolveAgentRouteInstanceId,
29 shouldFetchAgentSkillBody,31 shouldFetchAgentSkillBody,
32 sortAgentPageThreads,
30 supportsGatewayReconnect,33 supportsGatewayReconnect,
31 upsertAgentSkillBody,34 upsertAgentSkillBody,
32 type AgentConnectionStatus,35 type AgentConnectionStatus,
@@ -334,3 +337,101 @@ describe("resolveAgentRouteInstanceId", () => { · 301 unmodified lines
334 expect(resolveAgentRouteInstanceId({ instanceId: " " })).toBe(null);337 expect(resolveAgentRouteInstanceId({ instanceId: " " })).toBe(null);
335 });338 });
336});339});
340
341describe("agent thread list", () => {
342 function makeProvider(overrides: Partial<ServerProvider> = {}): ServerProvider {
343 return {
344 instanceId: "hermes-main" as ProviderInstanceId,
345 driver: "hermes",
346 displayName: "Hermes · Main",
347 enabled: true,
348 installed: true,
349 version: null,
350 status: "ready",
351 auth: { status: "authenticated", type: "gateway" },
352 checkedAt: "2026-03-09T10:00:00.000Z",
353 models: [],
354 slashCommands: [],
355 skills: [],
356 ...overrides,
357 } as ServerProvider;
358 }
359
360 function makeThread(input: {
361 readonly id: string;
362 readonly updatedAt: string;
363 readonly archivedAt?: string | null;
364 }) {
365 return {
366 id: input.id,
367 createdAt: input.updatedAt,
368 updatedAt: input.updatedAt,
369 latestUserMessageAt: input.updatedAt,
370 messages: [],
371 archivedAt: input.archivedAt ?? null,
372 };
373 }
374
375 const threads = [
376 makeThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
377 makeThread({ id: "thread-middle", updatedAt: "2026-03-09T10:00:00.000Z" }),
378 makeThread({ id: "thread-home", updatedAt: "2026-01-01T00:00:00.000Z" }),
379 ];
380
381 it("reads the designation off the matching provider snapshot", () => {
382 expect(
383 resolveAgentHomeThreadId(
384 [makeProvider({ homeThreadId: "thread-home" as never })],
385 "hermes-main" as ProviderInstanceId,
386 ),
387 ).toBe("thread-home");
388 });
389
390 it("reports no designation for an undesignated or absent provider", () => {
391 expect(resolveAgentHomeThreadId([makeProvider()], "hermes-main" as ProviderInstanceId)).toBe(
392 null,
393 );
394 expect(resolveAgentHomeThreadId([], "hermes-main" as ProviderInstanceId)).toBe(null);
395 expect(
396 resolveAgentHomeThreadId(
397 [makeProvider({ homeThreadId: "thread-home" as never })],
398 "hermes-other" as ProviderInstanceId,
399 ),
400 ).toBe(null);
401 });
402
403 it("pins the home thread first even when it is the oldest", () => {
404 expect(
405 sortAgentPageThreads({
406 threads,
407 sortOrder: "updated_at",
408 homeThreadId: "thread-home" as never,
409 }).map((thread) => thread.id),
410 ).toEqual(["thread-home", "thread-newest", "thread-middle"]);
411 });
412
413 it("leaves an undesignated agent's list in plain recency order", () => {
414 expect(
415 sortAgentPageThreads({ threads, sortOrder: "updated_at", homeThreadId: null }).map(
416 (thread) => thread.id,
417 ),
418 ).toEqual(["thread-newest", "thread-middle", "thread-home"]);
419 });
420
421 it("excludes archived threads", () => {
422 expect(
423 sortAgentPageThreads({
424 threads: [
425 ...threads,
426 makeThread({
427 id: "thread-archived",
428 updatedAt: "2026-03-09T13:00:00.000Z",
429 archivedAt: "2026-03-09T13:30:00.000Z",
430 }),
431 ],
432 sortOrder: "updated_at",
433 homeThreadId: "thread-home" as never,
434 }).map((thread) => thread.id),
435 ).toEqual(["thread-home", "thread-newest", "thread-middle"]);
436 });
437});
apps/web/src/components/agents/AgentPage.logic.ts+43−0
@@ -2,11 +2,15 @@ import type { · 1 unmodified line
2 ProviderInstanceConnection,2 ProviderInstanceConnection,
3 ProviderInstanceDescription,3 ProviderInstanceDescription,
4 ProviderInstanceId,4 ProviderInstanceId,
5 ServerProvider,
5 ServerProviderSkill,6 ServerProviderSkill,
7 ThreadId,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
7 10
8import { getProviderVersionLabel, type ProviderStatusKey } from "../settings/providerStatus";11import { getProviderVersionLabel, type ProviderStatusKey } from "../settings/providerStatus";
9import { searchProviderSkills } from "../../providerSkillSearch";12import { searchProviderSkills } from "../../providerSkillSearch";
13import { sortThreads, type ThreadSortInput } from "../../lib/threadSort";
10 14
11/**15/**
12 * Placeholder for any field the driver did not report.16 * Placeholder for any field the driver did not report.
@@ -283,6 +287,45 @@ export function isAgentInstanceNotFoundError(error: unknown): boolean { · 270 unmodified lines
283 );287 );
284}288}
285 289
290/**
291 * The instance's designated home thread, off the pushed provider snapshot.
292 *
293 * Read from `ServerProvider` rather than `ProviderInstanceDescription`: the
294 * snapshot is re-emitted on every status tick, so a designation made after
295 * the page mounted arrives without a re-describe. Absent for every non-Hermes
296 * driver and for a Hermes instance that has never completed a handshake —
297 * both are `null`, because both mean "this list has no pinned row".
298 */
299export function resolveAgentHomeThreadId(
300 providers: ReadonlyArray<ServerProvider>,
301 instanceId: ProviderInstanceId | string,
302): ThreadId | null {
303 const provider = providers.find((candidate) => candidate.instanceId === instanceId);
304 return provider?.homeThreadId ?? null;
305}
306
307/**
308 * The agent's thread list for the page, home first.
309 *
310 * Archived threads are excluded for the same reason the sidebar excludes
311 * them — this list is "what can I open right now?", and the sort is the same
312 * shared `sortThreads` the sidebar uses so the two surfaces cannot disagree
313 * about order.
314 */
315export function sortAgentPageThreads<
316 T extends { readonly id: string; readonly archivedAt: string | null } & ThreadSortInput,
317>(input: {
318 readonly threads: ReadonlyArray<T>;
319 readonly sortOrder: SidebarThreadSortOrder;
320 readonly homeThreadId: ThreadId | null;
321}): T[] {
322 return sortThreads(
323 input.threads.filter((thread) => thread.archivedAt === null),
324 input.sortOrder,
325 input.homeThreadId,
326 );
327}
328
286/**329/**
287 * Route param → branded instance id.330 * Route param → branded instance id.
288 *331 *
apps/web/src/components/agents/AgentPage.tsx+156−1
@@ -1,18 +1,22 @@
1"use client";1"use client";
2 2
3import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import { scopeProjectRef } from "@t3tools/client-runtime/environment";4import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
6import type {7import type {
7 ProviderInstanceDescription,8 ProviderInstanceDescription,
8 ProviderInstanceId,9 ProviderInstanceId,
9 ServerProviderSkill,10 ServerProviderSkill,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import type { SidebarThreadSortOrder, TimestampFormat } from "@t3tools/contracts/settings";
11import { useNavigate } from "@tanstack/react-router";13import { useNavigate } from "@tanstack/react-router";
12import {14import {
13 ChevronRightIcon,15 ChevronRightIcon,
14 CpuIcon,16 CpuIcon,
17 HouseIcon,
15 LoaderIcon,18 LoaderIcon,
19 MessageSquareIcon,
16 MessageSquarePlusIcon,20 MessageSquarePlusIcon,
17 PlugZapIcon,21 PlugZapIcon,
18 RefreshCwIcon,22 RefreshCwIcon,
@@ -20,19 +24,25 @@ import { · 1 unmodified line
20 ServerIcon,24 ServerIcon,
21 SettingsIcon,25 SettingsIcon,
22 SparklesIcon,26 SparklesIcon,
27 Trash2Icon,
23} from "lucide-react";28} from "lucide-react";
24import { useCallback, useEffect, useMemo, useRef, useState } from "react";29import { useCallback, useEffect, useMemo, useRef, useState } from "react";
25 30
26import { useNewThreadHandler } from "../../hooks/useHandleNewThread";31import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
32import { useClientSettings } from "../../hooks/useSettings";
33import { useThreadActions } from "../../hooks/useThreadActions";
27import { cn } from "../../lib/utils";34import { cn } from "../../lib/utils";
28import { formatProviderDriverKindLabel } from "../../providerModels";35import { formatProviderDriverKindLabel } from "../../providerModels";
29import {36import {
30 formatProviderSkillDisplayName,37 formatProviderSkillDisplayName,
31 formatProviderSkillInstallSource,38 formatProviderSkillInstallSource,
32} from "../../providerSkillPresentation";39} from "../../providerSkillPresentation";
40import { useProjects, useThreadShellsForProjectRefs } from "../../state/entities";
33import { usePrimaryEnvironment } from "../../state/environments";41import { usePrimaryEnvironment } from "../../state/environments";
34import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";42import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";
35import { useAtomCommand } from "../../state/use-atom-command";43import { useAtomCommand } from "../../state/use-atom-command";
44import { buildThreadRouteParams } from "../../threadRoutes";
45import { formatShortTimestamp } from "../../timestampFormat";
36import ChatMarkdown from "../ChatMarkdown";46import ChatMarkdown from "../ChatMarkdown";
37import { hermesGatewayProviderSignal } from "../settings/HermesGatewayInstanceSection.logic";47import { hermesGatewayProviderSignal } from "../settings/HermesGatewayInstanceSection.logic";
38import { PROVIDER_STATUS_STYLES } from "../settings/providerStatus";48import { PROVIDER_STATUS_STYLES } from "../settings/providerStatus";
@@ -61,7 +71,9 @@ import { · 22 unmodified lines
61 isAgentPageBusy,71 isAgentPageBusy,
62 messageFromAgentPageError,72 messageFromAgentPageError,
63 nextExpandedSkillName,73 nextExpandedSkillName,
74 resolveAgentHomeThreadId,
64 shouldFetchAgentSkillBody,75 shouldFetchAgentSkillBody,
76 sortAgentPageThreads,
65 supportsGatewayReconnect,77 supportsGatewayReconnect,
66 upsertAgentSkillBody,78 upsertAgentSkillBody,
67 type AgentPageAction,79 type AgentPageAction,
@@ -173,6 +185,61 @@ function AgentSkillRow(props: { · 105 unmodified lines
173 );185 );
174}186}
175 187
188/**
189 * One row in the agent's thread list.
190 *
191 * The home row gets the same restrained treatment the sidebar uses: a muted
192 * leading glyph and no Delete. The title is whatever the thread is called
193 * ("Home") — plain text, no emoji.
194 */
195function AgentThreadRow(props: {
196 readonly thread: EnvironmentThreadShell;
197 readonly isHomeThread: boolean;
198 readonly timestampFormat: TimestampFormat;
199 readonly onOpen: (thread: EnvironmentThreadShell) => void;
200 readonly onDelete: ((thread: EnvironmentThreadShell) => void) | null;
201}) {
202 const { isHomeThread, thread } = props;
203 return (
204 <div className="group/agent-thread relative flex items-center border-b border-border/50 last:border-b-0">
205 <button
206 type="button"
207 className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2.5 text-left hover:bg-accent/40"
208 data-testid={`agent-thread-row-${thread.id}`}
209 onClick={() => props.onOpen(thread)}
210 >
211 {isHomeThread ? (
212 <HouseIcon
213 aria-label="Home thread"
214 className="size-3.5 shrink-0 text-muted-foreground/70"
215 />
216 ) : (
217 <MessageSquareIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground/40" />
218 )}
219 <span className="min-w-0 flex-1 truncate text-sm text-foreground" title={thread.title}>
220 {thread.title}
221 </span>
222 <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
223 {formatShortTimestamp(thread.updatedAt, props.timestampFormat)}
224 </span>
225 </button>
226 {/* Withheld for the home thread: the server's decider rejects the
227 delete, so the button could only ever produce an error. */}
228 {props.onDelete && !isHomeThread ? (
229 <button
230 type="button"
231 aria-label={`Delete ${thread.title}`}
232 data-testid={`agent-thread-delete-${thread.id}`}
233 className="mr-1.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/12 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover/agent-thread:opacity-100"
234 onClick={() => props.onDelete?.(thread)}
235 >
236 <Trash2Icon className="size-3.5" />
237 </button>
238 ) : null}
239 </div>
240 );
241}
242
176export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanceId }) {243export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanceId }) {
177 const environmentId = usePrimaryEnvironment()?.environmentId ?? null;244 const environmentId = usePrimaryEnvironment()?.environmentId ?? null;
178 const navigate = useNavigate();245 const navigate = useNavigate();
@@ -194,6 +261,7 @@ export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanc · 15 unmodified lines
194 const getGatewayStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, {261 const getGatewayStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, {
195 reportFailure: false,262 reportFailure: false,
196 });263 });
264 const { deleteThread } = useThreadActions();
197 265
198 const [description, setDescription] = useState<ProviderInstanceDescription | null>(null);266 const [description, setDescription] = useState<ProviderInstanceDescription | null>(null);
199 const [error, setError] = useState<string | null>(null);267 const [error, setError] = useState<string | null>(null);
@@ -323,6 +391,62 @@ export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanc · 123 unmodified lines
323 })();391 })();
324 };392 };
325 393
394 // Threads come off the same shell projection every other surface reads —
395 // the sidebar, the command palette — rather than a page-local fetch, so the
396 // list repaints live as the agent works and never disagrees with the
397 // sidebar's copy of the same rows. The agent's synthetic project is found by
398 // its `agentInstanceId` discriminator, which is exactly how the sidebar
399 // recognizes an agent row.
400 const projects = useProjects();
401 const agentProjectRefs = useMemo(
402 () =>
403 projects
404 .filter((project) => project.agentInstanceId === instanceId)
405 .map((project) => scopeProjectRef(project.environmentId, project.id)),
406 [instanceId, projects],
407 );
408 const agentThreads = useThreadShellsForProjectRefs(agentProjectRefs);
409 const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
410 (settings) => settings.sidebarThreadSortOrder,
411 );
412 const timestampFormat = useClientSettings<TimestampFormat>(
413 (settings) => settings.timestampFormat,
414 );
415 const homeThreadId = useMemo(
416 () => resolveAgentHomeThreadId(providers, instanceId),
417 [instanceId, providers],
418 );
419 const visibleThreads = useMemo(
420 () => sortAgentPageThreads({ threads: agentThreads, sortOrder: threadSortOrder, homeThreadId }),
421 [agentThreads, homeThreadId, threadSortOrder],
422 );
423
424 const handleOpenThread = useCallback(
425 (thread: EnvironmentThreadShell) => {
426 void navigate({
427 to: "/$environmentId/$threadId",
428 params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
429 });
430 },
431 [navigate],
432 );
433 const handleDeleteThread = useCallback(
434 (thread: EnvironmentThreadShell) => {
435 void (async () => {
436 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id));
437 if (result._tag === "Failure") {
438 const failure = squashAtomCommandFailure(result);
439 toastManager.add({
440 type: "error",
441 title: "Failed to delete thread",
442 description: messageFromAgentPageError(failure),
443 });
444 }
445 })();
446 },
447 [deleteThread],
448 );
449
326 const status = description?.connection.status ?? "offline";450 const status = description?.connection.status ?? "offline";
327 const isBusy = isAgentPageBusy(pendingAction);451 const isBusy = isAgentPageBusy(pendingAction);
328 const title = agentPageTitle(description, instanceId);452 const title = agentPageTitle(description, instanceId);
@@ -580,6 +704,37 @@ export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanc · 251 unmodified lines
580 </SectionCard>704 </SectionCard>
581 </SettingsSection>705 </SettingsSection>
582 706
707 <SettingsSection
708 title="Threads"
709 icon={<MessageSquareIcon className="size-4" />}
710 headerAction={
711 visibleThreads.length > 0 ? (
712 <span className="text-xs text-muted-foreground">{visibleThreads.length}</span>
713 ) : null
714 }
715 >
716 <SectionCard>
717 {visibleThreads.length === 0 ? (
718 <p className="text-xs text-muted-foreground/70 italic">
719 No threads yet. Start one with “New thread” above.
720 </p>
721 ) : (
722 <div className="min-w-0 rounded-lg border border-border/60 bg-background/60">
723 {visibleThreads.map((thread) => (
724 <AgentThreadRow
725 key={`${thread.environmentId}:${thread.id}`}
726 thread={thread}
727 isHomeThread={homeThreadId !== null && thread.id === homeThreadId}
728 timestampFormat={timestampFormat}
729 onOpen={handleOpenThread}
730 onDelete={handleDeleteThread}
731 />
732 ))}
733 </div>
734 )}
735 </SectionCard>
736 </SettingsSection>
737
583 <SettingsSection title="MCP servers" icon={<ServerIcon className="size-4" />}>738 <SettingsSection title="MCP servers" icon={<ServerIcon className="size-4" />}>
584 <SectionCard>739 <SectionCard>
585 <p className="text-xs text-muted-foreground/70 italic">740 <p className="text-xs text-muted-foreground/70 italic">
apps/web/src/components/chat/MessagesTimeline.tsx+31−0
@@ -39,6 +39,7 @@ import { · 38 unmodified lines
39} from "../../lib/diffRendering";39} from "../../lib/diffRendering";
40import ChatMarkdown from "../ChatMarkdown";40import ChatMarkdown from "../ChatMarkdown";
41import {41import {
42 BellIcon,
42 BotIcon,43 BotIcon,
43 CheckIcon,44 CheckIcon,
44 ChevronDownIcon,45 ChevronDownIcon,
@@ -81,6 +82,10 @@ import { · 36 unmodified lines
81 TIMELINE_MINIMAP_MIN_ITEMS,82 TIMELINE_MINIMAP_MIN_ITEMS,
82 type TimelineLatestTurn,83 type TimelineLatestTurn,
83} from "./MessagesTimeline.logic";84} from "./MessagesTimeline.logic";
85import {
86 resolveNotificationBadge,
87 type NotificationBadgePresentation,
88} from "./notificationBadge.logic";
84import { TerminalContextInlineChip } from "./TerminalContextInlineChip";89import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
85import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";90import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
86import {91import {
@@ -1015,13 +1020,39 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- · 928 unmodified lines
1015 );1020 );
1016}1021}
1017 1022
1023/**
1024 * Provenance chip above a proactive agent delivery.
1025 *
1026 * Deliberately not a status pill: it is inline, unpadded on the left, and
1027 * sits in the message's own column so the body below reads as the same
1028 * assistant message it would have been without it.
1029 */
1030function NotificationBadge({ badge }: { badge: NotificationBadgePresentation }) {
1031 return (
1032 <div
1033 className={cn(
1034 "mb-1 inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
1035 badge.muted
1036 ? "bg-muted/50 text-muted-foreground/80"
1037 : "bg-accent/60 text-accent-foreground",
1038 )}
1039 data-notification-badge
1040 >
1041 <BellIcon className="size-3 opacity-70" />
1042 <span className="min-w-0 truncate normal-case tracking-normal">{badge.label}</span>
1043 </div>
1044 );
1045}
1046
1018function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {1047function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
1019 const ctx = use(TimelineRowCtx);1048 const ctx = use(TimelineRowCtx);
1020 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");1049 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
1050 const notificationBadge = resolveNotificationBadge(row.message.notification);
1021 1051
1022 return (1052 return (
1023 <>1053 <>
1024 <div className="relative min-w-0 px-1 py-0.5">1054 <div className="relative min-w-0 px-1 py-0.5">
1055 {notificationBadge ? <NotificationBadge badge={notificationBadge} /> : null}
1025 <ChatMarkdown1056 <ChatMarkdown
1026 text={messageText}1057 text={messageText}
1027 cwd={ctx.markdownCwd}1058 cwd={ctx.markdownCwd}
apps/web/src/components/chat/notificationBadge.logic.test.ts+63−0
@@ -0,0 +1,63 @@
1import type { OrchestrationMessageNotification } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveNotificationBadge } from "./notificationBadge.logic";
5
6function makeNotification(
7 overrides: Partial<OrchestrationMessageNotification> = {},
8): OrchestrationMessageNotification {
9 return {
10 kind: "cron",
11 label: "daily-digest",
12 deliveryId: "delivery-1",
13 ...overrides,
14 } as OrchestrationMessageNotification;
15}
16
17describe("resolveNotificationBadge", () => {
18 it("renders no badge for an ordinary assistant message", () => {
19 // The overwhelmingly common case — a reply to a turn the user started.
20 expect(resolveNotificationBadge(undefined)).toBe(null);
21 });
22
23 it("joins the kind and the label for a cron delivery", () => {
24 expect(resolveNotificationBadge(makeNotification())).toEqual({
25 label: "Cron · daily-digest",
26 muted: false,
27 });
28 });
29
30 it("labels an agent-initiated send as Agent, not Message", () => {
31 expect(
32 resolveNotificationBadge(makeNotification({ kind: "message", label: "check-in" })),
33 ).toEqual({ label: "Agent · check-in", muted: false });
34 });
35
36 it("mutes lifecycle notices", () => {
37 expect(
38 resolveNotificationBadge(makeNotification({ kind: "lifecycle", label: "gateway online" })),
39 ).toEqual({ label: "Gateway · gateway online", muted: true });
40 });
41
42 it("keeps handoff and other at normal weight", () => {
43 expect(
44 resolveNotificationBadge(makeNotification({ kind: "handoff", label: "to laptop" })),
45 ).toEqual({ label: "Handoff · to laptop", muted: false });
46 expect(resolveNotificationBadge(makeNotification({ kind: "other", label: "misc" }))).toEqual({
47 label: "Notification · misc",
48 muted: false,
49 });
50 });
51
52 it("collapses to the kind alone when the label adds nothing", () => {
53 // Agent-authored free text: it may be blank after trimming, or merely
54 // repeat the kind. Either way a dangling separator would be noise.
55 expect(resolveNotificationBadge(makeNotification({ label: " " }))).toEqual({
56 label: "Cron",
57 muted: false,
58 });
59 expect(
60 resolveNotificationBadge(makeNotification({ kind: "lifecycle", label: "gateway" })),
61 ).toEqual({ label: "Gateway", muted: true });
62 });
63});
apps/web/src/components/chat/notificationBadge.logic.ts+59−0
@@ -0,0 +1,59 @@
1import type { OrchestrationMessageNotification } from "@t3tools/contracts";
2
3/**
4 * Presentation for the small header badge above a proactive agent delivery.
5 *
6 * A notification is provenance, not content: the row underneath stays an
7 * ordinary assistant message (selectable, copyable, part of the transcript)
8 * and the badge only answers "who asked for this, since I didn't?".
9 */
10export interface NotificationBadgePresentation {
11 /** Rendered text — "Cron · daily-digest", or just the kind when unlabelled. */
12 readonly label: string;
13 /**
14 * `lifecycle` notices ("gateway online") are bookkeeping the user did not
15 * ask for and will not act on, so they get the quieter treatment. Everything
16 * else is content someone scheduled or sent and reads at normal weight.
17 */
18 readonly muted: boolean;
19}
20
21/**
22 * Human name for a notification kind.
23 *
24 * `message` renders as "Agent" rather than "Message": every row in the
25 * timeline is a message, so the useful distinction is that this one was the
26 * agent's own initiative.
27 */
28const NOTIFICATION_KIND_LABELS: Record<OrchestrationMessageNotification["kind"], string> = {
29 cron: "Cron",
30 message: "Agent",
31 lifecycle: "Gateway",
32 handoff: "Handoff",
33 other: "Notification",
34};
35
36/**
37 * Badge for one message, or `null` when the message is an ordinary reply.
38 *
39 * "No notification" is the overwhelmingly common case and must never
40 * synthesize a badge — an ordinary answer with a "Notification" chip on it
41 * would be a lie about where it came from.
42 *
43 * The kind prefix is always shown even when a label repeats it, because the
44 * label is agent-authored free text and cannot be trusted to carry the kind.
45 * A label that is empty after trimming (or exactly the kind label) collapses
46 * to the kind alone rather than rendering a dangling separator.
47 */
48export function resolveNotificationBadge(
49 notification: OrchestrationMessageNotification | undefined,
50): NotificationBadgePresentation | null {
51 if (!notification) return null;
52 const kindLabel = NOTIFICATION_KIND_LABELS[notification.kind] ?? NOTIFICATION_KIND_LABELS.other;
53 const detail = notification.label.trim();
54 const label =
55 detail === "" || detail.toLowerCase() === kindLabel.toLowerCase()
56 ? kindLabel
57 : `${kindLabel} · ${detail}`;
58 return { label, muted: notification.kind === "lifecycle" };
59}
docs/project/hermes-home-channel-plan.md+165−13
@@ -23,7 +23,10 @@ Three independent gates block Hermes-initiated messages into T3: · 22 unmodified lines
23 23
241. **Plugin turn gate** — every emitter requires `_active_turns[thread_id]`, populated241. **Plugin turn gate** — every emitter requires `_active_turns[thread_id]`, populated
25 only by a T3-issued `turn.start`; `send()` fails with `"no active T3 turn"`25 only by a T3-issued `turn.start`; `send()` fails with `"no active T3 turn"`
26 (`adapter.py:207-209`, `:254-256`).26 (`adapter.py:209`, `:256`). Since the finalize-bug fixes (`3f71c601a`), turn
27 completion is keyed **exclusively** off `notify=True` metadata on `send()`
28 (`adapter.py:212-217`); `finalize` on `edit_message` is ignored outright. Any
29 proactive branch added to `send()` must respect that contract — see Part 3.
272. **Contract gate** — no plugin→T3 frame in protocol v2 opens a session or turn302. **Contract gate** — no plugin→T3 frame in protocol v2 opens a session or turn
28 (`packages/contracts/src/hermesGateway.ts:672-694`); everything is a reply carrying31 (`packages/contracts/src/hermesGateway.ts:672-694`); everything is a reply carrying
29 a T3-minted `requestId`.32 a T3-minted `requestId`.
@@ -190,19 +193,63 @@ env-only resolution path is always in sync with T3's durable designation. · 160 unmodified lines
190 193
191### Adapter delivery path194### Adapter delivery path
192 195
193`T3PlatformAdapter.send(chat_id, content, metadata)` grows a proactive branch: when196`T3PlatformAdapter.send(chat_id, content, metadata)` grows a proactive branch for the
194there is **no active turn** for the target and `chat_id` equals the home thread id,197home thread. **The gate must be provenance, not merely turn absence.** The naive rule
195emit `home.deliver` instead of failing with `"no active T3 turn"`. Kind/label198("no active turn for this thread → `home.deliver`") deadlocks against the
196classification is best-effort from what Hermes passes in `metadata` and the send199notify-completion contract when the home thread itself has a live turn: a cron or
197context (cron deliveries are identifiable; gateway startup/shutdown broadcasts match200`send_message` delivery targeting the home chat while the user is mid-conversation
198known message shapes; everything else defaults to `kind: "message"`, label "Hermes").201there would fall into the active-turn path, be streamed as that turn's assistant
199Sends to a non-home thread without an active turn keep the current explicit error —202content, and — because final cron deliveries arrive notify-stamped — **complete the
200scope creep into "message any thread unprompted" is deliberately excluded.203user's live turn with the cron output as its answer**. That is the same
201 204keyed-off-the-wrong-signal defect class COMPATIBILITY.md documents for `finalize`.
202The `/sethome` nudge suppression (`adapter.py:41-46`, `:211-214`, `:258-259`) stays:205
203once `env_enablement_fn` seeds a home channel Hermes stops emitting the nudge206The discriminator already exists: `_gateway_session_key()` (`adapter.py`, added in
207`3f71c601a`) reads `HERMES_SESSION_KEY` from Hermes' session contextvar. A genuine
208turn reply is produced inside the turn's session context, so the key resolves to this
209plugin's `build_session_key` id for that thread; cron deliveries run in their own
210`cron_*` session and broadcasts run in none, so the key is absent or names a different
211session. The branch rule in `send()` is therefore:
212
213- Active turn for `chat_id` **and** session key matches that turn's session → normal
214 turn path (`_emit_assistant_content`, `notify` completes the turn).
215- `chat_id` is the home thread and the session key does **not** match an active turn
216 there (or no turn exists) → emit `home.deliver`. Never touch `_active_turns`.
217- Non-home thread without a matching active turn → keep the current explicit
218 `"no active T3 turn"` error; "message any thread unprompted" stays out of scope.
219
220Kind/label classification is best-effort from `metadata` and the send context (cron
221deliveries are identifiable; gateway startup/shutdown broadcasts match known message
222shapes; everything else defaults to `kind: "message"`, label "Hermes").
223
224Three constraints from the turn-lifecycle fixes (`3f71c601a`, `a4000b86d`,
225COMPATIBILITY.md "Turn completion is keyed off `notify`, never `finalize`") shape
226this branch:
227
228- **Ordering inside `send()`.** The steer-control capture
229 (`_capture_steer_control_response`, `adapter.py:205`) must stay first — steer
230 acknowledgements arrive with `notify=True` and must never be misread as proactive
231 deliveries. The proactive branch slots after capture, gated by the provenance rule
232 above.
233- **`notify` does not end anything here.** In the active-turn path `notify=True`
234 triggers `_complete_turn`; in the proactive branch there is no turn to complete —
235 the metadata is consumed only as a classification hint (final cron deliveries
236 arrive notify-stamped via `_mark_notify_metadata`, `gateway/platforms/base.py:89`).
237 The branch must not touch `_active_turns` or emit any turn/item frames — only
238 `home.deliver`.
239- **Proactive edits are not supported.** Hermes' progress loop may follow a `send`
240 with `edit_message` calls against the returned `message_id` (the tool-progress
241 bubble pattern). `edit_message` outside an active turn keeps returning
242 `"no active T3 turn"` — a delivery is an atomic document, not a streaming surface.
243 In practice cron/`send_message`/lifecycle deliveries are single sends; if an
244 upstream path ever streams a home delivery, revisit with a
245 `home.deliver`-supersedes-by-`deliveryId` scheme rather than edit frames.
246
247The `/sethome` nudge suppression (`adapter.py:41-46`; checks at `:211`, `:258`)
248stays: once `env_enablement_fn` seeds a home channel Hermes stops emitting the nudge
204naturally, but the suppression covers the pre-designation window (first connect before249naturally, but the suppression covers the pre-designation window (first connect before
205any `connection.accepted` has written the env var).250any `connection.accepted` has written the env var). Note the nudge check now also
251gates on `notify` for turn completion (`adapter.py:211-214`) — the suppression tests
252in `tests/test_adapter.py` pin this and must keep passing.
206 253
207### Durable plugin-side queue254### Durable plugin-side queue
208 255
@@ -238,6 +285,88 @@ location and any metadata fields read for classification are new entries). · 29 unmodified lines
238 (server-side decider rejection is the real guard; UI just avoids offering it).285 (server-side decider rejection is the real guard; UI just avoids offering it).
239- Composer in the home thread is the normal Hermes composer — replying starts an286- Composer in the home thread is the normal Hermes composer — replying starts an
240 ordinary `session.ensure`/`turn.start` flow on the same `threadId`.287 ordinary `session.ensure`/`turn.start` flow on the same `threadId`.
288- **Pinning must survive the sidebar dedupe.** `visibleSidebarSections` now
289 de-duplicates by `projectKey` (`Sidebar.tsx:3555+`, added in `3f71c601a`'s batch)
290 and `getSidebarThreadIdsToPrewarm` dedupes thread ids. Home-thread pinning is a
291 sort inside one section's thread list, so it composes with both — but the pinned
292 ordering must be applied before the prewarm slice so Home is always warm.
293
294---
295
296## Part 5 — Bundled fix: settled-turn activity escapes the "Worked for …" fold
297
298Not home-channel machinery, but it lands in the same surfaces (Hermes activity
299ordering in the timeline) and the home thread will render notification + turn
300activity constantly, so it ships with this work.
301
302**Symptom.** After a Hermes turn settles, its activity should collapse behind the
303"Worked for …" fold above the final answer. Instead the activity splits: one cluster
304(work summary) folds correctly above, and a second cluster (the steps taken /
305"Work Log" with rows like "Hermes activity is running …") renders **below** the final
306assistant message, outside the fold. Both belong above.
307
308**What's already fixed.** `a4000b86d` reordered `_complete_turn` to close the live
309status item before the assistant message, because T3's fold only captures entries
310preceding the turn's terminal message — the status item was being stamped ~2ms after
311the answer and escaping. That fix is correct but insufficient: the symptom still
312reproduces (observed post-fix with a "Work Log — Hermes activity" section below the
313answer), which means at least one more path produces late- or mis-attributed entries.
314
315**How the fold actually works** (`MessagesTimeline.logic.ts`):
316
317- `deriveTimelineEntries` (`session-logic.ts:1340-1366`) merges messages, plans, and
318 work entries and sorts **purely by `createdAt` string compare**. Assistant messages
319 sort at their _creation_ time (early in the turn); activities re-stamp `createdAt`
320 on every update (the projector replaces by id and re-sorts,
321 `projector.ts:725-753`), so a turn's activities always sort _after_ its assistant
322 message. Position alone is therefore expected to be "activities below".
323- The fold compensates: `deriveTurnFolds` (`MessagesTimeline.logic.ts:285-404`)
324 groups entries by `turnId` and hides **every non-terminal entry of a settled turn
325 regardless of timestamp position**. So a trailing activity row that carries the
326 right `turnId` cannot escape the fold.
327- Consequently, an entry rendering below the final message **must have failed turn
328 attribution** — grouping skips entries whose `turnId` resolves null
329 (`MessagesTimeline.logic.ts:311-318`), and unattributed entries can never be hidden.
330
331**Candidate mechanisms to run down, in likelihood order:**
332
3331. **Late frames lose their turn.** Plugin tool hooks run on Hermes tool worker
334 threads; a `post_tool_call` → `item.completed` frame can arrive after
335 `turn.completed`. `ProviderRuntimeIngestion`'s turn-conflict gate
336 (`ProviderRuntimeIngestion.ts:1370-1411`) rejects events naming a non-active turn
337 — but any acceptance path that strips or nulls `turnId` (or an ingestion branch
338 that emits the activity with `toTurnId(...) ?? null`) produces exactly this
339 symptom. Check what happens to an `item.*` frame for a just-completed turn.
3402. **Command-dispatch race.** Activity appends and message finalization are separate
341 orchestration commands; wire order (status completed before assistant, per
342 `a4000b86d`) does not guarantee commit order. If the activity append commits after
343 the turn record settles, does any code path re-key or drop its `turnId`?
3443. **Fallback attribution gap.** `_turn_for_tool_hook`'s sole-active-turn fallback
345 emits nothing when it cannot resolve — but the _generic status_ item
346 (`_ensure_generic_activity`) and any frames emitted between `notify` completion
347 and socket flush should be audited for a window where `turn.turn_id` is stale or
348 the frame is built without it.
349
350The decisive experiment: reproduce, then inspect the projected thread — the escaping
351activity row's `turnId` in `projection_thread_activities`. Null confirms attribution
352loss (mechanisms 1/3); a correct `turnId` with the row still visible would falsify
353the fold analysis and point back at web-side grouping.
354
355**Fix direction.** Two layers, both worth doing:
356
357- Root cause: guarantee `turnId` attribution end-to-end for every activity a turn
358 produces, including frames that land after `turn.completed` (accept-and-attribute
359 late items for a recently-completed turn rather than dropping/nulling).
360- Defense: the fold should not depend on perfect attribution. For a settled turn,
361 any work entry timestamped between the turn's first entry and its terminal
362 message's `updatedAt` (plus a small trailing grace window) with no other turn
363 claiming it belongs to that turn's fold. This also future-proofs the home thread,
364 where notification rows and turn activity will interleave.
365
366Regression test: replay the recorded frame sequence of a real Hermes turn (the
367existing `test_adapter.py` frame-replay pattern) plus a late `item.completed`, and
368assert the derived timeline rows place no work/activity row after the terminal
369assistant message of a settled turn.
241 370
242---371---
243 372
@@ -259,6 +388,9 @@ Each step typechecks and is testable on its own: · 15 unmodified lines
2596. **Web UI** — snapshot `homeThreadId`, pinned row, notification badges, delete3886. **Web UI** — snapshot `homeThreadId`, pinned row, notification badges, delete
260 suppression.389 suppression.
2617. **Push** — `AgentAwarenessRelay` acceptance + headline mapping.3907. **Push** — `AgentAwarenessRelay` acceptance + headline mapping.
3918. **Fold-escape fix (Part 5)** — independent of 1–7; can land first. Doing it
392 before the home-channel UI keeps notification rows from inheriting the same
393 mis-ordering.
262 394
263Steps 1–3 make T3 able to receive; step 4 makes the flagship flows (cron via live395Steps 1–3 make T3 able to receive; step 4 makes the flagship flows (cron via live
264gateway, `send_message`, lifecycle, `/handoff`) work end to end; 5–7 complete it.396gateway, `send_message`, lifecycle, `/handoff`) work end to end; 5–7 complete it.
@@ -293,6 +425,14 @@ Manual checks: · 28 unmodified lines
293 generation-bumping a live gateway socket (verify with a concurrent live connection).425 generation-bumping a live gateway socket (verify with a concurrent live connection).
2949. Regression: Claude/Codex threads and non-home Hermes threads unchanged; unsolicited4269. Regression: Claude/Codex threads and non-home Hermes threads unchanged; unsolicited
295 sends to non-home threads still error.427 sends to non-home threads still error.
42810. Turn/delivery interleave in Home: while a user turn is running in the home
429 thread, fire a `deliver=t3` cron job → the delivery lands as a notification row
430 and the live turn keeps streaming and completes normally on its own `notify`
431 (this is the provenance-gate check from Part 3 — the failure mode is the cron
432 output completing the user's turn).
43311. Part 5: settle a Hermes turn that used tools → all activity, including the
434 status line and every tool row, folds behind "Worked for …"; nothing renders
435 between or below the final answer.
296 436
297Automated: `pnpm typecheck`, `pnpm test`, `pnpm lint`; plugin tests in437Automated: `pnpm typecheck`, `pnpm test`, `pnpm lint`; plugin tests in
298`integrations/hermes-t3-gateway/tests` (queue FIFO + ack purge, nudge suppression438`integrations/hermes-t3-gateway/tests` (queue FIFO + ack purge, nudge suppression
@@ -313,3 +453,15 @@ still pinned, proactive-send branch, standalone sender against a mock server); · 14 unmodified lines
313- **Lifecycle notice volume.** Frequent gateway restarts write quiet rows into Home.453- **Lifecycle notice volume.** Frequent gateway restarts write quiet rows into Home.
314 If that proves noisy, Hermes' per-platform `gateway_restart_notification: false`454 If that proves noisy, Hermes' per-platform `gateway_restart_notification: false`
315 opt-out is the escape hatch (a one-line config change, no code).455 opt-out is the escape hatch (a one-line config change, no code).
456- **The sole-active-turn tool-hook fallback misattributes cron tool activity.**
457 `_turn_for_tool_hook` (added in `3f71c601a`) falls back to "the sole active turn"
458 when no routing key resolves. The plugin's `pre/post_tool_call` hooks are
459 process-global, so a **cron job running tools** while exactly one T3 turn is
460 active resolves through that fallback and paints the cron job's tool calls into
461 the unrelated live turn. Upstream is aware of the general hazard (the cron
462 scheduler deliberately avoids the process-global env var for exactly this
463 routing risk, `cron/scheduler.py:3077`). Low-frequency today; this feature makes
464 `deliver=t3` cron jobs common, so fix alongside Part 3: before the sole-turn
465 fallback, exclude hook calls whose `session_id` is a cron run id (`cron_*`
466 prefix, `cron/scheduler.py:3017`) — their activity belongs to the eventual
467 `home.deliver`, not to any live turn.
infra/relay/src/agentActivity/AgentActivityPublisher.ts+2−0
@@ -187,6 +187,8 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { · 186 unmodified lines
187 return "Approval";187 return "Approval";
188 case "waiting_for_input":188 case "waiting_for_input":
189 return "Input";189 return "Input";
190 case "notification":
191 return "Update";
190 case "completed":192 case "completed":
191 return "Done";193 return "Done";
192 case "failed":194 case "failed":
integrations/hermes-t3-gateway/COMPATIBILITY.md+189−5
@@ -3,6 +3,11 @@ · 2 unmodified lines
3The plugin deliberately uses only the public Hermes plugin and platform-adapter3The plugin deliberately uses only the public Hermes plugin and platform-adapter
4surfaces audited at Hermes Agent upstream commit `62e07223` (v0.19.0).4surfaces audited at Hermes Agent upstream commit `62e07223` (v0.19.0).
5 5
6Scope note: this file inventories **upstream Hermes** surfaces only. T3-side
7machinery the plugin talks to over the wire — `withHermesConfig`, the broker's
8generation fencing, `getOrCreateHomeThread` — is not a Python concern and is
9documented on the T3 side; only the wire contract those produce appears here.
10
6Audited surfaces, all present at that commit:11Audited surfaces, all present at that commit:
7 12
8| Surface | Location at 62e07223 |13| Surface | Location at 62e07223 |
@@ -35,9 +40,37 @@ Audited surfaces, all present at that commit: · 26 unmodified lines
35| Active-command inline dispatch | `gateway/platforms/base.py:4926` |40| Active-command inline dispatch | `gateway/platforms/base.py:4926` |
36| User-plugin path `$HERMES_HOME/plugins/` | `hermes_cli/plugins.py:10`, `:1350` |41| User-plugin path `$HERMES_HOME/plugins/` | `hermes_cli/plugins.py:10`, `:1350` |
37 42
38This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn43Home-channel surfaces, added for protocol v3:
39recovery in `session.ready` and authoritative `content.snapshot` replacement;44
40older server/plugin pairs are rejected during the handshake.45| Surface | Location at 62e07223 |
46| ------------------------------------------ | ---------------------------------- |
47| `cron_deliver_env_var` registration flag | `gateway/platform_registry.py:143` |
48| `standalone_sender_fn` registration flag | `gateway/platform_registry.py:159` |
49| Standalone-sender invocation | `tools/send_message_tool.py:741` |
50| `_home_target_env_var` fallback convention | `gateway/run.py:1541` |
51| `_resolve_home_env_var` (plugin lookup) | `cron/scheduler.py:1025` |
52| `env_enablement_fn` `home_channel` promote | `gateway/config.py:2648` |
53| `HomeChannel` dataclass | `gateway/config.py:421` |
54| `get_home_channel` | `gateway/config.py:1022` |
55| `get_hermes_home` (queue/state base) | `hermes_constants.py:106` |
56| `get_hermes_home` re-export | `hermes_cli/config.py:686` |
57| Cron run-id shape (`cron_*`) | `cron/scheduler.py:3017`, `:3484` |
58| Cron session-var clearing | `cron/scheduler.py:3066-3091` |
59| Cron `job_id` in routed metadata | `cron/scheduler.py:1782` |
60| Cron metadata reaching `adapter.send` | `gateway/delivery.py:606` |
61| Cron wrap header (`Cronjob Response: …`) | `cron/scheduler.py:1513` |
62| Gateway online notice | `gateway/run.py:17277` |
63| Gateway restart notice | `gateway/run.py:17236` |
64| Gateway shutdown/restarting notice | `gateway/run.py:6599` |
65| `/handoff` synthetic source identity | `gateway/run.py:8854` |
66| `HERMES_SESSION_USER_ID` binding | `gateway/run.py:17372` |
67| Session-context lifetime around a turn | `gateway/run.py:12972` → `:14626` |
68
69This inventory describes gateway wire protocol v3. Protocol v2 added active-turn
70recovery in `session.ready` and authoritative `content.snapshot` replacement; v3
71adds `role` on `connection.hello`, `homeThreadId` on `connection.accepted`, and
72the `home.deliver` / `home.deliver.ack` pair. Older server/plugin pairs are
73rejected during the handshake — the version policy stays fail-closed.
41 74
42## Mapped in the initial scope75## Mapped in the initial scope
43 76
@@ -55,6 +88,7 @@ older server/plugin pairs are rejected during the handshake. · 11 unmodified lines
55| `load_config_readonly()["agent"]["reasoning_effort"]` | Optional `reasoningEffort` on `describe.response` |88| `load_config_readonly()["agent"]["reasoning_effort"]` | Optional `reasoningEffort` on `describe.response` |
56| `skills_list()` metadata | `skills` on `describe.response` |89| `skills_list()` metadata | `skills` on `describe.response` |
57| `skill_view(name, preprocess=False)` | `markdown` on `skill.body.response` |90| `skill_view(name, preprocess=False)` | `markdown` on `skill.body.response` |
91| Cron `deliver=t3`, `send_message t3`, lifecycle | `home.deliver` / `home.deliver.ack` |
58 92
59## Known limitations93## Known limitations
60 94
@@ -195,7 +229,18 @@ older server/plugin pairs are rejected during the handshake. · 134 unmodified lines
195 and the wire type is non-empty. That takes the ordinary correlated229 and the wire type is non-empty. That takes the ordinary correlated
196 `protocol.error` path.230 `protocol.error` path.
197- Attachments are not accepted. They are the first planned post-stability231- Attachments are not accepted. They are the first planned post-stability
198 feature; the capability is reserved and fixed to `false` in protocol v2.232 feature; the capability is reserved and fixed to `false` in protocol v3. The
233 `standalone_sender_fn` signature accepts `media_files` / `force_document` for
234 parity with `gateway/platform_registry.py:150-158`, but the v3 wire contract
235 has no attachment framing, so both are ignored (logged, not silently
236 dropped).
237- **Kind/label classification is heuristic.** `adapter.send()` carries no
238 structured "this is a cron delivery" marker on every path, so the plugin
239 reads what does exist (see "Home-channel delivery" below). A
240 misclassification costs a wrong badge — and, for `lifecycle`, a delivery that
241 raises its hand when it should have landed quietly — never a lost delivery.
242 If upstream ever exposes delivery provenance in metadata, adopt it and
243 replace the heuristics here.
199 244
200## Turn completion is keyed off `notify`, never `finalize`245## Turn completion is keyed off `notify`, never `finalize`
201 246
@@ -221,7 +266,7 @@ boundary, and `_complete_turn` is reached from nowhere else on the output path. · 19 unmodified lines
221### The signal that does NOT end a turn: `finalize=True` on `edit_message`266### The signal that does NOT end a turn: `finalize=True` on `edit_message`
222 267
223`finalize` reads like "last edit of the response", and the base class documents268`finalize` reads like "last edit of the response", and the base class documents
224it that way (`gateway/platforms/base.py:3178-3186`). It is **not** a turn269it that way (`gateway/platforms/base.py:3176-3183`). It is **not** a turn
225boundary. Two upstream paths set it mid-turn:270boundary. Two upstream paths set it mid-turn:
226 271
2271. **The tool-progress loop.** When an adapter declares2721. **The tool-progress loop.** When an adapter declares
@@ -266,6 +311,145 @@ would hang in the running state with the full answer streamed but no · 38 unmodified lines
266`turn.completed`. That is the opposite failure mode from the original bug and311`turn.completed`. That is the opposite failure mode from the original bug and
267would show up as spinners that never resolve, not truncated answers.312would show up as spinners that never resolve, not truncated answers.
268 313
314## Home-channel delivery: the gate is provenance, not turn absence
315
316Hermes-initiated output — cron results, `send_message` with a bare `t3` target,
317gateway lifecycle notices, `/handoff t3` — has no T3-issued turn to stream into.
318It is emitted as `home.deliver` against the instance's durable home thread.
319
320### The deadlock this design exists to avoid
321
322The naive rule — "no active turn for this thread → deliver" — is wrong, and
323wrong in the same keyed-off-the-wrong-signal way as the `finalize` bug above.
324When the home thread itself has a live user turn, a cron delivery targeting it
325would fall into the active-turn path, stream as that turn's assistant content,
326and — because final cron deliveries arrive notify-stamped via
327`_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete the user's
328live turn with the cron output as its answer**.
329
330The discriminator is `HERMES_SESSION_KEY`. The gateway binds it onto the turn's
331context for the whole handler (`gateway/run.py:12972` → `:14626`, read via
332`get_session_env`), and every send a turn produces — streamed or final — happens
333inside that scope, so a genuine turn reply resolves to this plugin's
334`build_session_key` id for its thread. Cron runs under its own
335`cron_<job>_<timestamp>` session with the gateway routing keys explicitly
336cleared (`cron/scheduler.py:3066-3091`), and lifecycle broadcasts run in no
337session at all.
338
339`_is_proactive_delivery` therefore decides in this order:
340
3411. Session key matches an active turn on this thread → **turn content**, never
342 a delivery. Checked first, so a turn reply can never be rerouted.
3432. Not the home thread → never a delivery. "Message any thread unprompted"
344 stays out of scope and the existing `"no active T3 turn"` error is returned
345 verbatim.
3463. Home thread, no active turn → delivery. There is nothing it could belong to.
3474. Home thread **with** a non-matching active turn → delivery only when
348 provenance is positively established. This is the conservative half: an
349 unattributable send in that window stays with the turn (at worst misplaced
350 inside the same thread) rather than being torn out of a turn it may belong
351 to. So an unclassifiable send can never steal a live answer, and a
352 recognisable cron/lifecycle/handoff delivery never completes one.
353
354Ordering inside `send()` is load-bearing: `_capture_steer_control_response`
355stays first, because steer acknowledgements arrive with `notify=True` and must
356never be read as deliveries.
357
358`edit_message` has **no** proactive branch and keeps returning `"no active T3
359turn"` outside a turn. A delivery is an atomic document, not a streaming
360surface. If an upstream path ever streams a home delivery, revisit with a
361`home.deliver`-supersedes-by-`deliveryId` scheme rather than edit frames.
362
363### What classification keys off
364
365All best-effort, in precedence order, all degrading to
366`("message", "Hermes", uncertain)`:
367
368- `metadata["job_id"]` — the only structured signal. The cron scheduler stamps
369 it into the routed metadata (`cron/scheduler.py:1782`) and
370 `DeliveryRouter._deliver_to_platform` passes the dict through to
371 `adapter.send` unchanged (`gateway/delivery.py:606`).
372- The cron wrap header `Cronjob Response: <name>` (`cron/scheduler.py:1513`),
373 present whenever `cron.wrap_response` is on (the default), which also
374 supplies the human job name for the badge.
375- Lifecycle literals: `gateway/run.py:17277`, `:17236`, `:6599`. These are
376 inline f-strings upstream, not exported constants, so they carry the same
377 drift risk as the `/sethome` notice and the `⏩ Steer queued` prefix.
378- `HERMES_SESSION_USER_ID == "system:handoff"`, the synthetic source identity
379 `/handoff` dispatches under (`gateway/run.py:8854`, bound at `:17372`).
380
381### Registration contracts
382
383- **`cron_deliver_env_var="T3_HOME_CHANNEL"`.** The name is not free-form.
384 `_home_target_env_var` (`gateway/run.py:1541`) consults built-ins, then the
385 plugin registry via `_resolve_home_env_var` (`cron/scheduler.py:1025`), then
386 falls back to `f"{PLATFORM.upper()}_HOME_CHANNEL"` — exactly this string for
387 platform `t3`. Matching the fallback means `send_message`'s error hints and
388 cron's env-only resolution agree with what the plugin writes, with no
389 upstream override-table entry. Without the flag, `deliver=t3` is silently
390 dropped by cron.
391- **`env_enablement_fn` seeds `home_channel`.** That key is magic: core pops it
392 out of the returned dict and promotes it to a real `HomeChannel` dataclass
393 (`gateway/config.py:2648-2660`, reading only `chat_id` / `name` /
394 `thread_id`). The promotion is what makes `get_home_channel("t3")`
395 (`gateway/config.py:1022`) resolve, which is what makes `send_message`,
396 lifecycle broadcasts, and `/handoff` work — core hardcodes env promotion only
397 for built-ins. T3 threads are the addressing unit, so `chat_id` **is** the
398 thread id and `thread_id` stays unset.
399- **`standalone_sender_fn`.** Out-of-process cron has no live adapter
400 (`tools/send_message_tool.py:741`). The plugin dials T3 itself over a
401 short-lived socket announcing `role: "delivery"`. That role is load-bearing:
402 T3's broker registers a `gateway` connection under generation fencing and
403 displaces its predecessor, so a cron dial-in announcing the default role
404 would kick the live gateway socket off its own instance mid-turn.
405
406### Designation is a synced cache, not local state
407
408`T3_HOME_CHANNEL` is written by the plugin, never by the user. T3's settings
409blob is authoritative and republishes `homeThreadId` on every
410`connection.accepted`; the plugin compares and persists via `save_env_value`
411(the same profile-aware helper enrollment uses) and mirrors into `os.environ`
412so a running gateway needs no restart. A hand-edited value is overwritten on
413the next reconnect — documented in the README. A read-only or managed `.env`
414degrades to the in-process mirror only: routing works for the life of the
415process and re-reconciles on the next connect.
416
417### Queue and state location
418
419The plugin previously persisted nothing to disk. It now keeps one JSONL outbox
420at `<hermes home>/gateway/t3_home_delivery_queue.jsonl`, using
421`get_hermes_home()` (`hermes_constants.py:106`, re-exported at
422`hermes_cli/config.py:686`) as the base — the same accessor and the same
423`gateway/` subdirectory Hermes' own Discord adapter uses for per-profile
424adapter state (`plugins/platforms/discord/adapter.py:52`, `:272`, `:1694`).
425Resolving through that accessor rather than `~/.hermes` makes the queue
426profile-scoped: a second profile cannot replay another profile's deliveries
427into its own home thread.
428
429Correctness rests on one rule: an entry is removed **only** on its
430`home.deliver.ack`. Everything else — a socket that dropped mid-send, a server
431that died before writing, a plugin restart — leaves the entry to be replayed,
432which is safe because T3 dedupes on `deliveryId`. Acking before the durable
433write on the server side would break this. The queue is capped at 300 entries
434and drops oldest-first with a logged warning; one flush replays at most 50
435entries so a reconnect does not stall live traffic.
436
437### Cron tool-hook misattribution
438
439`_turn_for_tool_hook`'s sole-active-turn fallback is now skipped for cron runs.
440The hooks are process-global, so a cron job running tools while exactly one T3
441turn happens to be live would resolve through that fallback and paint the cron
442job's tool calls into an unrelated live conversation. Cron runs are identifiable
443by the `cron_<job>_<timestamp>` session id the scheduler mints
444(`cron/scheduler.py:3017`, passed to the agent at `:3484`) — the exact value the
445hooks receive. Upstream treats the same routing hazard as real, clearing the
446process-global session env vars for it (`cron/scheduler.py:3066-3091`). A cron
447job's activity belongs to the eventual `home.deliver`, never to a live turn.
448
449Prefix matching carries the usual drift risk: if upstream renames the shape,
450this degrades to the previous behaviour (cron tool rows may again be
451misattributed to a sole live turn) rather than breaking anything.
452
269## Tool-progress chrome: the `format_tool_event` override is not the defence453## Tool-progress chrome: the `format_tool_event` override is not the defence
270 454
271The plugin overrides `format_tool_event` to return `None`455The plugin overrides `format_tool_event` to return `None`
integrations/hermes-t3-gateway/README.md+36−4
@@ -4,7 +4,7 @@ Experimental Hermes platform plugin for connecting one already-running Hermes · 3 unmodified lines
4process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes4process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes
5does not need to listen on a public port.5does not need to listen on a public port.
6 6
7The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated7The gateway wire protocol is v3. The T3 server and Hermes plugin must be updated
8together; mismatched versions fail the connection handshake closed.8together; mismatched versions fail the connection handshake closed.
9 9
10Each T3 thread maps deterministically to one Hermes gateway session. A new T310Each T3 thread maps deterministically to one Hermes gateway session. A new T3
@@ -54,6 +54,36 @@ The handshake also reports Hermes' configured default model so T3 can show a · 43 unmodified lines
54truthful label in its picker. It is read-only — Hermes owns model selection —54truthful label in its picker. It is read-only — Hermes owns model selection —
55and is omitted entirely if it cannot be read.55and is omitted entirely if it cannot be read.
56 56
57## The Home thread
58
59Every enrolled instance gets one **Home** thread in T3, created automatically —
60there is nothing to set up and nothing to choose. It receives all of Hermes'
61proactive output: cron results (`deliver=t3`), the agent's `send_message` tool
62with a bare `t3` target, gateway online/shutdown notices, and `/handoff t3`.
63Replying in it runs an ordinary Hermes turn in the same thread.
64
65**T3 owns the designation, and `T3_HOME_CHANNEL` is a synced cache of it.** The
66plugin writes that variable itself: T3 republishes the home thread id on every
67successful handshake, and the plugin compares and persists it with Hermes'
68profile-aware `save_env_value` helper. A hand-edited `T3_HOME_CHANNEL` will
69therefore be **overwritten on the next reconnect** — to move the Home thread,
70change it in T3, not in `.env`. (`/sethome` is likewise inert for this platform:
71the designation is fixed, so Hermes' "set a home channel" nudge is suppressed.)
72
73Deliveries are durable in both directions. Each one is written to a small JSONL
74queue at `<hermes home>/gateway/t3_home_delivery_queue.jsonl` before it is sent
75and removed only once T3 acknowledges it, so nothing is lost if either side
76restarts mid-flight; queued deliveries flush on the next connect, and T3
77deduplicates so a replay is harmless. The queue is capped and drops oldest-first
78with a logged warning rather than growing without bound.
79
80Cron works whether or not the gateway is co-resident. When `hermes cron` runs in
81its own process there is no live adapter, so the plugin dials T3 over a
82short-lived delivery connection — authenticated the same way, but never
83registered as the instance's primary connection, so it cannot disturb a running
84`hermes gateway`. If T3 is unreachable the delivery is queued and the cron job
85still reports success.
86
57## Initial scope87## Initial scope
58 88
59- Text input and live assistant streaming89- Text input and live assistant streaming
@@ -66,9 +96,11 @@ and is omitted entirely if it cannot be read. · 6 unmodified lines
66- Reconnect with bounded backoff96- Reconnect with bounded backoff
67- Version-incompatible and revoked credentials fail closed97- Version-incompatible and revoked credentials fail closed
68 98
69T3 threads are session-only in the initial scope. The adapter suppresses Hermes' exact99- Proactive delivery into the Home thread: cron, `send_message`, lifecycle
70first-chat `/sethome` notice without assigning a home channel or redirecting100 notices, `/handoff` — with a durable queue and out-of-process cron support
71cron and cross-platform delivery.101
102Non-home T3 threads remain session-only: Hermes cannot message them unprompted,
103and an unsolicited send to one still fails with `no active T3 turn`.
72 104
73Attachments intentionally advertise `false`. Adding bounded image/file input is105Attachments intentionally advertise `false`. Adding bounded image/file input is
74the first post-stability feature and should not reuse arbitrary raw payloads.106the first post-stability feature and should not reuse arbitrary raw payloads.
integrations/hermes-t3-gateway/__init__.py+18−0
@@ -10,6 +10,8 @@ from .adapter import ( · 9 unmodified lines
10 validate_config,10 validate_config,
11)11)
12from .cli import register_cli, t3_command12from .cli import register_cli, t3_command
13from .home import HOME_CHANNEL_ENV, standalone_send
14from .home import HOME_CHANNEL_ENV, standalone_send
13 15
14 16
15def _pre_tool_call(17def _pre_tool_call(
@@ -53,6 +55,22 @@ def register(ctx) -> None: · 37 unmodified lines
53 "HERMES_T3_GATEWAY_CREDENTIAL",55 "HERMES_T3_GATEWAY_CREDENTIAL",
54 ],56 ],
55 env_enablement_fn=env_enablement,57 env_enablement_fn=env_enablement,
58 # Cron home-channel delivery. The name is not free-form: Hermes
59 # resolves a platform's cron home target through `_home_target_env_var`
60 # (`gateway/run.py:1541`), which falls back to
61 # f"{PLATFORM.upper()}_HOME_CHANNEL" for any platform without a
62 # built-in override entry — exactly this string for platform `t3`. So
63 # `send_message`'s error hints, `/sethome` messaging, and cron's
64 # env-only resolution all agree with the value the plugin writes, with
65 # no upstream override table entry. Without this, `deliver=t3` is
66 # silently dropped by cron.
67 cron_deliver_env_var=HOME_CHANNEL_ENV,
68 # Out-of-process cron delivery: when `hermes cron` runs in a separate
69 # process from `hermes gateway` there is no live adapter, and without
70 # this hook `deliver=t3` fails with "No live adapter for platform".
71 # Dials T3 over a short-lived `role: "delivery"` socket so it cannot
72 # displace the live gateway connection.
73 standalone_sender_fn=standalone_send,
56 max_message_length=120_000,74 max_message_length=120_000,
57 emoji="🔺",75 emoji="🔺",
58 pii_safe=True,76 pii_safe=True,
integrations/hermes-t3-gateway/adapter.py+242−4
@@ -23,6 +23,15 @@ from gateway.session import build_session_key · 22 unmodified lines
23 23
24from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV24from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV
25from .connection import T3GatewayConnection, dependency_available25from .connection import T3GatewayConnection, dependency_available
26from .home import (
27 HOME_CHANNEL_ENV,
28 MAX_FLUSH_PER_CONNECT,
29 HomeDeliveryQueue,
30 build_delivery,
31 classify_delivery,
32 home_thread_id,
33 save_home_thread_id,
34)
26from .protocol import (35from .protocol import (
27 canonical_tool_data,36 canonical_tool_data,
28 canonical_tool_item_type,37 canonical_tool_item_type,
@@ -139,6 +148,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 110 unmodified lines
139 self._active_turns: dict[str, _TurnState] = {}148 self._active_turns: dict[str, _TurnState] = {}
140 self._approval_requests: dict[str, tuple[str, str]] = {}149 self._approval_requests: dict[str, tuple[str, str]] = {}
141 self._user_input_requests: dict[str, tuple[str, str]] = {}150 self._user_input_requests: dict[str, tuple[str, str]] = {}
151 self._home_queue = HomeDeliveryQueue()
142 # Strong references to fire-and-forget tasks. asyncio only holds a weak152 # Strong references to fire-and-forget tasks. asyncio only holds a weak
143 # reference to a running task, so without this the GC may collect one153 # reference to a running task, so without this the GC may collect one
144 # mid-flight and its exception surfaces as a bare warning.154 # mid-flight and its exception surfaces as a bare warning.
@@ -172,6 +182,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 27 unmodified lines
172 hermes_version=_hermes_version(),182 hermes_version=_hermes_version(),
173 on_message=self._handle_server_frame,183 on_message=self._handle_server_frame,
174 on_state=self._handle_connection_state,184 on_state=self._handle_connection_state,
185 on_accepted=self._handle_connection_accepted,
175 )186 )
176 try:187 try:
177 connected = await self._connection.connect()188 connected = await self._connection.connect()
@@ -204,7 +215,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 26 unmodified lines
204 captured = self._capture_steer_control_response(chat_id, content, reply_to)215 captured = self._capture_steer_control_response(chat_id, content, reply_to)
205 if captured is not None:216 if captured is not None:
206 return captured217 return captured
207 turn = self._active_turns.get(str(chat_id))218 thread_id = str(chat_id)
219 turn = self._active_turns.get(thread_id)
220 if self._is_proactive_delivery(thread_id, turn, content, metadata):
221 return await self._deliver_to_home(thread_id, content, metadata)
208 if turn is None:222 if turn is None:
209 return SendResult(success=False, error="no active T3 turn")223 return SendResult(success=False, error="no active T3 turn")
210 try:224 try:
@@ -265,6 +279,170 @@ class T3PlatformAdapter(BasePlatformAdapter): · 54 unmodified lines
265 async def get_chat_info(self, chat_id: str) -> dict[str, Any]:279 async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
266 return {"name": f"T3 thread {chat_id}", "type": "dm"}280 return {"name": f"T3 thread {chat_id}", "type": "dm"}
267 281
282 # ── proactive home delivery ────────────────────────────────────────
283
284 def _is_proactive_delivery(
285 self,
286 thread_id: str,
287 turn: _TurnState | None,
288 content: str,
289 metadata: dict[str, Any] | None,
290 ) -> bool:
291 """Decide whether this send is Hermes-initiated home delivery.
292
293 **The gate is provenance, not turn absence.** The naive rule ("no
294 active turn for this thread → deliver") deadlocks against the
295 notify-completion contract the moment the home thread has a live turn:
296 a cron result or `send_message` targeting the home chat mid-conversation
297 would take the active-turn path, stream as that turn's assistant
298 content, and — because final cron deliveries arrive notify-stamped via
299 `_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete
300 the user's live turn with the cron output as its answer.** That is the
301 same keyed-off-the-wrong-signal defect class as the `finalize` bug.
302
303 The discriminator is `_gateway_session_key()`: the gateway binds
304 `HERMES_SESSION_KEY` onto the turn's context for the whole handler
305 (`gateway/run.py:12972` → `:14626`), and every send a turn produces —
306 streamed or final — happens inside that scope. A genuine turn reply
307 therefore resolves to this plugin's `build_session_key` id for its
308 thread. Cron runs in its own `cron_*` session with the gateway keys
309 explicitly cleared (`cron/scheduler.py:3066-3091`), and lifecycle
310 broadcasts run in no session at all, so neither resolves to the live
311 turn's key.
312
313 The rules, in order:
314
315 * A send whose session key matches an active turn on this thread is
316 that turn's own output. Never a delivery — checked first so a turn
317 reply can never be rerouted.
318 * A send to a non-home thread is never a delivery: "message any thread
319 unprompted" is deliberately out of scope, and the existing
320 `"no active T3 turn"` error stays verbatim for it.
321 * On the home thread with no active turn, any send is a delivery.
322 There is nothing it could belong to.
323 * On the home thread **with** an active turn whose session key does not
324 match, provenance must be positively established (`classify_delivery`
325 returning certain) before the send bypasses the turn. This is the
326 conservative half of the gate: an unattributable send in that window
327 falls through to the turn path — a possible misplacement inside the
328 same thread — rather than being torn out of a turn it may belong to.
329 An unclassifiable send can therefore never steal a live answer, and a
330 recognisable cron/lifecycle/handoff delivery never completes one.
331 """
332 if turn is not None and self._gateway_session_key() == turn.session_id:
333 return False
334 home = home_thread_id()
335 if not home or thread_id != home:
336 return False
337 if turn is None:
338 return True
339 _kind, _label, certain = classify_delivery(
340 content,
341 metadata,
342 session_user_id=self._session_user_id(),
343 )
344 return certain
345
346 async def _deliver_to_home(
347 self,
348 thread_id: str,
349 content: str,
350 metadata: dict[str, Any] | None,
351 ) -> SendResult:
352 """Emit one `home.deliver`, queueing it until T3 acknowledges it.
353
354 Deliberately touches none of the turn machinery. `_active_turns` is not
355 read or written, no turn/item frame is emitted, and `notify` — which
356 arrives True on every final cron delivery — is consumed only as a
357 classification hint. A delivery landing while the user has a live turn
358 in this same thread must leave that turn running.
359 """
360 kind, label, _certain = classify_delivery(
361 content,
362 metadata,
363 session_user_id=self._session_user_id(),
364 )
365 delivery = build_delivery(
366 thread_id=thread_id,
367 text=str(content or ""),
368 kind=kind,
369 label=label,
370 )
371 delivery_id_value = str(delivery["deliveryId"])
372 # Persist BEFORE sending. The queue is the durability guarantee: if the
373 # socket dies between here and the ack, the entry survives to be
374 # replayed on the next connect, and T3's `deliveryId` dedupe makes the
375 # replay harmless.
376 self._home_queue.append(delivery)
377 try:
378 await self._send_frame(delivery)
379 except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
380 logger.warning(
381 "T3 home delivery %s could not be sent (%s); it is queued for "
382 "the next connect",
383 delivery_id_value,
384 exc,
385 )
386 # Reported successful either way: the delivery is durably queued, and
387 # failing the send would make a cron job report an error for output
388 # that will in fact arrive.
389 return SendResult(success=True, message_id=delivery_id_value)
390
391 async def _handle_connection_accepted(self, accepted: dict[str, Any]) -> None:
392 """Reconcile the home designation, then flush the delivery queue.
393
394 T3's settings blob is the authoritative designation and it republishes
395 it on every successful handshake, so the plugin's `T3_HOME_CHANNEL` is
396 a synced cache: a differing local value — including a hand-edited one —
397 is overwritten. Reconciling on every accept bounds drift to a single
398 reconnect.
399 """
400 thread_id = str(accepted.get("homeThreadId") or "").strip()
401 if thread_id and thread_id != home_thread_id():
402 logger.info("T3 designated home thread %s", thread_id)
403 save_home_thread_id(thread_id)
404 elif thread_id:
405 save_home_thread_id(thread_id)
406 await self._flush_home_queue()
407
408 async def _flush_home_queue(self) -> None:
409 """Replay unacknowledged deliveries oldest-first.
410
411 Entries are NOT removed here — only a `home.deliver.ack` purges one.
412 Re-sending an entry T3 already durably wrote is harmless (it dedupes on
413 `deliveryId`); dropping one it never wrote is not.
414 """
415 pending = self._home_queue.entries()
416 if not pending:
417 return
418 logger.info("Flushing %d queued T3 home deliver(y|ies)", len(pending))
419 for entry in pending[:MAX_FLUSH_PER_CONNECT]:
420 try:
421 await self._send_frame(entry)
422 except Exception as exc: # noqa: BLE001 - the rest rides the next connect
423 logger.warning("T3 home delivery flush stopped: %s", exc)
424 return
425
426 async def _acknowledge_home_delivery(self, message: dict[str, Any]) -> None:
427 """Purge a delivery T3 has durably written."""
428 delivery_id_value = str(message.get("deliveryId") or "").strip()
429 if not delivery_id_value:
430 raise ValueError("home.deliver.ack requires a deliveryId")
431 self._home_queue.purge(delivery_id_value)
432
433 @staticmethod
434 def _session_user_id() -> str:
435 """Read the bound session's user id, for `/handoff` classification.
436
437 Returns `""` on any failure, exactly like `_gateway_session_key`.
438 """
439 try:
440 from gateway.session_context import get_session_env
441
442 return str(get_session_env("HERMES_SESSION_USER_ID", "") or "")
443 except Exception: # noqa: BLE001 - classification must never raise
444 return ""
445
268 def format_tool_event(446 def format_tool_event(
269 self, event: Any, *, mode: str = "all", preview_max_len: int = 40447 self, event: Any, *, mode: str = "all", preview_max_len: int = 40
270 ) -> str | None:448 ) -> str | None:
@@ -410,6 +588,8 @@ class T3PlatformAdapter(BasePlatformAdapter): · 139 unmodified lines
410 await self._describe(message)588 await self._describe(message)
411 elif frame_type == "skill.body.request":589 elif frame_type == "skill.body.request":
412 await self._send_skill_body(message)590 await self._send_skill_body(message)
591 elif frame_type == "home.deliver.ack":
592 await self._acknowledge_home_delivery(message)
413 except ValueError as exc:593 except ValueError as exc:
414 await self._send_frame(594 await self._send_frame(
415 protocol_error(595 protocol_error(
@@ -965,19 +1145,47 @@ class T3PlatformAdapter(BasePlatformAdapter): · 549 unmodified lines
965 2. `HERMES_SESSION_KEY` from the Hermes session context.1145 2. `HERMES_SESSION_KEY` from the Hermes session context.
966 3. The sole active turn, when exactly one exists — a single-threaded1146 3. The sole active turn, when exactly one exists — a single-threaded
967 Hermes process has no ambiguity to resolve, and dropping the1147 Hermes process has no ambiguity to resolve, and dropping the
968 activity would be strictly worse.1148 activity would be strictly worse. **Cron runs are excluded from
1149 this step** (see below).
969 Anything unresolved returns `None` and the item is simply not emitted;1150 Anything unresolved returns `None` and the item is simply not emitted;
970 tool activity is decorative, so this must never raise or misroute.1151 tool activity is decorative, so this must never raise or misroute.
1152
1153 The cron exclusion: these hooks are process-global, so a cron job
1154 running tools while exactly one T3 turn happens to be live would
1155 resolve through the sole-turn fallback and paint the cron job's tool
1156 calls into an unrelated live conversation. Cron runs are identifiable —
1157 the scheduler builds its agent with
1158 `session_id=f"cron_{job_id}_{timestamp}"` (`cron/scheduler.py:3017`,
1159 passed at `:3484`), which is exactly the value these hooks receive as
1160 `session_id`. Upstream treats the same routing hazard as real: the
1161 scheduler deliberately clears the process-global session env vars for
1162 it (`cron/scheduler.py:3066-3091`). A cron job's activity belongs to
1163 the eventual `home.deliver`, never to a live turn, so it is dropped
1164 rather than guessed at.
971 """1165 """
972 thread_id = self._thread_by_session.get(str(session_id))1166 thread_id = self._thread_by_session.get(str(session_id))
973 if thread_id is None:1167 if thread_id is None:
974 thread_id = self._thread_by_session.get(self._gateway_session_key())1168 thread_id = self._thread_by_session.get(self._gateway_session_key())
975 if thread_id is not None:1169 if thread_id is not None:
976 return self._active_turns.get(thread_id)1170 return self._active_turns.get(thread_id)
1171 if self._is_cron_session(session_id):
1172 return None
977 if len(self._active_turns) == 1:1173 if len(self._active_turns) == 1:
978 return next(iter(self._active_turns.values()))1174 return next(iter(self._active_turns.values()))
979 return None1175 return None
980 1176
1177 @staticmethod
1178 def _is_cron_session(session_id: str) -> bool:
1179 """True when this hook call belongs to a cron run, not a gateway turn.
1180
1181 Keyed on the `cron_` prefix the scheduler mints at
1182 `cron/scheduler.py:3017`. Matching a prefix rather than an exported
1183 constant carries the usual drift risk: if upstream renames the shape,
1184 this degrades to today's behaviour (cron tool rows may again be
1185 misattributed to a sole live turn) rather than breaking anything.
1186 """
1187 return str(session_id or "").startswith("cron_")
1188
981 @staticmethod1189 @staticmethod
982 def _gateway_session_key() -> str:1190 def _gateway_session_key() -> str:
983 """Read the turn's gateway routing key from Hermes' session context.1191 """Read the turn's gateway routing key from Hermes' session context.
@@ -1181,15 +1389,45 @@ def validate_config(config: PlatformConfig) -> bool: · 197 unmodified lines
1181 )1389 )
1182 1390
1183 1391
1184def env_enablement() -> dict[str, str] | None:1392def env_enablement() -> dict[str, Any] | None:
1393 """Seed `PlatformConfig.extra` from the environment at config-load time.
1394
1395 Called by the platform registry's env-enablement hook before the adapter is
1396 constructed, so `gateway status` and `get_connected_platforms()` reflect an
1397 env-only enrollment without instantiating a connection.
1398
1399 `home_channel` is a **magic key**, not an ordinary extra: core pops it out
1400 of the returned dict and promotes it to a real `HomeChannel` dataclass on
1401 the `PlatformConfig` (`gateway/config.py:2648-2660`, reading only
1402 `chat_id` / `name` / `thread_id`). That promotion is what makes
1403 `get_home_channel("t3")` resolve, which is in turn what makes
1404 `send_message` with a bare `t3` target, the gateway's lifecycle broadcasts,
1405 and `/handoff t3` work at all — core hardcodes env promotion only for
1406 built-in platforms, so a plugin must supply it here. Pattern copied from
1407 IRC (`plugins/platforms/irc/adapter.py:653-701`).
1408
1409 The thread id comes from `T3_HOME_CHANNEL`, which T3 owns: the plugin
1410 rewrites it from `homeThreadId` on every `connection.accepted`. Before the
1411 first accept there is nothing to seed and the key is simply absent — Hermes
1412 then behaves exactly as it did pre-home-channel, which is why the
1413 `/sethome` nudge suppression is still needed for that window.
1414 """
1185 url = os.environ.get(URL_ENV, "").strip()1415 url = os.environ.get(URL_ENV, "").strip()
1186 instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()1416 instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()
1187 credential = os.environ.get(CREDENTIAL_ENV, "").strip()1417 credential = os.environ.get(CREDENTIAL_ENV, "").strip()
1188 if not (url and instance_id and credential):1418 if not (url and instance_id and credential):
1189 return None1419 return None
1190 return {1420 seed: dict[str, Any] = {
1191 "url": url,1421 "url": url,
1192 "instance_id": instance_id,1422 "instance_id": instance_id,
1193 "credential": credential,1423 "credential": credential,
1194 "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes",1424 "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes",
1195 }1425 }
1426 home = os.environ.get(HOME_CHANNEL_ENV, "").strip()
1427 if home:
1428 # T3 threads are the addressing unit end to end: `chat_id` IS the
1429 # thread id, and the separate `thread_id` field stays unset. Setting
1430 # both would make Hermes route `chat_id` + `thread_id` metadata at a
1431 # platform whose `send(chat_id, ...)` already resolves the thread.
1432 seed["home_channel"] = {"chat_id": home, "name": "Home"}
1433 return seed
integrations/hermes-t3-gateway/connection.py+39−1
@@ -21,6 +21,8 @@ except ImportError: # pragma: no cover - Hermes currently installs websockets · 20 unmodified lines
21 21
22MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]22MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]
23StateHandler = Callable[[bool, str | None], Awaitable[None] | None]23StateHandler = Callable[[bool, str | None], Awaitable[None] | None]
24AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]]
25AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]]
24 26
25 27
26class ConnectionRejected(RuntimeError):28class ConnectionRejected(RuntimeError):
@@ -72,10 +74,12 @@ async def authenticate_socket( · 45 unmodified lines
72 authentication: dict[str, str],74 authentication: dict[str, str],
73 hermes_version: str,75 hermes_version: str,
74 timeout: float = 20,76 timeout: float = 20,
77 role: str = "gateway",
75) -> dict[str, Any]:78) -> dict[str, Any]:
76 hello = connection_hello(79 hello = connection_hello(
77 hermes_version=hermes_version,80 hermes_version=hermes_version,
78 authentication=authentication,81 authentication=authentication,
82 role=role,
79 )83 )
80 await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False))84 await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False))
81 85
@@ -165,6 +169,7 @@ class T3GatewayConnection: · 83 unmodified lines
165 hermes_version: str,169 hermes_version: str,
166 on_message: MessageHandler,170 on_message: MessageHandler,
167 on_state: StateHandler | None = None,171 on_state: StateHandler | None = None,
172 on_accepted: AcceptedHandler | None = None,
168 ):173 ):
169 self.url = websocket_url(url)174 self.url = websocket_url(url)
170 self.instance_id = instance_id175 self.instance_id = instance_id
@@ -172,6 +177,7 @@ class T3GatewayConnection: · 1 unmodified line
172 self.hermes_version = hermes_version177 self.hermes_version = hermes_version
173 self._on_message = on_message178 self._on_message = on_message
174 self._on_state = on_state179 self._on_state = on_state
180 self._on_accepted = on_accepted
175 self._socket: Any = None181 self._socket: Any = None
176 self._supervisor: asyncio.Task[None] | None = None182 self._supervisor: asyncio.Task[None] | None = None
177 self._send_lock = asyncio.Lock()183 self._send_lock = asyncio.Lock()
@@ -266,7 +272,7 @@ class T3GatewayConnection: · 88 unmodified lines
266 try:272 try:
267 socket = await _open_socket(self.url)273 socket = await _open_socket(self.url)
268 self._socket = socket274 self._socket = socket
269 await authenticate_socket(275 accepted = await authenticate_socket(
270 socket,276 socket,
271 authentication={277 authentication={
272 "type": "instance-credential",278 "type": "instance-credential",
@@ -278,6 +284,10 @@ class T3GatewayConnection: · 5 unmodified lines
278 self._connected.set()284 self._connected.set()
279 if self._first_result is not None and not self._first_result.done():285 if self._first_result is not None and not self._first_result.done():
280 self._first_result.set_result(True)286 self._first_result.set_result(True)
287 # Deliberately after `_connected.set()`: the accepted callback
288 # reconciles the home designation and flushes the durable
289 # delivery queue, and both send frames back over this socket.
290 await self._notify_accepted(accepted)
281 await self._notify_state(True, None)291 await self._notify_state(True, None)
282 delay = 1.0292 delay = 1.0
283 async for raw in socket:293 async for raw in socket:
@@ -322,6 +332,34 @@ class T3GatewayConnection: · 38 unmodified lines
322 await asyncio.sleep(delay)332 await asyncio.sleep(delay)
323 delay = min(delay * 2, 30.0)333 delay = min(delay * 2, 30.0)
324 334
335 async def _notify_accepted(self, accepted: dict[str, Any]) -> None:
336 """Hand the `connection.accepted` frame to the adapter, best-effort.
337
338 A failure here — a read-only `.env`, an unwritable queue file — must
339 not tear down a connection that authenticated successfully, so it is
340 logged and swallowed exactly like the state callback.
341 """
342 if self._on_accepted is None:
343 return
344 try:
345 await self._on_accepted(accepted)
346 except Exception: # noqa: BLE001 - reconciliation must not fail a good handshake
347 logger.warning("T3 connection accepted callback failed", exc_info=True)
348
349 async def _notify_accepted(self, accepted: dict[str, Any]) -> None:
350 """Hand the `connection.accepted` frame to the adapter, best-effort.
351
352 A failure here — a read-only `.env`, an unwritable queue file — must
353 not tear down a connection that authenticated successfully, so it is
354 logged and swallowed exactly like the state callback.
355 """
356 if self._on_accepted is None:
357 return
358 try:
359 await self._on_accepted(accepted)
360 except Exception: # noqa: BLE001 - reconciliation must not fail a good handshake
361 logger.warning("T3 connection accepted callback failed", exc_info=True)
362
325 async def _notify_state(self, connected: bool, reason: str | None) -> None:363 async def _notify_state(self, connected: bool, reason: str | None) -> None:
326 if self._on_state is None:364 if self._on_state is None:
327 return365 return
integrations/hermes-t3-gateway/home.py+605−0
@@ -0,0 +1,605 @@
1"""Home-channel delivery: durable queue, classification, standalone sender.
2
3Hermes-initiated output — cron results, the agent's `send_message` tool with a
4bare `t3` target, gateway lifecycle notices, `/handoff t3` — has no T3-issued
5turn to stream into. It is delivered as a `home.deliver` frame against the
6instance's durable **home thread**, whose id T3 owns and republishes on every
7`connection.accepted`.
8
9Three concerns live here rather than in the adapter:
10
11* **The durable queue.** A delivery is written to disk before it is sent and
12 removed only when T3 acknowledges it, so nothing is lost when either side
13 restarts mid-flight. T3 dedupes on `deliveryId`, which is what makes
14 re-flushing the whole queue safe.
15* **Kind/label classification.** Best-effort provenance recovery from the send
16 context — see `classify_delivery`.
17* **The standalone sender.** Out-of-process cron has no live adapter, so it
18 dials T3 itself over a short-lived `role: "delivery"` socket.
19"""
20
21from __future__ import annotations
22
23import json
24import logging
25import os
26import threading
27from pathlib import Path
28from typing import Any
29
30from .protocol import (
31 HOME_DELIVERY_KINDS,
32 delivery_id,
33 home_deliver,
34 iso_now,
35)
36
37logger = logging.getLogger(__name__)
38
39try: # POSIX advisory locking; absent on Windows.
40 import fcntl
41except ImportError: # pragma: no cover - Windows fallback
42 fcntl = None # type: ignore[assignment]
43
44# The env var carrying T3's home-thread designation.
45#
46# The name is NOT arbitrary. Hermes resolves a platform's cron home target with
47# `_home_target_env_var` (`gateway/run.py:1541`), which falls back to
48# `f"{PLATFORM.upper()}_HOME_CHANNEL"` for any platform without an override
49# entry. Platform `t3` therefore resolves to exactly this string, so the
50# `send_message` error hints, `/sethome` messaging, and cron's env-only
51# resolution path all agree with the value the plugin writes — with no upstream
52# override table entry required.
53HOME_CHANNEL_ENV = "T3_HOME_CHANNEL"
54
55# Queue location. `gateway/` under the Hermes home is the subdirectory Hermes'
56# own bundled plugins use for per-profile adapter state (the Discord adapter's
57# command-sync and non-conversational stores, `plugins/platforms/discord/
58# adapter.py:52`, `:272`, `:1694`). Using `get_hermes_home()` as the base means
59# the queue is profile-scoped for free: a second profile with its own
60# `HERMES_HOME` gets its own queue rather than replaying another profile's
61# deliveries into its own home thread.
62QUEUE_SUBDIR = "gateway"
63QUEUE_FILENAME = "t3_home_delivery_queue.jsonl"
64
65# Bound the queue. Deliveries are small (a cron brief, a lifecycle line), so a
66# few hundred is generous for any realistic outage while keeping the file
67# readable and the flush bounded. Overflow drops the OLDEST entries: a
68# fortnight-old cron brief is worth less than this morning's, and dropping
69# newest would make a wedged queue permanently swallow current output.
70MAX_QUEUE_ENTRIES = 300
71
72# Bound one flush attempt. A reconnect must not spend minutes replaying before
73# the connection is usable for live traffic; the remainder rides the next
74# reconnect.
75MAX_FLUSH_PER_CONNECT = 50
76
77
78def hermes_home() -> Path:
79 """Resolve Hermes' home directory, degrading to the documented default.
80
81 Prefers Hermes' own accessor so a context-local profile override
82 (`set_hermes_home_override`) is honoured, then `HERMES_HOME`, then the
83 platform default. The plugin must not create state outside the active
84 profile, but it also must not fail to queue a delivery merely because
85 Hermes could not be imported (the standalone cron path can run in a very
86 thin process).
87 """
88 try:
89 from hermes_cli.config import get_hermes_home
90
91 return Path(get_hermes_home())
92 except Exception: # noqa: BLE001 - queueing must never depend on Hermes importing
93 pass
94 try:
95 from hermes_constants import get_hermes_home
96
97 return Path(get_hermes_home())
98 except Exception: # noqa: BLE001 - same
99 pass
100 override = os.environ.get("HERMES_HOME", "").strip()
101 return Path(override) if override else Path.home() / ".hermes"
102
103
104def queue_path() -> Path:
105 return hermes_home() / QUEUE_SUBDIR / QUEUE_FILENAME
106
107
108def home_thread_id() -> str:
109 """Read the currently designated home thread from the environment."""
110 return os.environ.get(HOME_CHANNEL_ENV, "").strip()
111
112
113def save_home_thread_id(thread_id: str) -> bool:
114 """Persist T3's home designation, mirroring it into this process.
115
116 T3's settings blob is authoritative and this env var is a synced cache, so
117 a differing local value is overwritten rather than merged — including one a
118 user hand-edited (documented in the plugin README).
119
120 Returns True when the value was durably written. A read-only or managed
121 `.env` degrades to the in-process mirror only: routing works for the life
122 of this gateway and re-reconciles on the next connect.
123 """
124 value = str(thread_id or "").strip()
125 if not value:
126 return False
127 saved = False
128 try:
129 from hermes_cli.config import save_env_value
130
131 save_env_value(HOME_CHANNEL_ENV, value)
132 saved = True
133 except Exception: # noqa: BLE001 - a read-only .env must not break the handshake
134 logger.warning(
135 "Could not persist %s; T3 home delivery will use the in-process "
136 "value until the next reconnect",
137 HOME_CHANNEL_ENV,
138 exc_info=True,
139 )
140 # Mirror into this process exactly as enrollment does (`cli.py`): the
141 # running gateway resolves the home channel from the environment and must
142 # not need a restart to see a freshly designated thread.
143 os.environ[HOME_CHANNEL_ENV] = value
144 return saved
145
146
147class HomeDeliveryQueue:
148 """Append-only JSONL outbox of unacknowledged `home.deliver` frames.
149
150 Correctness rests on one rule: an entry is removed **only** when T3 acks
151 its `deliveryId`. Everything else — a socket that dropped mid-send, a
152 server that died before writing, a plugin that restarted — leaves the entry
153 on disk to be replayed. Replay is safe because T3 dedupes on `deliveryId`,
154 so the failure mode of this design is a duplicate suppressed server-side,
155 never a lost delivery.
156
157 Two processes can hold the same queue: the gateway adapter and an
158 out-of-process cron run using the standalone sender. Writes take a POSIX
159 advisory lock on a sidecar file where `fcntl` is available; on Windows the
160 in-process lock alone applies and a concurrent cron process could in
161 principle interleave a rewrite. The consequence there is a duplicate
162 delivery (deduped by T3), not corruption of an already-acked entry.
163 """
164
165 def __init__(self, path: Path | None = None, max_entries: int = MAX_QUEUE_ENTRIES):
166 self._path = Path(path) if path is not None else None
167 self._max_entries = max(1, int(max_entries))
168 self._lock = threading.RLock()
169
170 @property
171 def path(self) -> Path:
172 # Resolved lazily, not in __init__: `get_hermes_home()` honours a
173 # context-local profile override that may be installed after the
174 # adapter is constructed.
175 return self._path if self._path is not None else queue_path()
176
177 def _ensure_parent(self) -> None:
178 self.path.parent.mkdir(parents=True, exist_ok=True)
179
180 def _read_lines(self) -> list[dict[str, Any]]:
181 path = self.path
182 if not path.exists():
183 return []
184 entries: list[dict[str, Any]] = []
185 try:
186 raw = path.read_text(encoding="utf-8")
187 except OSError:
188 logger.warning("Could not read the T3 home delivery queue", exc_info=True)
189 return []
190 for line in raw.splitlines():
191 line = line.strip()
192 if not line:
193 continue
194 try:
195 entry = json.loads(line)
196 except ValueError:
197 # A torn write from a killed process. Skip the line rather than
198 # discarding the whole queue.
199 continue
200 if isinstance(entry, dict) and entry.get("deliveryId"):
201 entries.append(entry)
202 return entries
203
204 def _write_all(self, entries: list[dict[str, Any]]) -> None:
205 self._ensure_parent()
206 payload = "".join(
207 json.dumps(entry, separators=(",", ":"), ensure_ascii=False) + "\n"
208 for entry in entries
209 )
210 temporary = self.path.with_suffix(".jsonl.tmp")
211 temporary.write_text(payload, encoding="utf-8")
212 temporary.replace(self.path)
213
214 def _file_lock(self):
215 """Advisory cross-process lock, or a no-op where unavailable."""
216
217 class _NoLock:
218 def __enter__(self):
219 return None
220
221 def __exit__(self, *args):
222 return False
223
224 if fcntl is None:
225 return _NoLock()
226
227 queue = self.path
228 try:
229 queue.parent.mkdir(parents=True, exist_ok=True)
230 except OSError:
231 return _NoLock()
232
233 class _FileLock:
234 def __init__(self, lock_path: Path):
235 self._lock_path = lock_path
236 self._handle: Any = None
237
238 def __enter__(self):
239 try:
240 self._handle = open(self._lock_path, "a+") # noqa: SIM115
241 fcntl.flock(self._handle, fcntl.LOCK_EX)
242 except OSError:
243 if self._handle is not None:
244 self._handle.close()
245 self._handle = None
246 return None
247
248 def __exit__(self, *args):
249 if self._handle is not None:
250 try:
251 fcntl.flock(self._handle, fcntl.LOCK_UN)
252 finally:
253 self._handle.close()
254 self._handle = None
255 return False
256
257 return _FileLock(queue.with_suffix(".jsonl.lock"))
258
259 def append(self, frame: dict[str, Any]) -> bool:
260 """Persist one delivery. Returns False when it could not be written."""
261 if not isinstance(frame, dict) or not frame.get("deliveryId"):
262 return False
263 with self._lock, self._file_lock():
264 entries = self._read_lines()
265 if any(
266 entry.get("deliveryId") == frame["deliveryId"] for entry in entries
267 ):
268 return True
269 entries.append(dict(frame))
270 dropped = len(entries) - self._max_entries
271 if dropped > 0:
272 logger.warning(
273 "T3 home delivery queue is full (%d entries); dropping the "
274 "%d oldest undelivered %s",
275 self._max_entries,
276 dropped,
277 "delivery" if dropped == 1 else "deliveries",
278 )
279 entries = entries[dropped:]
280 try:
281 self._write_all(entries)
282 except OSError:
283 logger.error(
284 "Could not persist a T3 home delivery to %s",
285 self.path,
286 exc_info=True,
287 )
288 return False
289 return True
290
291 def entries(self) -> list[dict[str, Any]]:
292 """Every unacknowledged delivery, oldest first."""
293 with self._lock:
294 return self._read_lines()
295
296 def purge(self, delivery_id_value: str) -> bool:
297 """Remove one acknowledged delivery. Returns True when it was present."""
298 target = str(delivery_id_value or "").strip()
299 if not target:
300 return False
301 with self._lock, self._file_lock():
302 entries = self._read_lines()
303 remaining = [
304 entry for entry in entries if entry.get("deliveryId") != target
305 ]
306 if len(remaining) == len(entries):
307 return False
308 try:
309 self._write_all(remaining)
310 except OSError:
311 logger.error(
312 "Could not purge acknowledged T3 home delivery %s from %s",
313 target,
314 self.path,
315 exc_info=True,
316 )
317 return False
318 return True
319
320 def __len__(self) -> int:
321 return len(self.entries())
322
323
324# ── classification ────────────────────────────────────────────────────────
325
326# Hermes' lifecycle broadcasts, matched by their literal shapes at 62e07223.
327# These are inline f-strings upstream, not exported constants, so the same
328# drift risk documented for the `/sethome` notice applies: a wording change
329# upstream downgrades these to `kind: "message"`, which costs a badge and a
330# quiet-delivery classification, never the delivery itself.
331_LIFECYCLE_EXACT = frozenset(
332 {
333 # gateway/run.py:17277
334 "♻️ Gateway online — Hermes is back and ready.",
335 # gateway/run.py:17236
336 "♻ Gateway restarted successfully. Your session continues.",
337 }
338)
339_LIFECYCLE_PREFIXES = (
340 # gateway/run.py:6599 — f"⚠️ Gateway {action} — {hint}"
341 "⚠️ Gateway restarting — ",
342 "⚠️ Gateway shutting down — ",
343)
344
345# cron/scheduler.py:1513 builds this header when `cron.wrap_response` is on
346# (the default).
347_CRON_HEADER = "Cronjob Response: "
348
349# gateway/run.py:8854 — the handoff destination's synthetic source identity.
350_HANDOFF_USER_ID = "system:handoff"
351
352
353def classify_delivery(
354 content: str,
355 metadata: dict[str, Any] | None = None,
356 *,
357 session_user_id: str = "",
358) -> tuple[str, str, bool]:
359 """Best-effort `(kind, label, provenance_certain)` for a proactive send.
360
361 Hermes' `adapter.send()` contract carries no structured "this is a cron
362 delivery" marker on every path, so this reads the signals that do exist:
363
364 * **`metadata["job_id"]`** — the cron scheduler stamps it into the routed
365 metadata for every live-gateway delivery (`cron/scheduler.py:1782`), and
366 `DeliveryRouter._deliver_to_platform` passes that dict through to
367 `adapter.send` unchanged (`gateway/delivery.py:606`). This is the
368 strongest signal available and the only structured one.
369 * **The cron wrap header** — `cron.wrap_response` (default on) prefixes the
370 brief with `Cronjob Response: <name>` (`cron/scheduler.py:1513`), which
371 also supplies a human job name for the badge.
372 * **Lifecycle literals** — the gateway's online/restart/shutdown notices.
373 * **The bound session identity** — `/handoff` dispatches its synthetic turn
374 under `user_id="system:handoff"` (`gateway/run.py:8854`), bound onto the
375 session context by `_set_session_env` (`gateway/run.py:17372`).
376
377 The third element reports whether provenance was *positively* established.
378 The adapter uses it as the tiebreaker when a live turn exists in the home
379 thread: only a positively-identified proactive send may bypass that turn,
380 so an unclassifiable send can never steal a user's answer.
381
382 Worst case for the first two elements is a wrong badge, never a lost
383 delivery — an unrecognised send is `("message", "Hermes", False)`.
384 """
385 text = str(content or "")
386 meta = metadata or {}
387
388 job_id = str(meta.get("job_id") or "").strip()
389 job_name = _cron_job_name(text)
390 if job_id or job_name:
391 label = f"Cron: {job_name or job_id}"
392 return "cron", label, True
393
394 stripped = text.strip()
395 if stripped in _LIFECYCLE_EXACT or stripped.startswith(_LIFECYCLE_PREFIXES):
396 return "lifecycle", "Gateway", True
397
398 if str(session_user_id or "").strip() == _HANDOFF_USER_ID:
399 return "handoff", "Handoff", True
400
401 return "message", "Hermes", False
402
403
404def _cron_job_name(text: str) -> str:
405 """Recover the job name from the cron wrap header, if present."""
406 first_line = text.lstrip().split("\n", 1)[0]
407 if not first_line.startswith(_CRON_HEADER):
408 return ""
409 return first_line[len(_CRON_HEADER) :].strip()
410
411
412def build_delivery(
413 *,
414 thread_id: str,
415 text: str,
416 kind: str = "message",
417 label: str = "Hermes",
418 created_at: str | None = None,
419) -> dict[str, Any]:
420 """Mint one `home.deliver` frame, id and timestamp included.
421
422 `createdAt` is stamped here — when Hermes produced the content — not when
423 the frame reaches T3, so a delivery flushed after a two-hour outage still
424 reports the moment it was written.
425 """
426 normalized_kind = kind if kind in HOME_DELIVERY_KINDS else "other"
427 return home_deliver(
428 delivery_id_value=delivery_id(),
429 thread_id=thread_id,
430 kind=normalized_kind,
431 label=label,
432 text=text,
433 created_at=created_at or iso_now(),
434 )
435
436
437# ── standalone (out-of-process) sender ────────────────────────────────────
438
439
440async def standalone_send(
441 pconfig: Any,
442 chat_id: str,
443 message: str,
444 *,
445 thread_id: str | None = None,
446 media_files: list[str] | None = None,
447 force_document: bool = False,
448) -> dict[str, Any]:
449 """Deliver to the home thread with no live gateway adapter in this process.
450
451 Registered as `standalone_sender_fn` and called by
452 `tools/send_message_tool._send_via_adapter` when the in-process adapter
453 weakref is empty — the `hermes cron` process running separately from
454 `hermes gateway`. Without it, `deliver=t3` cron jobs fail with "No live
455 adapter for platform".
456
457 The socket announces `role: "delivery"`. That is load-bearing: T3's broker
458 registers a `gateway` connection under generation fencing and displaces the
459 previous one, so a naive cron dial-in would kick the live gateway socket
460 off its own instance mid-turn. A `delivery` connection is authenticated
461 identically, never becomes the primary, and is expected to close promptly.
462
463 A connection failure is **not** a cron failure. The delivery is already on
464 disk before the socket is opened, so the job reports success-with-queued
465 and the live gateway flushes it on its next `connection.accepted`.
466
467 `media_files` and `force_document` are accepted for signature parity
468 (`gateway/platform_registry.py:150-158`); the v3 wire contract carries no
469 attachment framing, so they are ignored.
470 """
471 del force_document
472 if media_files:
473 logger.info(
474 "T3 standalone send ignoring %d media file(s): the v3 gateway "
475 "contract has no attachment framing",
476 len(media_files),
477 )
478
479 extra = getattr(pconfig, "extra", None) or {}
480
481 def _setting(key: str, env: str) -> str:
482 return str(extra.get(key) or os.environ.get(env, "") or "").strip()
483
484 url = _setting("url", "HERMES_T3_GATEWAY_URL")
485 instance_id = _setting("instance_id", "HERMES_T3_GATEWAY_INSTANCE_ID")
486 credential = _setting("credential", "HERMES_T3_GATEWAY_CREDENTIAL")
487 if not (url and instance_id and credential):
488 return {
489 "error": (
490 "T3 standalone send: this Hermes is not enrolled. Run "
491 "`hermes t3 connect --url <url> --token <token>` first."
492 )
493 }
494
495 target = str(chat_id or "").strip() or str(thread_id or "").strip()
496 if not target:
497 target = home_thread_id()
498 if not target:
499 return {
500 "error": (
501 "T3 standalone send: no home thread is designated. Start "
502 "`hermes gateway` once so T3 can publish one."
503 )
504 }
505
506 kind, label, _certain = classify_delivery(message, None)
507 frame = build_delivery(thread_id=target, text=message, kind=kind, label=label)
508 delivery = str(frame["deliveryId"])
509
510 queue = HomeDeliveryQueue()
511 queued = queue.append(frame)
512
513 try:
514 acked = await _deliver_over_short_lived_socket(
515 url=url,
516 instance_id=instance_id,
517 credential=credential,
518 frame=frame,
519 )
520 except Exception as exc: # noqa: BLE001 - cron delivery must not raise
521 logger.debug("T3 standalone send raised", exc_info=True)
522 if queued:
523 return {
524 "success": True,
525 "message_id": delivery,
526 "queued": True,
527 "detail": f"T3 unreachable ({exc}); queued for the next connect",
528 }
529 return {"error": f"T3 standalone send failed: {exc}"}
530
531 if acked:
532 queue.purge(delivery)
533 return {"success": True, "message_id": delivery}
534 if queued:
535 return {
536 "success": True,
537 "message_id": delivery,
538 "queued": True,
539 "detail": "T3 did not acknowledge the delivery; queued for retry",
540 }
541 return {"error": "T3 standalone send: the delivery was neither acked nor queued"}
542
543
544async def _deliver_over_short_lived_socket(
545 *,
546 url: str,
547 instance_id: str,
548 credential: str,
549 frame: dict[str, Any],
550 timeout: float = 20.0,
551) -> bool:
552 """Open, authenticate as `delivery`, send, await the ack, close."""
553 import asyncio
554
555 from .connection import _open_socket, authenticate_socket
556
557 hermes_version = _hermes_version()
558 socket = await _open_socket(url)
559 try:
560 await authenticate_socket(
561 socket,
562 authentication={
563 "type": "instance-credential",
564 "instanceId": instance_id,
565 "credential": credential,
566 },
567 hermes_version=hermes_version,
568 role="delivery",
569 )
570 await socket.send(
571 json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
572 )
573 loop = asyncio.get_running_loop()
574 deadline = loop.time() + timeout
575 while True:
576 remaining = deadline - loop.time()
577 if remaining <= 0:
578 return False
579 raw = await asyncio.wait_for(socket.recv(), timeout=remaining)
580 try:
581 reply = json.loads(raw)
582 except ValueError:
583 continue
584 if not isinstance(reply, dict):
585 continue
586 if reply.get("type") == "home.deliver.ack" and str(
587 reply.get("deliveryId") or ""
588 ) == str(frame["deliveryId"]):
589 return True
590 # Anything else (a liveness ping, an unrelated frame) is ignored:
591 # this socket exists only to hand over one delivery.
592 finally:
593 try:
594 await socket.close()
595 except Exception: # noqa: BLE001 - a failed close must not fail the send
596 logger.debug("T3 standalone socket close failed", exc_info=True)
597
598
599def _hermes_version() -> str:
600 try:
601 from hermes_cli import __version__
602
603 return str(__version__)
604 except Exception: # noqa: BLE001 - version discovery must not block delivery
605 return "unknown"
integrations/hermes-t3-gateway/plugin.yaml+1−1
@@ -1,7 +1,7 @@
1name: hermes-t3-gateway1name: hermes-t3-gateway
2label: T3 Code2label: T3 Code
3kind: platform3kind: platform
4version: 0.2.04version: 0.3.0
5description: >5description: >
6 Experimental outbound gateway that exposes one Hermes Agent process as a6 Experimental outbound gateway that exposes one Hermes Agent process as a
7 named T3 Code provider instance over an authenticated WebSocket.7 named T3 Code provider instance over an authenticated WebSocket.
integrations/hermes-t3-gateway/protocol.py+141−2
@@ -7,10 +7,17 @@ import uuid · 6 unmodified lines
7from datetime import datetime, timezone7from datetime import datetime, timezone
8from typing import Any8from typing import Any
9 9
10PROTOCOL_VERSION = 210PROTOCOL_VERSION = 3
11PLUGIN_VERSION = "0.2.0"11PLUGIN_VERSION = "0.3.0"
12WEBSOCKET_PATH = "/api/hermes-gateway/ws"12WEBSOCKET_PATH = "/api/hermes-gateway/ws"
13 13
14# What a connecting socket intends to be. `gateway` is the instance's one live
15# plugin connection; `delivery` is a short-lived socket (an out-of-process cron
16# run) that hands over a `home.deliver` and leaves. T3 never registers a
17# `delivery` socket as the primary connection, so it cannot displace a healthy
18# gateway connection under the broker's generation fencing.
19CONNECTION_ROLES = frozenset({"gateway", "delivery"})
20
14CAPABILITIES = {21CAPABILITIES = {
15 "protocolVersion": PROTOCOL_VERSION,22 "protocolVersion": PROTOCOL_VERSION,
16 "streaming": True,23 "streaming": True,
@@ -34,9 +41,21 @@ SERVER_COMMANDS = frozenset( · 17 unmodified lines
34 "ping",41 "ping",
35 "describe.request",42 "describe.request",
36 "skill.body.request",43 "skill.body.request",
44 "home.deliver.ack",
37 }45 }
38)46)
39 47
48# Kinds a `home.deliver` may carry. Mirrors the T3 contract's
49# `HermesGatewayHomeDeliveryKind`; anything not classified lands on "message".
50HOME_DELIVERY_KINDS = frozenset({"cron", "message", "lifecycle", "handoff", "other"})
51
52# Wire bounds from the T3 contract (`HermesGatewayHomeDeliver`): `label` is a
53# trimmed non-empty string of at most 200 chars, `text` is 1..120000 chars.
54# Enforced here so a pathological Hermes payload is clamped rather than
55# rejected by the server after the plugin already dropped its local copy.
56MAX_HOME_DELIVERY_LABEL_CHARS = 200
57MAX_HOME_DELIVERY_TEXT_CHARS = 120_000
58
40# Ceiling on a single skill body crossing the wire. Skill markdown is59# Ceiling on a single skill body crossing the wire. Skill markdown is
41# human-authored documentation, not data: 512 KiB is far past any real60# human-authored documentation, not data: 512 KiB is far past any real
42# SKILL.md while still bounding a pathological file from stalling the socket.61# SKILL.md while still bounding a pathological file from stalling the socket.
@@ -51,6 +70,26 @@ def item_id() -> str: · 8 unmodified lines
51 return str(uuid.uuid4())70 return str(uuid.uuid4())
52 71
53 72
73def delivery_id() -> str:
74 """Mint the idempotency key for one home delivery.
75
76 Stable across retries by construction: the id is minted once, when the
77 delivery is created, and the queued copy carries it verbatim through every
78 flush. T3 dedupes on it, which is what makes double-flushing safe.
79 """
80 return str(uuid.uuid4())
81
82
83def delivery_id() -> str:
84 """Mint the idempotency key for one home delivery.
85
86 Stable across retries by construction: the id is minted once, when the
87 delivery is created, and the queued copy carries it verbatim through every
88 flush. T3 dedupes on it, which is what makes double-flushing safe.
89 """
90 return str(uuid.uuid4())
91
92
54def iso_now() -> str:93def iso_now() -> str:
55 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")94 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
56 95
@@ -261,8 +300,19 @@ def connection_hello( · 204 unmodified lines
261 authentication: dict[str, str],300 authentication: dict[str, str],
262 hello_request_id: str | None = None,301 hello_request_id: str | None = None,
263 model: str | None = None,302 model: str | None = None,
303 role: str = "gateway",
264) -> dict[str, Any]:304) -> dict[str, Any]:
305 """Build the handshake frame.
306
307 `role` is sent explicitly even though the T3 contract decodes a missing
308 value as `"gateway"`: an out-of-process cron sender MUST announce
309 `"delivery"` or the broker registers it as the instance's primary
310 connection and generation-fences the live gateway socket off.
311 """
265 resolved_model = model if model is not None else configured_model()312 resolved_model = model if model is not None else configured_model()
313 normalized_role = str(role or "gateway").strip().lower()
314 if normalized_role not in CONNECTION_ROLES:
315 normalized_role = "gateway"
266 hello: dict[str, Any] = {316 hello: dict[str, Any] = {
267 "type": "connection.hello",317 "type": "connection.hello",
268 "requestId": hello_request_id or request_id(),318 "requestId": hello_request_id or request_id(),
@@ -271,6 +321,7 @@ def connection_hello( · 2 unmodified lines
271 "hermesVersion": hermes_version,321 "hermesVersion": hermes_version,
272 "capabilities": dict(CAPABILITIES),322 "capabilities": dict(CAPABILITIES),
273 "authentication": authentication,323 "authentication": authentication,
324 "role": normalized_role,
274 }325 }
275 # Optional on the wire: omit entirely rather than send null/empty so a326 # Optional on the wire: omit entirely rather than send null/empty so a
276 # server that has the field still falls back to its generic label.327 # server that has the field still falls back to its generic label.
@@ -279,6 +330,94 @@ def connection_hello( · 2 unmodified lines
279 return hello330 return hello
280 331
281 332
333def home_deliver(
334 *,
335 delivery_id_value: str,
336 thread_id: str,
337 kind: str,
338 label: str,
339 text: str,
340 created_at: str | None = None,
341) -> dict[str, Any]:
342 """Build a `home.deliver` frame with every wire bound already applied.
343
344 Like `protocol_error`, this wraps the generic `frame()` helper rather than
345 assembling the envelope itself — but unlike the plain outbound frames the
346 adapter builds inline, a delivery has server-side validation the plugin
347 must not trip: `label` is trimmed non-empty ≤200 chars and `text` is
348 1..120000 chars in the T3 contract. Normalizing here rather than at the
349 call sites means a queued delivery is already wire-valid on disk, so a
350 flush after a plugin upgrade cannot resurrect a payload the server will
351 reject — the plugin would purge it only on an ack that never comes.
352
353 An unknown `kind` degrades to `"other"` and an empty label to `"Hermes"`:
354 a misclassified badge is the documented worst case, a dropped delivery is
355 not.
356 """
357 normalized_kind = str(kind or "").strip().lower()
358 if normalized_kind not in HOME_DELIVERY_KINDS:
359 normalized_kind = "other"
360 normalized_label = str(label or "").strip()[:MAX_HOME_DELIVERY_LABEL_CHARS].strip()
361 if not normalized_label:
362 normalized_label = "Hermes"
363 normalized_text = str(text or "")[:MAX_HOME_DELIVERY_TEXT_CHARS]
364 if not normalized_text:
365 normalized_text = " "
366 return frame(
367 "home.deliver",
368 deliveryId=delivery_id_value,
369 threadId=str(thread_id),
370 kind=normalized_kind,
371 label=normalized_label,
372 text=normalized_text,
373 createdAt=created_at or iso_now(),
374 )
375
376
377def home_deliver(
378 *,
379 delivery_id_value: str,
380 thread_id: str,
381 kind: str,
382 label: str,
383 text: str,
384 created_at: str | None = None,
385) -> dict[str, Any]:
386 """Build a `home.deliver` frame with every wire bound already applied.
387
388 Like `protocol_error`, this wraps the generic `frame()` helper rather than
389 assembling the envelope itself — but unlike the plain outbound frames the
390 adapter builds inline, a delivery has server-side validation the plugin
391 must not trip: `label` is trimmed non-empty <=200 chars and `text` is
392 1..120000 chars in the T3 contract. Normalizing here rather than at the
393 call sites means a queued delivery is already wire-valid on disk, so a
394 flush after a plugin upgrade cannot resurrect a payload the server will
395 reject — the plugin would purge it only on an ack that never comes.
396
397 An unknown `kind` degrades to `"other"` and an empty label to `"Hermes"`:
398 a misclassified badge is the documented worst case, a dropped delivery is
399 not.
400 """
401 normalized_kind = str(kind or "").strip().lower()
402 if normalized_kind not in HOME_DELIVERY_KINDS:
403 normalized_kind = "other"
404 normalized_label = str(label or "").strip()[:MAX_HOME_DELIVERY_LABEL_CHARS].strip()
405 if not normalized_label:
406 normalized_label = "Hermes"
407 normalized_text = str(text or "")[:MAX_HOME_DELIVERY_TEXT_CHARS]
408 if not normalized_text:
409 normalized_text = " "
410 return frame(
411 "home.deliver",
412 deliveryId=delivery_id_value,
413 threadId=str(thread_id),
414 kind=normalized_kind,
415 label=normalized_label,
416 text=normalized_text,
417 createdAt=created_at or iso_now(),
418 )
419
420
282def protocol_error(421def protocol_error(
283 code: str,422 code: str,
284 message: str,423 message: str,
integrations/hermes-t3-gateway/tests/test_adapter.py+466−28
@@ -7,6 +7,7 @@ import enum · 6 unmodified lines
7import importlib.util7import importlib.util
8import pathlib8import pathlib
9import sys9import sys
10import tempfile
10import types11import types
11import unittest12import unittest
12import unittest.mock13import unittest.mock
@@ -146,7 +147,7 @@ def load_plugin_modules(): · 133 unmodified lines
146 package = types.ModuleType(PACKAGE)147 package = types.ModuleType(PACKAGE)
147 package.__path__ = [str(ROOT)]148 package.__path__ = [str(ROOT)]
148 sys.modules[PACKAGE] = package149 sys.modules[PACKAGE] = package
149 for name in ("protocol", "connection", "cli", "adapter"):150 for name in ("protocol", "connection", "cli", "home", "adapter"):
150 spec = importlib.util.spec_from_file_location(151 spec = importlib.util.spec_from_file_location(
151 f"{PACKAGE}.{name}", ROOT / f"{name}.py"152 f"{PACKAGE}.{name}", ROOT / f"{name}.py"
152 )153 )
@@ -159,6 +160,7 @@ def load_plugin_modules(): · 6 unmodified lines
159 160
160adapter_module = load_plugin_modules()161adapter_module = load_plugin_modules()
161protocol_module = sys.modules[f"{PACKAGE}.protocol"]162protocol_module = sys.modules[f"{PACKAGE}.protocol"]
163home_module = sys.modules[f"{PACKAGE}.home"]
162 164
163 165
164@contextlib.contextmanager166@contextlib.contextmanager
@@ -217,7 +219,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 52 unmodified lines
217 await self.adapter._handle_server_frame(219 await self.adapter._handle_server_frame(
218 {220 {
219 "type": "session.ensure",221 "type": "session.ensure",
220 "protocolVersion": 2,222 "protocolVersion": 3,
221 "requestId": f"ensure-{thread_id}",223 "requestId": f"ensure-{thread_id}",
222 "threadId": thread_id,224 "threadId": thread_id,
223 }225 }
@@ -226,7 +228,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
226 await self.adapter._handle_server_frame(228 await self.adapter._handle_server_frame(
227 {229 {
228 "type": "turn.start",230 "type": "turn.start",
229 "protocolVersion": 2,231 "protocolVersion": 3,
230 "requestId": f"start-{thread_id}",232 "requestId": f"start-{thread_id}",
231 "threadId": thread_id,233 "threadId": thread_id,
232 "sessionId": session_id,234 "sessionId": session_id,
@@ -240,7 +242,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
240 await self.adapter._handle_server_frame(242 await self.adapter._handle_server_frame(
241 {243 {
242 "type": "session.ensure",244 "type": "session.ensure",
243 "protocolVersion": 2,245 "protocolVersion": 3,
244 "requestId": "ensure-1",246 "requestId": "ensure-1",
245 "threadId": "thread-1",247 "threadId": "thread-1",
246 }248 }
@@ -253,7 +255,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 6 unmodified lines
253 await self.adapter._handle_server_frame(255 await self.adapter._handle_server_frame(
254 {256 {
255 "type": "turn.start",257 "type": "turn.start",
256 "protocolVersion": 2,258 "protocolVersion": 3,
257 "requestId": "start-1",259 "requestId": "start-1",
258 "threadId": "thread-1",260 "threadId": "thread-1",
259 "sessionId": ready["sessionId"],261 "sessionId": ready["sessionId"],
@@ -833,7 +835,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 573 unmodified lines
833 await self.adapter._handle_server_frame(835 await self.adapter._handle_server_frame(
834 {836 {
835 "type": "session.ensure",837 "type": "session.ensure",
836 "protocolVersion": 2,838 "protocolVersion": 3,
837 "requestId": "ensure-reconnect",839 "requestId": "ensure-reconnect",
838 "threadId": "thread-reconnect",840 "threadId": "thread-reconnect",
839 "resumeSessionId": session_id,841 "resumeSessionId": session_id,
@@ -849,7 +851,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 9 unmodified lines
849 await self.adapter._handle_server_frame(851 await self.adapter._handle_server_frame(
850 {852 {
851 "type": "session.ensure",853 "type": "session.ensure",
852 "protocolVersion": 2,854 "protocolVersion": 3,
853 "requestId": "ensure-2",855 "requestId": "ensure-2",
854 "threadId": "thread-2",856 "threadId": "thread-2",
855 }857 }
@@ -858,7 +860,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
858 await self.adapter._handle_server_frame(860 await self.adapter._handle_server_frame(
859 {861 {
860 "type": "turn.start",862 "type": "turn.start",
861 "protocolVersion": 2,863 "protocolVersion": 3,
862 "requestId": "start-2",864 "requestId": "start-2",
863 "threadId": "thread-2",865 "threadId": "thread-2",
864 "sessionId": session_id,866 "sessionId": session_id,
@@ -877,7 +879,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines
877 await self.adapter._handle_server_frame(879 await self.adapter._handle_server_frame(
878 {880 {
879 "type": "turn.steer",881 "type": "turn.steer",
880 "protocolVersion": 2,882 "protocolVersion": 3,
881 "requestId": "steer-2",883 "requestId": "steer-2",
882 "threadId": "thread-2",884 "threadId": "thread-2",
883 "sessionId": session_id,885 "sessionId": session_id,
@@ -949,7 +951,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 65 unmodified lines
949 await self.adapter._handle_server_frame(951 await self.adapter._handle_server_frame(
950 {952 {
951 "type": "turn.steer",953 "type": "turn.steer",
952 "protocolVersion": 2,954 "protocolVersion": 3,
953 "requestId": "steer-race",955 "requestId": "steer-race",
954 "threadId": "thread-steer-race",956 "threadId": "thread-steer-race",
955 "sessionId": session_id,957 "sessionId": session_id,
@@ -998,7 +1000,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 42 unmodified lines
998 await self.adapter._handle_server_frame(1000 await self.adapter._handle_server_frame(
999 {1001 {
1000 "type": "turn.steer",1002 "type": "turn.steer",
1001 "protocolVersion": 2,1003 "protocolVersion": 3,
1002 "requestId": "steer-edit",1004 "requestId": "steer-edit",
1003 "threadId": "thread-steer-edit",1005 "threadId": "thread-steer-edit",
1004 "sessionId": session_id,1006 "sessionId": session_id,
@@ -1017,7 +1019,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines
1017 await self.adapter._handle_server_frame(1019 await self.adapter._handle_server_frame(
1018 {1020 {
1019 "type": "session.ensure",1021 "type": "session.ensure",
1020 "protocolVersion": 2,1022 "protocolVersion": 3,
1021 "requestId": "ensure-rejected-steer",1023 "requestId": "ensure-rejected-steer",
1022 "threadId": "thread-rejected-steer",1024 "threadId": "thread-rejected-steer",
1023 }1025 }
@@ -1026,7 +1028,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
1026 await self.adapter._handle_server_frame(1028 await self.adapter._handle_server_frame(
1027 {1029 {
1028 "type": "turn.start",1030 "type": "turn.start",
1029 "protocolVersion": 2,1031 "protocolVersion": 3,
1030 "requestId": "start-rejected-steer",1032 "requestId": "start-rejected-steer",
1031 "threadId": "thread-rejected-steer",1033 "threadId": "thread-rejected-steer",
1032 "sessionId": session_id,1034 "sessionId": session_id,
@@ -1043,7 +1045,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
1043 await self.adapter._handle_server_frame(1045 await self.adapter._handle_server_frame(
1044 {1046 {
1045 "type": "turn.steer",1047 "type": "turn.steer",
1046 "protocolVersion": 2,1048 "protocolVersion": 3,
1047 "requestId": "steer-rejected",1049 "requestId": "steer-rejected",
1048 "threadId": "thread-rejected-steer",1050 "threadId": "thread-rejected-steer",
1049 "sessionId": session_id,1051 "sessionId": session_id,
@@ -1090,7 +1092,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 40 unmodified lines
1090 await self.adapter._handle_server_frame(1092 await self.adapter._handle_server_frame(
1091 {1093 {
1092 "type": "session.ensure",1094 "type": "session.ensure",
1093 "protocolVersion": 2,1095 "protocolVersion": 3,
1094 "requestId": "ensure-failed-steer",1096 "requestId": "ensure-failed-steer",
1095 "threadId": "thread-failed-steer",1097 "threadId": "thread-failed-steer",
1096 }1098 }
@@ -1099,7 +1101,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
1099 await self.adapter._handle_server_frame(1101 await self.adapter._handle_server_frame(
1100 {1102 {
1101 "type": "turn.start",1103 "type": "turn.start",
1102 "protocolVersion": 2,1104 "protocolVersion": 3,
1103 "requestId": "start-failed-steer",1105 "requestId": "start-failed-steer",
1104 "threadId": "thread-failed-steer",1106 "threadId": "thread-failed-steer",
1105 "sessionId": session_id,1107 "sessionId": session_id,
@@ -1116,7 +1118,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
1116 await self.adapter._handle_server_frame(1118 await self.adapter._handle_server_frame(
1117 {1119 {
1118 "type": "turn.steer",1120 "type": "turn.steer",
1119 "protocolVersion": 2,1121 "protocolVersion": 3,
1120 "requestId": "steer-failed",1122 "requestId": "steer-failed",
1121 "threadId": "thread-failed-steer",1123 "threadId": "thread-failed-steer",
1122 "sessionId": session_id,1124 "sessionId": session_id,
@@ -1204,7 +1206,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 81 unmodified lines
1204 await self.adapter._handle_server_frame(1206 await self.adapter._handle_server_frame(
1205 {1207 {
1206 "type": "session.ensure",1208 "type": "session.ensure",
1207 "protocolVersion": 2,1209 "protocolVersion": 3,
1208 "requestId": "ensure-3",1210 "requestId": "ensure-3",
1209 "threadId": "thread-3",1211 "threadId": "thread-3",
1210 }1212 }
@@ -1214,7 +1216,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 3 unmodified lines
1214 await self.adapter._handle_server_frame(1216 await self.adapter._handle_server_frame(
1215 {1217 {
1216 "type": "session.stop",1218 "type": "session.stop",
1217 "protocolVersion": 2,1219 "protocolVersion": 3,
1218 "requestId": "stop-3",1220 "requestId": "stop-3",
1219 "threadId": "thread-3",1221 "threadId": "thread-3",
1220 "sessionId": session_id,1222 "sessionId": session_id,
@@ -1235,7 +1237,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines
1235 await self.adapter._handle_server_frame(1237 await self.adapter._handle_server_frame(
1236 {1238 {
1237 "type": "describe.request",1239 "type": "describe.request",
1238 "protocolVersion": 2,1240 "protocolVersion": 3,
1239 "requestId": "describe-1",1241 "requestId": "describe-1",
1240 }1242 }
1241 )1243 )
@@ -1244,7 +1246,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
1244 self.assertEqual(reply["type"], "describe.response")1246 self.assertEqual(reply["type"], "describe.response")
1245 # Correlation, exactly like ping -> pong.1247 # Correlation, exactly like ping -> pong.
1246 self.assertEqual(reply["requestId"], "describe-1")1248 self.assertEqual(reply["requestId"], "describe-1")
1247 self.assertEqual(reply["protocolVersion"], 2)1249 self.assertEqual(reply["protocolVersion"], 3)
1248 self.assertEqual(reply["hermesVersion"], "0.19.0")1250 self.assertEqual(reply["hermesVersion"], "0.19.0")
1249 self.assertIsInstance(reply["skills"], list)1251 self.assertIsInstance(reply["skills"], list)
1250 self.assertIn("capabilities", reply)1252 self.assertIn("capabilities", reply)
@@ -1258,7 +1260,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
1258 await self.adapter._handle_server_frame(1260 await self.adapter._handle_server_frame(
1259 {1261 {
1260 "type": "describe.request",1262 "type": "describe.request",
1261 "protocolVersion": 2,1263 "protocolVersion": 3,
1262 "requestId": "describe-degraded",1264 "requestId": "describe-degraded",
1263 }1265 }
1264 )1266 )
@@ -1275,7 +1277,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
1275 await self.adapter._handle_server_frame(1277 await self.adapter._handle_server_frame(
1276 {1278 {
1277 "type": "skill.body.request",1279 "type": "skill.body.request",
1278 "protocolVersion": 2,1280 "protocolVersion": 3,
1279 "requestId": "body-degraded",1281 "requestId": "body-degraded",
1280 "skillName": "codex",1282 "skillName": "codex",
1281 }1283 }
@@ -1293,7 +1295,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 11 unmodified lines
1293 await self.adapter._handle_server_frame(1295 await self.adapter._handle_server_frame(
1294 {1296 {
1295 "type": "skill.body.request",1297 "type": "skill.body.request",
1296 "protocolVersion": 2,1298 "protocolVersion": 3,
1297 "requestId": "body-1",1299 "requestId": "body-1",
1298 "skillName": "codex",1300 "skillName": "codex",
1299 }1301 }
@@ -1310,7 +1312,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
1310 await self.adapter._handle_server_frame(1312 await self.adapter._handle_server_frame(
1311 {1313 {
1312 "type": "skill.body.request",1314 "type": "skill.body.request",
1313 "protocolVersion": 2,1315 "protocolVersion": 3,
1314 "requestId": "body-2",1316 "requestId": "body-2",
1315 "skillName": "does-not-exist",1317 "skillName": "does-not-exist",
1316 }1318 }
@@ -1330,7 +1332,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 13 unmodified lines
1330 await self.adapter._handle_server_frame(1332 await self.adapter._handle_server_frame(
1331 {1333 {
1332 "type": "skill.body.request",1334 "type": "skill.body.request",
1333 "protocolVersion": 2,1335 "protocolVersion": 3,
1334 "requestId": "body-3",1336 "requestId": "body-3",
1335 }1337 }
1336 )1338 )
@@ -1344,12 +1346,12 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
1344 for message in (1346 for message in (
1345 {1347 {
1346 "type": "describe.request",1348 "type": "describe.request",
1347 "protocolVersion": 2,1349 "protocolVersion": 3,
1348 "requestId": "describe-no-error",1350 "requestId": "describe-no-error",
1349 },1351 },
1350 {1352 {
1351 "type": "skill.body.request",1353 "type": "skill.body.request",
1352 "protocolVersion": 2,1354 "protocolVersion": 3,
1353 "requestId": "body-no-error",1355 "requestId": "body-no-error",
1354 "skillName": "codex",1356 "skillName": "codex",
1355 },1357 },
@@ -1535,6 +1537,442 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 179 unmodified lines
1535 types_after = [m["type"] for m in self.connection.messages[order_start:]]1537 types_after = [m["type"] for m in self.connection.messages[order_start:]]
1536 self.assertEqual(types_after[-2:], ["turn.completed", "connection.status"])1538 self.assertEqual(types_after[-2:], ["turn.completed", "connection.status"])
1537 1539
1540 async def test_cron_tool_hooks_are_excluded_from_the_sole_turn_fallback(self):
1541 """A cron job's tool calls must never land in an unrelated live turn.
1542
1543 The hooks are process-global, so a cron job running tools while exactly
1544 one T3 turn happens to be active would otherwise resolve through the
1545 sole-active-turn fallback and paint its tool rows into a conversation
1546 it has nothing to do with. Cron runs are identifiable by the
1547 `cron_<job>_<timestamp>` session id the scheduler mints
1548 (`cron/scheduler.py:3017`); their activity belongs to the eventual
1549 `home.deliver`, not to any turn.
1550 """
1551 self.adapter._event_loop = asyncio.get_running_loop()
1552 await self._start_turn("thread-live", "turn-live")
1553 frames_before = len(self.connection.messages)
1554 cron_session = "cron_daily-digest_20260726_090000"
1555
1556 with unittest.mock.patch.object(
1557 adapter_module.T3PlatformAdapter,
1558 "_gateway_session_key",
1559 staticmethod(lambda: ""),
1560 ):
1561 self.adapter.emit_tool_started(
1562 cron_session, "web_search", {"query": "weather"}, "cron-call-1"
1563 )
1564 self.adapter.emit_tool_completed(
1565 cron_session, "web_search", "sunny", 12, "cron-call-1"
1566 )
1567 await asyncio.sleep(0)
1568
1569 self.assertEqual(self.connection.messages[frames_before:], [])
1570 # The unrelated turn is untouched and still streaming.
1571 self.assertIn("thread-live", self.adapter._active_turns)
1572
1573 # A genuine gateway run id still takes the fallback — the exclusion is
1574 # scoped to cron, not a blanket removal of the fallback.
1575 with unittest.mock.patch.object(
1576 adapter_module.T3PlatformAdapter,
1577 "_gateway_session_key",
1578 staticmethod(lambda: ""),
1579 ):
1580 self.adapter.emit_tool_started(
1581 "20260726_090000_ab12cd34", "read_file", {"path": "a.py"}, "call-9"
1582 )
1583 await asyncio.sleep(0)
1584 fallback_frames = self.connection.messages[frames_before:]
1585 self.assertEqual([m["type"] for m in fallback_frames], ["item.started"])
1586 self.assertEqual(fallback_frames[0]["threadId"], "thread-live")
1587
1588
1589class HomeDeliveryTests(unittest.IsolatedAsyncioTestCase):
1590 """The proactive `home.deliver` branch and its durable queue."""
1591
1592 HOME = "home-thread"
1593
1594 async def asyncSetUp(self):
1595 self.adapter = adapter_module.T3PlatformAdapter(
1596 PlatformConfig(
1597 extra={
1598 "url": "wss://t3.example/api/hermes-gateway/ws",
1599 "instance_id": "instance",
1600 "credential": "credential",
1601 }
1602 )
1603 )
1604 self.connection = FakeConnection()
1605 self.adapter._connection = self.connection
1606
1607 self._tmp = tempfile.TemporaryDirectory()
1608 self.addCleanup(self._tmp.cleanup)
1609 queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
1610 self.queue = home_module.HomeDeliveryQueue(path=queue_file)
1611 self.adapter._home_queue = self.queue
1612
1613 environment = unittest.mock.patch.dict(
1614 adapter_module.os.environ,
1615 {home_module.HOME_CHANNEL_ENV: self.HOME},
1616 )
1617 environment.start()
1618 self.addCleanup(environment.stop)
1619
1620 # No Hermes session context is bound in tests, so the real accessors
1621 # would fall through to os.environ. Pin them to "no session" — the
1622 # state a cron run or a lifecycle broadcast is actually in.
1623 for name in ("_gateway_session_key", "_session_user_id"):
1624 patch = unittest.mock.patch.object(
1625 adapter_module.T3PlatformAdapter, name, staticmethod(lambda: "")
1626 )
1627 patch.start()
1628 self.addCleanup(patch.stop)
1629
1630 async def _start_turn(self, thread_id: str, turn_id: str) -> str:
1631 await self.adapter._handle_server_frame(
1632 {
1633 "type": "session.ensure",
1634 "protocolVersion": 3,
1635 "requestId": f"ensure-{thread_id}",
1636 "threadId": thread_id,
1637 }
1638 )
1639 session_id = self.adapter._sessions[thread_id]
1640 await self.adapter._handle_server_frame(
1641 {
1642 "type": "turn.start",
1643 "protocolVersion": 3,
1644 "requestId": f"start-{thread_id}",
1645 "threadId": thread_id,
1646 "sessionId": session_id,
1647 "turnId": turn_id,
1648 "text": "Start",
1649 }
1650 )
1651 return session_id
1652
1653 async def test_a_proactive_send_to_home_emits_home_deliver(self):
1654 frames_before = len(self.connection.messages)
1655
1656 result = await self.adapter.send(
1657 self.HOME,
1658 "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
1659 metadata={"notify": True, "job_id": "nightly"},
1660 )
1661
1662 frames = self.connection.messages[frames_before:]
1663 self.assertEqual([frame["type"] for frame in frames], ["home.deliver"])
1664 delivery = frames[0]
1665 self.assertEqual(delivery["protocolVersion"], 3)
1666 self.assertEqual(delivery["threadId"], self.HOME)
1667 self.assertEqual(delivery["kind"], "cron")
1668 self.assertEqual(delivery["label"], "Cron: nightly")
1669 self.assertTrue(delivery["createdAt"].endswith("Z"))
1670 self.assertTrue(result.success)
1671 self.assertEqual(result.message_id, delivery["deliveryId"])
1672
1673 # No turn was invented, and none was completed.
1674 self.assertEqual(self.adapter._active_turns, {})
1675
1676 async def test_a_delivery_never_emits_turn_or_item_frames(self):
1677 frames_before = len(self.connection.messages)
1678 await self.adapter.send(self.HOME, "♻️ Gateway online — Hermes is back and ready.")
1679 emitted = {frame["type"] for frame in self.connection.messages[frames_before:]}
1680 self.assertEqual(emitted, {"home.deliver"})
1681 self.assertEqual(self.adapter._active_turns, {})
1682
1683 async def test_a_notify_stamped_delivery_does_not_complete_the_live_turn(self):
1684 """THE deadlock regression.
1685
1686 A cron delivery landing in Home while the user has a live turn there
1687 arrives notify-stamped (`_mark_notify_metadata`,
1688 `gateway/platforms/base.py:89`). Under a naive "no active turn →
1689 deliver" gate it would fall into the active-turn path, stream as that
1690 turn's assistant content, and its notify stamp would COMPLETE the
1691 user's turn with the cron output as the answer. The gate is provenance,
1692 not turn absence: the cron send does not carry the turn's session key,
1693 so it becomes a `home.deliver` and the turn keeps running.
1694 """
1695 session_id = await self._start_turn(self.HOME, "turn-user")
1696 # The user's turn has already streamed some of its real answer.
1697 await self.adapter.send(self.HOME, "Working on it")
1698 frames_before = len(self.connection.messages)
1699
1700 # A cron delivery fires mid-turn, notify-stamped as every final cron
1701 # delivery is.
1702 result = await self.adapter.send(
1703 self.HOME,
1704 "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
1705 metadata={"notify": True, "job_id": "nightly"},
1706 )
1707
1708 frames = self.connection.messages[frames_before:]
1709 self.assertEqual([frame["type"] for frame in frames], ["home.deliver"])
1710 self.assertTrue(result.success)
1711
1712 # The user's turn is untouched: still active, still owning its stream.
1713 self.assertIn(self.HOME, self.adapter._active_turns)
1714 turn = self.adapter._active_turns[self.HOME]
1715 self.assertEqual(turn.turn_id, "turn-user")
1716 self.assertEqual(turn.visible_text, "Working on it")
1717 self.assertNotIn(
1718 "turn.completed", [frame["type"] for frame in self.connection.messages]
1719 )
1720
1721 # …and it still completes normally on its own notify, inside its own
1722 # session context.
1723 with unittest.mock.patch.object(
1724 adapter_module.T3PlatformAdapter,
1725 "_gateway_session_key",
1726 staticmethod(lambda: session_id),
1727 ):
1728 await self.adapter.send(
1729 self.HOME, "Here is the answer", metadata={"notify": True}
1730 )
1731 self.assertNotIn(self.HOME, self.adapter._active_turns)
1732 self.assertIn(
1733 "turn.completed", [frame["type"] for frame in self.connection.messages]
1734 )
1735
1736 async def test_a_turn_reply_in_home_is_never_rerouted_to_a_delivery(self):
1737 """Output produced inside the turn's session context is turn content."""
1738 session_id = await self._start_turn(self.HOME, "turn-user")
1739 frames_before = len(self.connection.messages)
1740
1741 with unittest.mock.patch.object(
1742 adapter_module.T3PlatformAdapter,
1743 "_gateway_session_key",
1744 staticmethod(lambda: session_id),
1745 ):
1746 await self.adapter.send(self.HOME, "Streaming answer")
1747
1748 types = [frame["type"] for frame in self.connection.messages[frames_before:]]
1749 self.assertEqual(types, ["item.started", "content.delta"])
1750 self.assertNotIn("home.deliver", types)
1751
1752 async def test_an_unclassifiable_send_during_a_live_home_turn_stays_with_it(self):
1753 """The conservative half of the gate.
1754
1755 With a live turn in Home and no positive provenance, the send may well
1756 be that turn's own output arriving from a context where the session key
1757 did not resolve. Routing it to `home.deliver` would tear a real answer
1758 out of the turn; leaving it with the turn is at worst a misplacement
1759 inside the same thread.
1760 """
1761 await self._start_turn(self.HOME, "turn-user")
1762 frames_before = len(self.connection.messages)
1763
1764 await self.adapter.send(self.HOME, "Something unclassifiable")
1765
1766 types = [frame["type"] for frame in self.connection.messages[frames_before:]]
1767 self.assertEqual(types, ["item.started", "content.delta"])
1768 self.assertIn(self.HOME, self.adapter._active_turns)
1769
1770 async def test_a_non_home_thread_without_a_turn_still_errors(self):
1771 """"Message any thread unprompted" stays out of scope."""
1772 frames_before = len(self.connection.messages)
1773
1774 result = await self.adapter.send(
1775 "some-other-thread",
1776 "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
1777 metadata={"notify": True, "job_id": "nightly"},
1778 )
1779
1780 self.assertFalse(result.success)
1781 self.assertEqual(result.error, "no active T3 turn")
1782 self.assertEqual(self.connection.messages[frames_before:], [])
1783
1784 async def test_no_designated_home_means_no_proactive_delivery(self):
1785 """Before the first `connection.accepted` there is nowhere to deliver."""
1786 with unittest.mock.patch.dict(
1787 adapter_module.os.environ, {home_module.HOME_CHANNEL_ENV: ""}
1788 ):
1789 result = await self.adapter.send(self.HOME, "Nowhere to go")
1790 self.assertFalse(result.success)
1791 self.assertEqual(result.error, "no active T3 turn")
1792
1793 async def test_edit_message_has_no_proactive_branch(self):
1794 """A delivery is an atomic document, not a streaming surface."""
1795 result = await self.adapter.edit_message(
1796 self.HOME, "some-message", "Revised delivery", finalize=True
1797 )
1798 self.assertFalse(result.success)
1799 self.assertEqual(result.error, "no active T3 turn")
1800 self.assertEqual(
1801 [frame["type"] for frame in self.connection.messages], []
1802 )
1803
1804 async def test_a_delivery_is_queued_before_it_is_sent_and_purged_on_ack(self):
1805 result = await self.adapter.send(self.HOME, "Queued then acked")
1806 delivery_id = result.message_id
1807 self.assertEqual(
1808 [entry["deliveryId"] for entry in self.queue.entries()], [delivery_id]
1809 )
1810
1811 await self.adapter._handle_server_frame(
1812 {
1813 "type": "home.deliver.ack",
1814 "protocolVersion": 3,
1815 "deliveryId": delivery_id,
1816 }
1817 )
1818 self.assertEqual(self.queue.entries(), [])
1819
1820 async def test_a_delivery_survives_a_dead_socket_and_flushes_on_reconnect(self):
1821 """Offline delivery: nothing is lost across either side restarting."""
1822
1823 class DeadConnection:
1824 connected = False
1825
1826 async def send(self, message):
1827 raise ConnectionError("T3 Code gateway is offline")
1828
1829 self.adapter._connection = DeadConnection()
1830 with self.assertLogs(adapter_module.logger, level="WARNING"):
1831 offline = await self.adapter.send(self.HOME, "Sent while offline")
1832 # Reported successful: it is durably queued and WILL arrive, so a cron
1833 # job must not log a failure for it.
1834 self.assertTrue(offline.success)
1835 self.assertEqual(
1836 [entry["text"] for entry in self.queue.entries()], ["Sent while offline"]
1837 )
1838
1839 # Reconnect: the accepted frame reconciles the designation and flushes.
1840 self.adapter._connection = self.connection
1841 await self.adapter._handle_connection_accepted(
1842 {
1843 "type": "connection.accepted",
1844 "protocolVersion": 3,
1845 "requestId": "hello-1",
1846 "instanceId": "instance",
1847 "nickname": "Hermes",
1848 "homeThreadId": self.HOME,
1849 }
1850 )
1851
1852 flushed = self.connection.messages
1853 self.assertEqual([frame["type"] for frame in flushed], ["home.deliver"])
1854 self.assertEqual(flushed[0]["text"], "Sent while offline")
1855 self.assertEqual(flushed[0]["deliveryId"], offline.message_id)
1856 # Still queued — only the ack purges it.
1857 self.assertEqual(len(self.queue.entries()), 1)
1858
1859 await self.adapter._handle_server_frame(
1860 {
1861 "type": "home.deliver.ack",
1862 "protocolVersion": 3,
1863 "deliveryId": offline.message_id,
1864 }
1865 )
1866 self.assertEqual(self.queue.entries(), [])
1867
1868 async def test_the_queue_flushes_in_fifo_order(self):
1869 class DeadConnection:
1870 connected = False
1871
1872 async def send(self, message):
1873 raise ConnectionError("T3 Code gateway is offline")
1874
1875 self.adapter._connection = DeadConnection()
1876 with self.assertLogs(adapter_module.logger, level="WARNING"):
1877 for text in ("first", "second", "third"):
1878 await self.adapter.send(self.HOME, text)
1879
1880 self.adapter._connection = self.connection
1881 await self.adapter._flush_home_queue()
1882
1883 self.assertEqual(
1884 [frame["text"] for frame in self.connection.messages],
1885 ["first", "second", "third"],
1886 )
1887
1888 async def test_connection_accepted_reconciles_the_home_designation(self):
1889 """T3 owns the designation; a differing local value is overwritten."""
1890 with unittest.mock.patch.dict(
1891 adapter_module.os.environ,
1892 {home_module.HOME_CHANNEL_ENV: "stale-hand-edited-thread"},
1893 ), unittest.mock.patch.object(
1894 adapter_module, "save_home_thread_id"
1895 ) as save:
1896 await self.adapter._handle_connection_accepted(
1897 {
1898 "type": "connection.accepted",
1899 "protocolVersion": 3,
1900 "requestId": "hello-1",
1901 "instanceId": "instance",
1902 "nickname": "Hermes",
1903 "homeThreadId": "authoritative-thread",
1904 }
1905 )
1906 save.assert_called_once_with("authoritative-thread")
1907
1908 async def test_an_accepted_frame_without_a_home_thread_changes_nothing(self):
1909 """Resolving the home thread must never fail a handshake."""
1910 with unittest.mock.patch.object(
1911 adapter_module, "save_home_thread_id"
1912 ) as save:
1913 await self.adapter._handle_connection_accepted(
1914 {
1915 "type": "connection.accepted",
1916 "protocolVersion": 3,
1917 "requestId": "hello-1",
1918 "instanceId": "instance",
1919 "nickname": "Hermes",
1920 }
1921 )
1922 save.assert_not_called()
1923 self.assertEqual(adapter_module.home_thread_id(), self.HOME)
1924
1925 async def test_a_nameless_ack_is_a_correlated_protocol_error(self):
1926 await self.adapter._handle_server_frame(
1927 {"type": "home.deliver.ack", "protocolVersion": 3, "requestId": "ack-1"}
1928 )
1929 reply = self.connection.messages[-1]
1930 self.assertEqual(reply["type"], "protocol.error")
1931 self.assertEqual(reply["code"], "unsupported-message")
1932
1933
1934class EnvEnablementTests(unittest.TestCase):
1935 """`home_channel` is the magic key that makes `get_home_channel` resolve."""
1936
1937 ENROLLED = {
1938 "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws",
1939 "HERMES_T3_GATEWAY_INSTANCE_ID": "instance",
1940 "HERMES_T3_GATEWAY_CREDENTIAL": "credential",
1941 }
1942
1943 def test_a_designated_home_seeds_the_magic_home_channel_key(self):
1944 with unittest.mock.patch.dict(
1945 adapter_module.os.environ,
1946 {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: "home-thread"},
1947 ):
1948 seed = adapter_module.env_enablement()
1949 # Core pops this key and promotes it to a real HomeChannel dataclass
1950 # (gateway/config.py:2648-2660), reading only chat_id/name/thread_id.
1951 # T3 threads are the addressing unit, so chat_id IS the thread id and
1952 # thread_id stays unset.
1953 self.assertEqual(
1954 seed["home_channel"], {"chat_id": "home-thread", "name": "Home"}
1955 )
1956
1957 def test_no_designation_yet_seeds_no_home_channel(self):
1958 with unittest.mock.patch.dict(
1959 adapter_module.os.environ,
1960 {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: ""},
1961 ):
1962 seed = adapter_module.env_enablement()
1963 # The pre-designation window — first connect, before any
1964 # `connection.accepted`. This is exactly why the `/sethome` nudge
1965 # suppression is still needed.
1966 self.assertNotIn("home_channel", seed)
1967 self.assertEqual(seed["instance_id"], "instance")
1968
1969 def test_an_unenrolled_hermes_seeds_nothing_at_all(self):
1970 with unittest.mock.patch.dict(
1971 adapter_module.os.environ,
1972 {**self.ENROLLED, "HERMES_T3_GATEWAY_CREDENTIAL": ""},
1973 ):
1974 self.assertIsNone(adapter_module.env_enablement())
1975
1538 1976
1539if __name__ == "__main__":1977if __name__ == "__main__":
1540 unittest.main()1978 unittest.main()
integrations/hermes-t3-gateway/tests/test_connection.py+9−7
@@ -65,7 +65,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 64 unmodified lines
65 socket = FakeSocket(65 socket = FakeSocket(
66 {66 {
67 "type": "connection.accepted",67 "type": "connection.accepted",
68 "protocolVersion": 2,68 "protocolVersion": 3,
69 "instanceId": "provider-instance",69 "instanceId": "provider-instance",
70 "nickname": "Research",70 "nickname": "Research",
71 "credential": "persistent-secret",71 "credential": "persistent-secret",
@@ -86,7 +86,9 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines
86 socket = FakeSocket(86 socket = FakeSocket(
87 {87 {
88 "type": "connection.accepted",88 "type": "connection.accepted",
89 "protocolVersion": 1,89 # A v2 server: the version policy stays fail-closed across the
90 # v3 bump, so this must not be silently accepted.
91 "protocolVersion": 2,
90 "instanceId": "provider-instance",92 "instanceId": "provider-instance",
91 "nickname": "Research",93 "nickname": "Research",
92 }94 }
@@ -108,7 +110,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 15 unmodified lines
108 "type": "connection.rejected",110 "type": "connection.rejected",
109 "code": "version-incompatible",111 "code": "version-incompatible",
110 "message": "upgrade required",112 "message": "upgrade required",
111 "expectedProtocolVersion": 2,113 "expectedProtocolVersion": 3,
112 }114 }
113 )115 )
114 with self.assertRaises(connection.ConnectionRejected) as raised:116 with self.assertRaises(connection.ConnectionRejected) as raised:
@@ -148,14 +150,14 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 33 unmodified lines
148 json.dumps(150 json.dumps(
149 {151 {
150 "type": "ping",152 "type": "ping",
151 "protocolVersion": 2,153 "protocolVersion": 3,
152 "requestId": "server-ping-1",154 "requestId": "server-ping-1",
153 }155 }
154 ),156 ),
155 json.dumps(157 json.dumps(
156 {158 {
157 "type": "connection.accepted",159 "type": "connection.accepted",
158 "protocolVersion": 2,160 "protocolVersion": 3,
159 "requestId": hello_id,161 "requestId": hello_id,
160 "instanceId": "provider-instance",162 "instanceId": "provider-instance",
161 "nickname": "Hermes",163 "nickname": "Hermes",
@@ -206,7 +208,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 44 unmodified lines
206 return json.dumps(208 return json.dumps(
207 {209 {
208 "type": "connection.accepted",210 "type": "connection.accepted",
209 "protocolVersion": 2,211 "protocolVersion": 3,
210 "requestId": self.sent[0]["requestId"],212 "requestId": self.sent[0]["requestId"],
211 "instanceId": "provider-instance",213 "instanceId": "provider-instance",
212 "nickname": "Hermes",214 "nickname": "Hermes",
@@ -220,7 +222,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 7 unmodified lines
220 async def frames():222 async def frames():
221 yield json.dumps({"type": "turn.start", "requestId": "turn-1"})223 yield json.dumps({"type": "turn.start", "requestId": "turn-1"})
222 yield json.dumps(224 yield json.dumps(
223 {"type": "ping", "protocolVersion": 2, "requestId": "ping-1"}225 {"type": "ping", "protocolVersion": 3, "requestId": "ping-1"}
224 )226 )
225 await released.wait()227 await released.wait()
226 228
integrations/hermes-t3-gateway/tests/test_home.py+473−0
@@ -0,0 +1,473 @@
1from __future__ import annotations
2
3import importlib.util
4import json
5import pathlib
6import sys
7import tempfile
8import types
9import unittest
10import unittest.mock
11
12ROOT = pathlib.Path(__file__).resolve().parents[1]
13PACKAGE = "hermes_t3_gateway_home_test"
14
15# `home.py` depends only on `protocol.py` at import time (`connection.py` is
16# imported lazily inside the standalone sender), so this loader needs none of
17# the fake `gateway.*` modules the adapter tests install.
18package = types.ModuleType(PACKAGE)
19package.__path__ = [str(ROOT)]
20sys.modules.setdefault(PACKAGE, package)
21
22for _name in ("protocol", "connection", "home"):
23 _spec = importlib.util.spec_from_file_location(
24 f"{PACKAGE}.{_name}", ROOT / f"{_name}.py"
25 )
26 assert _spec and _spec.loader
27 _module = importlib.util.module_from_spec(_spec)
28 sys.modules[f"{PACKAGE}.{_name}"] = _module
29 _spec.loader.exec_module(_module)
30
31home = sys.modules[f"{PACKAGE}.home"]
32protocol = sys.modules[f"{PACKAGE}.protocol"]
33connection = sys.modules[f"{PACKAGE}.connection"]
34
35
36def make_delivery(text: str, thread_id: str = "home-thread", **kwargs):
37 return home.build_delivery(thread_id=thread_id, text=text, **kwargs)
38
39
40class QueueTests(unittest.TestCase):
41 def setUp(self):
42 self._tmp = tempfile.TemporaryDirectory()
43 self.addCleanup(self._tmp.cleanup)
44 self.path = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
45 self.queue = home.HomeDeliveryQueue(path=self.path)
46
47 def test_queue_replays_in_fifo_order(self):
48 """Deliveries flush oldest-first, so a Home transcript reads in order."""
49 first = make_delivery("first")
50 second = make_delivery("second")
51 third = make_delivery("third")
52 for entry in (first, second, third):
53 self.assertTrue(self.queue.append(entry))
54
55 self.assertEqual(
56 [entry["text"] for entry in self.queue.entries()],
57 ["first", "second", "third"],
58 )
59
60 def test_an_entry_is_purged_only_by_its_own_ack(self):
61 """The durability guarantee: nothing leaves the queue without an ack.
62
63 T3 acks only after a durable write, so purging on anything else — a
64 successful `send()`, a reconnect, a flush — would drop deliveries the
65 server never actually stored.
66 """
67 first = make_delivery("first")
68 second = make_delivery("second")
69 self.queue.append(first)
70 self.queue.append(second)
71
72 # Sending, flushing, or re-reading changes nothing.
73 self.assertEqual(len(self.queue.entries()), 2)
74 self.assertEqual(len(self.queue.entries()), 2)
75
76 # An unknown id purges nothing at all.
77 self.assertFalse(self.queue.purge("never-sent"))
78 self.assertEqual(len(self.queue.entries()), 2)
79
80 self.assertTrue(self.queue.purge(first["deliveryId"]))
81 self.assertEqual(
82 [entry["deliveryId"] for entry in self.queue.entries()],
83 [second["deliveryId"]],
84 )
85
86 # A duplicate ack (a replayed flush T3 deduped) is inert.
87 self.assertFalse(self.queue.purge(first["deliveryId"]))
88 self.assertEqual(len(self.queue.entries()), 1)
89
90 def test_the_queue_is_capped_and_drops_the_oldest(self):
91 """A wedged queue must not grow without bound — and must not go stale.
92
93 Dropping newest would make an over-full queue permanently swallow
94 current output; dropping oldest loses the least valuable entries and
95 keeps today's cron brief.
96 """
97 queue = home.HomeDeliveryQueue(path=self.path, max_entries=3)
98 deliveries = [make_delivery(f"delivery-{index}") for index in range(5)]
99 with self.assertLogs(home.logger, level="WARNING") as captured:
100 for entry in deliveries:
101 queue.append(entry)
102
103 self.assertTrue(
104 any("queue is full" in line for line in captured.output),
105 captured.output,
106 )
107 self.assertEqual(
108 [entry["text"] for entry in queue.entries()],
109 ["delivery-2", "delivery-3", "delivery-4"],
110 )
111
112 def test_appending_the_same_delivery_twice_is_idempotent(self):
113 """A retried standalone send must not double-queue its own payload."""
114 entry = make_delivery("once")
115 self.assertTrue(self.queue.append(entry))
116 self.assertTrue(self.queue.append(dict(entry)))
117 self.assertEqual(len(self.queue.entries()), 1)
118
119 def test_a_torn_line_does_not_discard_the_whole_queue(self):
120 """A process killed mid-write must cost one entry, not the outbox."""
121 good = make_delivery("survivor")
122 self.queue.append(good)
123 with self.path.open("a", encoding="utf-8") as handle:
124 handle.write('{"deliveryId": "truncated"\n')
125
126 self.assertEqual(
127 [entry["deliveryId"] for entry in self.queue.entries()],
128 [good["deliveryId"]],
129 )
130
131 def test_the_queue_survives_a_process_restart(self):
132 """Durability across a plugin restart is the entire point of the file."""
133 entry = make_delivery("across-restart")
134 self.queue.append(entry)
135
136 reopened = home.HomeDeliveryQueue(path=self.path)
137 self.assertEqual(
138 [item["deliveryId"] for item in reopened.entries()],
139 [entry["deliveryId"]],
140 )
141
142 def test_the_queue_lives_under_the_active_hermes_home(self):
143 """Profile-scoped: a second profile must not replay the first's output."""
144 with unittest.mock.patch.object(
145 home, "hermes_home", return_value=pathlib.Path("/tmp/profile-b")
146 ):
147 self.assertEqual(
148 home.queue_path(),
149 pathlib.Path("/tmp/profile-b/gateway/t3_home_delivery_queue.jsonl"),
150 )
151
152
153class ClassificationTests(unittest.TestCase):
154 def test_the_cron_job_id_metadata_is_the_strongest_signal(self):
155 kind, label, certain = home.classify_delivery(
156 "Anything at all", {"job_id": "daily-digest", "notify": True}
157 )
158 self.assertEqual(kind, "cron")
159 self.assertEqual(label, "Cron: daily-digest")
160 self.assertTrue(certain)
161
162 def test_the_cron_wrap_header_supplies_a_human_job_name(self):
163 content = (
164 "Cronjob Response: Morning digest\n"
165 "(job_id: abc123)\n"
166 "-------------\n\n"
167 "Three things happened.\n"
168 )
169 kind, label, certain = home.classify_delivery(content, None)
170 self.assertEqual(kind, "cron")
171 self.assertEqual(label, "Cron: Morning digest")
172 self.assertTrue(certain)
173
174 def test_gateway_lifecycle_notices_land_quietly(self):
175 for content in (
176 "♻️ Gateway online — Hermes is back and ready.",
177 "♻ Gateway restarted successfully. Your session continues.",
178 "⚠️ Gateway shutting down — Your current task will be interrupted.",
179 "⚠️ Gateway restarting — Your current task will be interrupted. "
180 "Send any message after restart and I'll try to resume where you "
181 "left off.",
182 ):
183 with self.subTest(content=content[:32]):
184 kind, label, certain = home.classify_delivery(content, None)
185 self.assertEqual(kind, "lifecycle")
186 self.assertEqual(label, "Gateway")
187 self.assertTrue(certain)
188
189 def test_a_handoff_is_recognised_from_its_synthetic_session_identity(self):
190 kind, label, certain = home.classify_delivery(
191 "Picking up where the CLI left off.",
192 None,
193 session_user_id="system:handoff",
194 )
195 self.assertEqual(kind, "handoff")
196 self.assertEqual(label, "Handoff")
197 self.assertTrue(certain)
198
199 def test_an_unrecognised_send_defaults_to_an_uncertain_message(self):
200 """Worst case is a wrong badge — and, in the live-turn window, a send
201 that stays with the turn rather than being torn out of it."""
202 kind, label, certain = home.classify_delivery("Just checking in.", None)
203 self.assertEqual(kind, "message")
204 self.assertEqual(label, "Hermes")
205 self.assertFalse(certain)
206
207
208class FrameTests(unittest.TestCase):
209 def test_home_deliver_applies_every_wire_bound(self):
210 frame = protocol.home_deliver(
211 delivery_id_value="delivery-1",
212 thread_id="home-thread",
213 kind="cron",
214 label=" " + "L" * 400 + " ",
215 text="x" * (protocol.MAX_HOME_DELIVERY_TEXT_CHARS + 500),
216 created_at="2026-07-26T00:00:00Z",
217 )
218 self.assertEqual(frame["type"], "home.deliver")
219 self.assertEqual(frame["protocolVersion"], 3)
220 self.assertEqual(frame["deliveryId"], "delivery-1")
221 self.assertEqual(frame["threadId"], "home-thread")
222 self.assertEqual(frame["kind"], "cron")
223 self.assertEqual(len(frame["label"]), protocol.MAX_HOME_DELIVERY_LABEL_CHARS)
224 self.assertEqual(
225 len(frame["text"]), protocol.MAX_HOME_DELIVERY_TEXT_CHARS
226 )
227 self.assertEqual(frame["createdAt"], "2026-07-26T00:00:00Z")
228
229 def test_home_deliver_never_emits_an_invalid_kind_or_empty_label(self):
230 frame = protocol.home_deliver(
231 delivery_id_value="delivery-2",
232 thread_id="home-thread",
233 kind="not-a-kind",
234 label=" ",
235 text="",
236 )
237 # A misclassification must cost a badge, never a server rejection of a
238 # delivery the plugin has already queued.
239 self.assertEqual(frame["kind"], "other")
240 self.assertEqual(frame["label"], "Hermes")
241 self.assertTrue(len(frame["text"]) >= 1)
242
243 def test_home_deliver_ack_is_an_accepted_server_command(self):
244 message = {"type": "home.deliver.ack", "protocolVersion": 3}
245 self.assertEqual(protocol.validate_server_frame(message), message)
246
247 def test_hello_declares_its_connection_role(self):
248 gateway = protocol.connection_hello(
249 hermes_version="0.19.0",
250 authentication={"type": "instance-credential"},
251 )
252 self.assertEqual(gateway["role"], "gateway")
253 delivery = protocol.connection_hello(
254 hermes_version="0.19.0",
255 authentication={"type": "instance-credential"},
256 role="delivery",
257 )
258 self.assertEqual(delivery["role"], "delivery")
259 # An unknown role must never silently become "delivery" — the safe
260 # default is the ordinary connection.
261 unknown = protocol.connection_hello(
262 hermes_version="0.19.0",
263 authentication={"type": "instance-credential"},
264 role="nonsense",
265 )
266 self.assertEqual(unknown["role"], "gateway")
267
268
269class MockDeliveryServer:
270 """A T3 stand-in for the standalone sender's short-lived socket."""
271
272 def __init__(self, *, ack: bool = True):
273 self.ack = ack
274 self.sent: list[dict] = []
275 self.closed = False
276 self._outbox: list[str] = []
277
278 async def send(self, raw):
279 message = json.loads(raw)
280 self.sent.append(message)
281 if message.get("type") == "connection.hello":
282 self._outbox.append(
283 json.dumps(
284 {
285 "type": "connection.accepted",
286 "protocolVersion": protocol.PROTOCOL_VERSION,
287 "requestId": message["requestId"],
288 "instanceId": "provider-instance",
289 "nickname": "Hermes",
290 "homeThreadId": "home-thread",
291 }
292 )
293 )
294 elif message.get("type") == "home.deliver" and self.ack:
295 self._outbox.append(
296 json.dumps(
297 {
298 "type": "home.deliver.ack",
299 "protocolVersion": protocol.PROTOCOL_VERSION,
300 "deliveryId": message["deliveryId"],
301 }
302 )
303 )
304
305 async def recv(self):
306 if not self._outbox:
307 raise AssertionError("the mock server was polled with nothing to send")
308 return self._outbox.pop(0)
309
310 async def close(self):
311 self.closed = True
312
313
314class StandaloneSenderTests(unittest.IsolatedAsyncioTestCase):
315 def setUp(self):
316 self._tmp = tempfile.TemporaryDirectory()
317 self.addCleanup(self._tmp.cleanup)
318 self.queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl"
319 patch = unittest.mock.patch.object(
320 home, "hermes_home", return_value=pathlib.Path(self._tmp.name)
321 )
322 patch.start()
323 self.addCleanup(patch.stop)
324 self.environment = unittest.mock.patch.dict(
325 home.os.environ,
326 {
327 "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws",
328 "HERMES_T3_GATEWAY_INSTANCE_ID": "provider-instance",
329 "HERMES_T3_GATEWAY_CREDENTIAL": "secret",
330 home.HOME_CHANNEL_ENV: "home-thread",
331 },
332 )
333 self.environment.start()
334 self.addCleanup(self.environment.stop)
335
336 def _serve(self, server):
337 async def _open(_url):
338 return server
339
340 return unittest.mock.patch.object(connection, "_open_socket", _open)
341
342 async def test_standalone_send_hellos_as_delivery_then_acks_and_closes(self):
343 """The full out-of-process cron path.
344
345 `role: "delivery"` is load-bearing: T3's broker registers a `gateway`
346 connection under generation fencing and displaces its predecessor, so a
347 cron dial-in announcing the default role would kick the live gateway
348 socket off its own instance mid-turn.
349 """
350 server = MockDeliveryServer()
351 with self._serve(server):
352 result = await home.standalone_send(
353 None,
354 "home-thread",
355 "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.",
356 )
357
358 self.assertTrue(result["success"])
359 self.assertEqual(
360 [message["type"] for message in server.sent],
361 ["connection.hello", "home.deliver"],
362 )
363 hello, delivery = server.sent
364 self.assertEqual(hello["role"], "delivery")
365 self.assertEqual(hello["protocolVersion"], 3)
366 self.assertEqual(
367 hello["authentication"],
368 {
369 "type": "instance-credential",
370 "instanceId": "provider-instance",
371 "credential": "secret",
372 },
373 )
374 self.assertEqual(delivery["threadId"], "home-thread")
375 self.assertEqual(delivery["kind"], "cron")
376 self.assertEqual(delivery["label"], "Cron: nightly")
377 self.assertEqual(result["message_id"], delivery["deliveryId"])
378 self.assertTrue(server.closed, "the delivery socket must not linger")
379
380 # Acked, so nothing is left queued for the live gateway to replay.
381 self.assertEqual(home.HomeDeliveryQueue().entries(), [])
382
383 async def test_an_unreachable_t3_queues_rather_than_failing_the_cron_job(self):
384 """A cron job must not report failure for output that will arrive."""
385
386 async def _refuse(_url):
387 raise ConnectionRefusedError("T3 is down")
388
389 with unittest.mock.patch.object(connection, "_open_socket", _refuse):
390 result = await home.standalone_send(None, "home-thread", "Nightly brief")
391
392 self.assertTrue(result["success"])
393 self.assertTrue(result["queued"])
394 queued = home.HomeDeliveryQueue().entries()
395 self.assertEqual([entry["text"] for entry in queued], ["Nightly brief"])
396 self.assertEqual(queued[0]["deliveryId"], result["message_id"])
397
398 async def test_an_unacknowledged_delivery_stays_queued(self):
399 """No ack, no purge — the live gateway retries it on the next connect."""
400 server = MockDeliveryServer(ack=False)
401 with self._serve(server), unittest.mock.patch.object(
402 home, "_deliver_over_short_lived_socket", return_value=False
403 ):
404 result = await home.standalone_send(None, "home-thread", "Unacked brief")
405
406 self.assertTrue(result["success"])
407 self.assertTrue(result["queued"])
408 self.assertEqual(
409 [entry["text"] for entry in home.HomeDeliveryQueue().entries()],
410 ["Unacked brief"],
411 )
412
413 async def test_standalone_send_refuses_when_hermes_is_not_enrolled(self):
414 with unittest.mock.patch.dict(
415 home.os.environ, {"HERMES_T3_GATEWAY_CREDENTIAL": ""}
416 ):
417 result = await home.standalone_send(None, "home-thread", "Brief")
418 self.assertIn("not enrolled", result["error"])
419
420 async def test_standalone_send_falls_back_to_the_designated_home_thread(self):
421 server = MockDeliveryServer()
422 with self._serve(server):
423 result = await home.standalone_send(None, "", "Brief with no chat id")
424 self.assertTrue(result["success"])
425 self.assertEqual(server.sent[1]["threadId"], "home-thread")
426
427
428class DesignationTests(unittest.TestCase):
429 def test_saving_the_designation_mirrors_it_into_this_process(self):
430 """The running gateway must not need a restart to see a new home."""
431 saved = {}
432
433 config = types.ModuleType("hermes_cli.config")
434 config.save_env_value = lambda key, value: saved.__setitem__(key, value)
435 package = types.ModuleType("hermes_cli")
436 package.__path__ = []
437 with unittest.mock.patch.dict(
438 sys.modules, {"hermes_cli": package, "hermes_cli.config": config}
439 ), unittest.mock.patch.dict(home.os.environ, {}, clear=False):
440 self.assertTrue(home.save_home_thread_id("thread-home"))
441 self.assertEqual(saved, {home.HOME_CHANNEL_ENV: "thread-home"})
442 self.assertEqual(home.home_thread_id(), "thread-home")
443
444 def test_an_unwritable_env_still_routes_for_this_process(self):
445 """A managed or read-only `.env` degrades; it must not break routing."""
446
447 def refuse(key, value):
448 raise PermissionError("managed .env")
449
450 config = types.ModuleType("hermes_cli.config")
451 config.save_env_value = refuse
452 package = types.ModuleType("hermes_cli")
453 package.__path__ = []
454 with unittest.mock.patch.dict(
455 sys.modules, {"hermes_cli": package, "hermes_cli.config": config}
456 ), unittest.mock.patch.dict(
457 home.os.environ, {}, clear=False
458 ), self.assertLogs(home.logger, level="WARNING"):
459 self.assertFalse(home.save_home_thread_id("thread-home"))
460 # Not durable, but routable: this gateway delivers to the right
461 # thread until it restarts, and re-reconciles on the next connect.
462 self.assertEqual(home.home_thread_id(), "thread-home")
463
464 def test_an_empty_designation_is_ignored(self):
465 with unittest.mock.patch.dict(
466 home.os.environ, {home.HOME_CHANNEL_ENV: "keep-me"}
467 ):
468 self.assertFalse(home.save_home_thread_id(" "))
469 self.assertEqual(home.home_thread_id(), "keep-me")
470
471
472if __name__ == "__main__":
473 unittest.main()
integrations/hermes-t3-gateway/tests/test_protocol.py+8−8
@@ -66,7 +66,7 @@ def fake_hermes_skills(skills_list=None, skill_view=None): · 65 unmodified lines
66 66
67 67
68class ProtocolTests(unittest.TestCase):68class ProtocolTests(unittest.TestCase):
69 def test_hello_matches_v2_contract(self):69 def test_hello_matches_v3_contract(self):
70 hello = protocol.connection_hello(70 hello = protocol.connection_hello(
71 hermes_version="0.19.0",71 hermes_version="0.19.0",
72 authentication={"type": "enrollment-token", "token": "once"},72 authentication={"type": "enrollment-token", "token": "once"},
@@ -75,7 +75,7 @@ class ProtocolTests(unittest.TestCase): · 2 unmodified lines
75 )75 )
76 self.assertEqual(hello["type"], "connection.hello")76 self.assertEqual(hello["type"], "connection.hello")
77 self.assertEqual(hello["requestId"], "request-1")77 self.assertEqual(hello["requestId"], "request-1")
78 self.assertEqual(hello["protocolVersion"], 2)78 self.assertEqual(hello["protocolVersion"], 3)
79 self.assertFalse(hello["capabilities"]["attachments"])79 self.assertFalse(hello["capabilities"]["attachments"])
80 self.assertTrue(hello["capabilities"]["streaming"])80 self.assertTrue(hello["capabilities"]["streaming"])
81 self.assertEqual(hello["model"], "gpt-5.6-terra")81 self.assertEqual(hello["model"], "gpt-5.6-terra")
@@ -125,15 +125,15 @@ class ProtocolTests(unittest.TestCase): · 43 unmodified lines
125 125
126 def test_server_frame_validation_is_closed(self):126 def test_server_frame_validation_is_closed(self):
127 with self.assertRaisesRegex(ValueError, "unsupported"):127 with self.assertRaisesRegex(ValueError, "unsupported"):
128 protocol.validate_server_frame({"type": "made.up", "protocolVersion": 2})128 protocol.validate_server_frame({"type": "made.up", "protocolVersion": 3})
129 with self.assertRaisesRegex(ValueError, "version"):129 with self.assertRaisesRegex(ValueError, "version"):
130 # Protocol v1 peers must upgrade before sending runtime frames.130 # Protocol v2 peers must upgrade before sending runtime frames.
131 protocol.validate_server_frame({"type": "ping", "protocolVersion": 1})131 protocol.validate_server_frame({"type": "ping", "protocolVersion": 2})
132 132
133 def test_describe_frames_are_accepted_server_commands(self):133 def test_describe_frames_are_accepted_server_commands(self):
134 for frame_type in ("describe.request", "skill.body.request"):134 for frame_type in ("describe.request", "skill.body.request"):
135 with self.subTest(frame_type=frame_type):135 with self.subTest(frame_type=frame_type):
136 message = {"type": frame_type, "protocolVersion": 2}136 message = {"type": frame_type, "protocolVersion": 3}
137 self.assertEqual(protocol.validate_server_frame(message), message)137 self.assertEqual(protocol.validate_server_frame(message), message)
138 138
139 # ── describe.response ──────────────────────────────────────────────139 # ── describe.response ──────────────────────────────────────────────
@@ -151,7 +151,7 @@ class ProtocolTests(unittest.TestCase): · 11 unmodified lines
151 )151 )
152 self.assertEqual(response["type"], "describe.response")152 self.assertEqual(response["type"], "describe.response")
153 self.assertEqual(response["requestId"], "describe-1")153 self.assertEqual(response["requestId"], "describe-1")
154 self.assertEqual(response["protocolVersion"], 2)154 self.assertEqual(response["protocolVersion"], 3)
155 self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION)155 self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION)
156 self.assertEqual(response["hermesVersion"], "0.19.0")156 self.assertEqual(response["hermesVersion"], "0.19.0")
157 self.assertEqual(response["model"], "gpt-5.6-terra")157 self.assertEqual(response["model"], "gpt-5.6-terra")
@@ -282,7 +282,7 @@ class ProtocolTests(unittest.TestCase): · 124 unmodified lines
282 )282 )
283 self.assertEqual(response["type"], "skill.body.response")283 self.assertEqual(response["type"], "skill.body.response")
284 self.assertEqual(response["requestId"], "body-1")284 self.assertEqual(response["requestId"], "body-1")
285 self.assertEqual(response["protocolVersion"], 2)285 self.assertEqual(response["protocolVersion"], 3)
286 self.assertEqual(response["skillName"], "codex")286 self.assertEqual(response["skillName"], "codex")
287 self.assertEqual(response["markdown"], "# Codex\n\nDelegate coding.")287 self.assertEqual(response["markdown"], "# Codex\n\nDelegate coding.")
288 288
packages/client-runtime/src/state/threadReducer.test.ts+82−0
@@ -444,6 +444,88 @@ describe("applyThreadDetailEvent", () => { · 443 unmodified lines
444 });444 });
445 });445 });
446 446
447 describe("thread.notification-delivered", () => {
448 const deliveryEvent = {
449 ...baseEventFields,
450 sequence: 20,
451 occurredAt: "2026-04-01T07:00:00.000Z",
452 aggregateKind: "thread",
453 aggregateId: ThreadId.make("thread-1"),
454 type: "thread.notification-delivered",
455 payload: {
456 threadId: ThreadId.make("thread-1"),
457 messageId: MessageId.make("msg-notify"),
458 text: "Digest ready.",
459 notification: {
460 kind: "cron",
461 label: "Cron: daily-digest",
462 deliveryId: "delivery-1",
463 },
464 createdAt: "2026-04-01T07:00:00.000Z",
465 updatedAt: "2026-04-01T07:00:00.000Z",
466 },
467 } as const;
468
469 it("appends the delivery as a turnless assistant message carrying provenance", () => {
470 const result = applyThreadDetailEvent(baseThread, deliveryEvent);
471
472 expect(result.kind).toBe("updated");
473 if (result.kind === "updated") {
474 expect(result.thread.messages).toHaveLength(1);
475 expect(result.thread.messages[0]).toEqual({
476 id: "msg-notify",
477 role: "assistant",
478 text: "Digest ready.",
479 turnId: null,
480 streaming: false,
481 notification: {
482 kind: "cron",
483 label: "Cron: daily-digest",
484 deliveryId: "delivery-1",
485 },
486 createdAt: "2026-04-01T07:00:00.000Z",
487 updatedAt: "2026-04-01T07:00:00.000Z",
488 });
489 expect(result.thread.updatedAt).toBe("2026-04-01T07:00:00.000Z");
490 }
491 });
492
493 it("leaves an unrelated live turn untouched", () => {
494 // A delivery can land mid-turn; attributing it to that turn would fold
495 // it away behind the turn's summary and mis-settle the turn state.
496 const threadWithRunningTurn: OrchestrationThread = {
497 ...baseThread,
498 latestTurn: {
499 turnId: TurnId.make("turn-1"),
500 state: "running",
501 requestedAt: "2026-04-01T06:00:00.000Z",
502 startedAt: "2026-04-01T06:00:00.000Z",
503 completedAt: null,
504 assistantMessageId: null,
505 },
506 };
507
508 const result = applyThreadDetailEvent(threadWithRunningTurn, deliveryEvent);
509 expect(result.kind).toBe("updated");
510 if (result.kind === "updated") {
511 expect(result.thread.latestTurn).toEqual(threadWithRunningTurn.latestTurn);
512 }
513 });
514
515 it("replaces rather than duplicates on a replayed delivery", () => {
516 const first = applyThreadDetailEvent(baseThread, deliveryEvent);
517 expect(first.kind).toBe("updated");
518 if (first.kind !== "updated") return;
519
520 const second = applyThreadDetailEvent(first.thread, { ...deliveryEvent, sequence: 21 });
521 expect(second.kind).toBe("updated");
522 if (second.kind === "updated") {
523 expect(second.thread.messages).toHaveLength(1);
524 expect(second.thread.messages[0]).toEqual(first.thread.messages[0]);
525 }
526 });
527 });
528
447 describe("thread.session-set", () => {529 describe("thread.session-set", () => {
448 it("settles a running latestTurn when the session leaves the running status", () => {530 it("settles a running latestTurn when the session leaves the running status", () => {
449 const threadWithRunningTurn: OrchestrationThread = {531 const threadWithRunningTurn: OrchestrationThread = {
packages/client-runtime/src/state/threadReducer.ts+30−0
@@ -325,6 +325,36 @@ export function applyThreadDetailEvent( · 324 unmodified lines
325 };325 };
326 }326 }
327 327
328 case "thread.notification-delivered": {
329 // A proactive delivery is an ordinary transcript message carrying
330 // provenance. It binds to no turn, so latestTurn and the checkpoint
331 // rebinding that `thread.message-sent` performs are deliberately
332 // untouched — attributing it to the live turn would fold it away
333 // behind that turn's summary.
334 const message: OrchestrationMessage = {
335 id: event.payload.messageId,
336 role: "assistant",
337 text: event.payload.text,
338 turnId: null,
339 streaming: false,
340 notification: event.payload.notification,
341 createdAt: event.payload.createdAt,
342 updatedAt: event.payload.updatedAt,
343 };
344
345 // Replays re-emit the persisted row verbatim, so replacing in place
346 // keeps the transcript stable instead of duplicating the delivery.
347 const existingMessage = thread.messages.find((entry) => entry.id === message.id);
348 const messages = existingMessage
349 ? Arr.map(thread.messages, (entry) => (entry.id === message.id ? message : entry))
350 : Arr.append(thread.messages, message);
351
352 return {
353 kind: "updated",
354 thread: { ...thread, messages, updatedAt: event.occurredAt },
355 };
356 }
357
328 // ── Session ─────────────────────────────────────────────────────358 // ── Session ─────────────────────────────────────────────────────
329 case "thread.session-set": {359 case "thread.session-set": {
330 // Leaving the "running" session status is the turn-end signal: settle a360 // Leaving the "running" session status is the turn-end signal: settle a
packages/client-runtime/src/state/threadSort.test.ts+40−0
@@ -68,4 +68,44 @@ describe("sortThreads", () => { · 67 unmodified lines
68 68
69 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);69 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
70 });70 });
71
72 it("pins the requested thread first regardless of its timestamp", () => {
73 // The agent home thread: a designated destination, not a conversation the
74 // user started, so staleness must not demote it.
75 const sorted = sortThreads(
76 [
77 makeThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
78 makeThread({ id: "thread-home", updatedAt: "2026-01-01T00:00:00.000Z" }),
79 makeThread({ id: "thread-middle", updatedAt: "2026-03-09T10:00:00.000Z" }),
80 ],
81 "updated_at",
82 "thread-home",
83 );
84
85 expect(sorted.map((thread) => thread.id)).toEqual([
86 "thread-home",
87 "thread-newest",
88 "thread-middle",
89 ]);
90 });
91
92 it("sorts unchanged when no thread is pinned or the pinned id is unknown", () => {
93 const threads = [
94 makeThread({ id: "thread-newest", updatedAt: "2026-03-09T12:00:00.000Z" }),
95 makeThread({ id: "thread-oldest", updatedAt: "2026-01-01T00:00:00.000Z" }),
96 ];
97
98 expect(sortThreads(threads, "updated_at").map((thread) => thread.id)).toEqual([
99 "thread-newest",
100 "thread-oldest",
101 ]);
102 expect(sortThreads(threads, "updated_at", null).map((thread) => thread.id)).toEqual([
103 "thread-newest",
104 "thread-oldest",
105 ]);
106 expect(sortThreads(threads, "updated_at", "thread-gone").map((thread) => thread.id)).toEqual([
107 "thread-newest",
108 "thread-oldest",
109 ]);
110 });
71});111});
packages/client-runtime/src/state/threadSort.ts+17−0
@@ -69,18 +69,35 @@ export function getThreadSortTimestamp( · 68 unmodified lines
69 return getLatestUserMessageTimestamp(thread);69 return getLatestUserMessageTimestamp(thread);
70}70}
71 71
72/**
73 * Sort threads for a list, optionally pinning one thread to the top.
74 *
75 * `pinnedThreadId` exists for the agent home thread: it is a designated
76 * destination rather than a conversation the user started, so it must hold
77 * position one no matter how stale it looks — an agent whose last cron ran a
78 * month ago still has to be the first row of its instance. The pin is a
79 * leading sort key rather than a splice so a single pass produces the whole
80 * ordering, and every downstream consumer of that ordering (preview slice,
81 * keyboard traversal, prewarming) inherits it for free.
82 *
83 * A pinned id that matches no thread changes nothing.
84 */
72export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(85export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
73 threads: readonly T[],86 threads: readonly T[],
74 sortOrder: SidebarThreadSortOrder,87 sortOrder: SidebarThreadSortOrder,
88 pinnedThreadId?: string | null,
75): T[] {89): T[] {
76 return Arr.sort(90 return Arr.sort(
77 threads,91 threads,
78 Order.mapInput(92 Order.mapInput(
79 Order.Struct({93 Order.Struct({
94 // `Order.Boolean` puts `false` first; flipped, the pinned row leads.
95 pinned: Order.flip(Order.Boolean),
80 timestamp: Order.flip(Order.Number),96 timestamp: Order.flip(Order.Number),
81 id: Order.flip(Order.String),97 id: Order.flip(Order.String),
82 }),98 }),
83 (thread: T) => ({99 (thread: T) => ({
100 pinned: pinnedThreadId != null && thread.id === pinnedThreadId,
84 timestamp: getThreadSortTimestamp(thread, sortOrder),101 timestamp: getThreadSortTimestamp(thread, sortOrder),
85 id: thread.id,102 id: thread.id,
86 }),103 }),
packages/contracts/src/hermesGateway.test.ts+113−20
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 PROVIDER_DISPLAY_NAMES,8 PROVIDER_DISPLAY_NAMES,
9} from "./model.ts";9} from "./model.ts";
10import {10import {
11 HERMES_GATEWAY_PROTOCOL_VERSION,
11 HermesGatewayCapabilities,12 HermesGatewayCapabilities,
12 HermesGatewayConnectionHello,13 HermesGatewayConnectionHello,
13 HermesGatewayCreateEnrollmentInput,14 HermesGatewayCreateEnrollmentInput,
@@ -72,9 +73,9 @@ describe("Hermes gateway management contracts", () => { · 58 unmodified lines
72 model: "gpt-5.6-terra",73 model: "gpt-5.6-terra",
73 connectionGeneration: 3,74 connectionGeneration: 3,
74 activeSessionCount: 2,75 activeSessionCount: 2,
75 protocolVersion: 2,76 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
76 capabilities: {77 capabilities: {
77 protocolVersion: 2,78 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
78 streaming: true,79 streaming: true,
79 activity: true,80 activity: true,
80 approvals: true,81 approvals: true,
@@ -107,11 +108,11 @@ describe("Hermes gateway handshake", () => { · 26 unmodified lines
107 const hello = decodeHello({108 const hello = decodeHello({
108 type: "connection.hello",109 type: "connection.hello",
109 requestId: "hello-1",110 requestId: "hello-1",
110 protocolVersion: 2,111 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
111 pluginVersion: "0.2.0",112 pluginVersion: "0.2.0",
112 hermesVersion: "1.2.3",113 hermesVersion: "1.2.3",
113 capabilities: {114 capabilities: {
114 protocolVersion: 2,115 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
115 streaming: true,116 streaming: true,
116 activity: true,117 activity: true,
117 approvals: true,118 approvals: true,
@@ -131,11 +132,11 @@ describe("Hermes gateway handshake", () => { · 13 unmodified lines
131 const hello = decodeHello({132 const hello = decodeHello({
132 type: "connection.hello",133 type: "connection.hello",
133 requestId: "hello-2",134 requestId: "hello-2",
134 protocolVersion: 2,135 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
135 pluginVersion: "0.2.0",136 pluginVersion: "0.2.0",
136 hermesVersion: "1.2.3",137 hermesVersion: "1.2.3",
137 capabilities: {138 capabilities: {
138 protocolVersion: 2,139 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
139 streaming: true,140 streaming: true,
140 activity: true,141 activity: true,
141 approvals: true,142 approvals: true,
@@ -161,11 +162,11 @@ describe("Hermes gateway handshake", () => { · 19 unmodified lines
161 const hello = decodeHello({162 const hello = decodeHello({
162 type: "connection.hello",163 type: "connection.hello",
163 requestId: "hello-no-model",164 requestId: "hello-no-model",
164 protocolVersion: 2,165 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
165 pluginVersion: "0.2.0",166 pluginVersion: "0.2.0",
166 hermesVersion: "1.2.3",167 hermesVersion: "1.2.3",
167 capabilities: {168 capabilities: {
168 protocolVersion: 2,169 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
169 streaming: true,170 streaming: true,
170 activity: true,171 activity: true,
171 approvals: true,172 approvals: true,
@@ -210,7 +211,7 @@ describe("Hermes gateway handshake", () => { · 38 unmodified lines
210 it("reserves attachments for a future protocol version", () => {211 it("reserves attachments for a future protocol version", () => {
211 expect(() =>212 expect(() =>
212 decodeCapabilities({213 decodeCapabilities({
213 protocolVersion: 2,214 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
214 streaming: true,215 streaming: true,
215 activity: true,216 activity: true,
216 approvals: true,217 approvals: true,
@@ -221,12 +222,12 @@ describe("Hermes gateway handshake", () => { · 4 unmodified lines
221 });222 });
222});223});
223 224
224describe("T3 to Hermes v2 messages", () => {225describe("T3 to Hermes messages", () => {
225 it("decodes session creation and opaque resume cursors", () => {226 it("decodes session creation and opaque resume cursors", () => {
226 expect(227 expect(
227 decodeT3Message({228 decodeT3Message({
228 type: "session.ensure",229 type: "session.ensure",
229 protocolVersion: 2,230 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
230 requestId: "ensure-1",231 requestId: "ensure-1",
231 threadId: "thread-1",232 threadId: "thread-1",
232 resumeSessionId: "opaque/hermes/session/value",233 resumeSessionId: "opaque/hermes/session/value",
@@ -235,7 +236,7 @@ describe("T3 to Hermes v2 messages", () => { · 2 unmodified lines
235 236
236 expect(237 expect(
237 decodeResumeCursor({238 decodeResumeCursor({
238 protocolVersion: 2,239 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
239 sessionId: "opaque/hermes/session/value",240 sessionId: "opaque/hermes/session/value",
240 }).sessionId,241 }).sessionId,
241 ).toBe("opaque/hermes/session/value");242 ).toBe("opaque/hermes/session/value");
@@ -243,7 +244,7 @@ describe("T3 to Hermes v2 messages", () => { · 1 unmodified line
243 244
244 it("decodes start and steering as distinct turn operations", () => {245 it("decodes start and steering as distinct turn operations", () => {
245 const context = {246 const context = {
246 protocolVersion: 2,247 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
247 requestId: "turn-command-1",248 requestId: "turn-command-1",
248 threadId: "thread-1",249 threadId: "thread-1",
249 sessionId: "session-1",250 sessionId: "session-1",
@@ -257,7 +258,7 @@ describe("T3 to Hermes v2 messages", () => { · 7 unmodified lines
257 258
258 it("decodes interrupt, approval, structured input, stop, and ping", () => {259 it("decodes interrupt, approval, structured input, stop, and ping", () => {
259 const turnContext = {260 const turnContext = {
260 protocolVersion: 2,261 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
261 threadId: "thread-1",262 threadId: "thread-1",
262 sessionId: "session-1",263 sessionId: "session-1",
263 turnId: "turn-1",264 turnId: "turn-1",
@@ -289,7 +290,7 @@ describe("T3 to Hermes v2 messages", () => { · 25 unmodified lines
289 expect(290 expect(
290 decodeT3Message({291 decodeT3Message({
291 type: "session.stop",292 type: "session.stop",
292 protocolVersion: 2,293 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
293 requestId: "stop-1",294 requestId: "stop-1",
294 threadId: "thread-1",295 threadId: "thread-1",
295 sessionId: "session-1",296 sessionId: "session-1",
@@ -298,7 +299,7 @@ describe("T3 to Hermes v2 messages", () => { · 2 unmodified lines
298 expect(299 expect(
299 decodeT3Message({300 decodeT3Message({
300 type: "ping",301 type: "ping",
301 protocolVersion: 2,302 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
302 requestId: "ping-1",303 requestId: "ping-1",
303 sentAt: "2026-07-23T12:00:00.000Z",304 sentAt: "2026-07-23T12:00:00.000Z",
304 }).type,305 }).type,
@@ -316,11 +317,103 @@ describe("T3 to Hermes v2 messages", () => { · 11 unmodified lines
316 }),317 }),
317 ).toThrow();318 ).toThrow();
318 });319 });
320
321 it("defaults an unstated connection role to gateway", () => {
322 // The field is about intent, not tolerance: a hello that says nothing is
323 // the ordinary live plugin, which must never be read as a throwaway
324 // delivery socket.
325 const hello = decodeHello({
326 type: "connection.hello",
327 requestId: "hello-role-default",
328 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
329 pluginVersion: "0.2.0",
330 hermesVersion: "1.2.3",
331 capabilities: {
332 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
333 streaming: true,
334 activity: true,
335 approvals: true,
336 userInput: true,
337 attachments: false,
338 },
339 authentication: { type: "instance-credential", instanceId: "hermes", credential: "secret" },
340 });
341
342 expect(hello.role).toBe("gateway");
343 });
344
345 it("carries the home thread designation on acceptance", () => {
346 const accepted = decodeT3Message({
347 type: "connection.accepted",
348 requestId: "hello-1",
349 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
350 instanceId: "hermes",
351 nickname: "Remote Hermes",
352 homeThreadId: "thread-home-1",
353 });
354
355 expect(accepted.type).toBe("connection.accepted");
356 if (accepted.type === "connection.accepted") {
357 expect(accepted.homeThreadId).toBe("thread-home-1");
358 }
359
360 // Optional: a handshake whose home-thread resolution failed still accepts
361 // the plugin rather than refusing an authenticated connection.
362 const withoutHome = decodeT3Message({
363 type: "connection.accepted",
364 requestId: "hello-2",
365 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
366 instanceId: "hermes",
367 nickname: "Remote Hermes",
368 });
369 expect(withoutHome.type).toBe("connection.accepted");
370 });
371});
372
373describe("Hermes home deliveries", () => {
374 const delivery = {
375 type: "home.deliver",
376 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
377 deliveryId: "delivery-1",
378 threadId: "thread-home-1",
379 kind: "cron",
380 label: "Cron: daily-digest",
381 text: "Your digest is ready.",
382 createdAt: "2026-07-25T12:00:00.000Z",
383 } as const;
384
385 it("decodes a delivery and its acknowledgement", () => {
386 const decoded = decodePluginMessage(delivery);
387 expect(decoded.type).toBe("home.deliver");
388 if (decoded.type === "home.deliver") {
389 expect(decoded.kind).toBe("cron");
390 expect(decoded.deliveryId).toBe("delivery-1");
391 }
392
393 const ack = decodeT3Message({
394 type: "home.deliver.ack",
395 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
396 deliveryId: "delivery-1",
397 });
398 expect(ack.type).toBe("home.deliver.ack");
399 });
400
401 it("requires a delivery id, since it is the dedupe key for retries", () => {
402 expect(() => decodePluginMessage({ ...delivery, deliveryId: "" })).toThrow();
403 });
404
405 it("rejects an unknown delivery kind rather than guessing a badge", () => {
406 expect(() => decodePluginMessage({ ...delivery, kind: "surprise" })).toThrow();
407 });
408
409 it("rejects empty delivery text", () => {
410 expect(() => decodePluginMessage({ ...delivery, text: "" })).toThrow();
411 });
319});412});
320 413
321describe("Hermes to T3 v2 events", () => {414describe("Hermes to T3 events", () => {
322 const turnContext = {415 const turnContext = {
323 protocolVersion: 2,416 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
324 threadId: "thread-1",417 threadId: "thread-1",
325 sessionId: "session-1",418 sessionId: "session-1",
326 turnId: "turn-1",419 turnId: "turn-1",
@@ -329,7 +422,7 @@ describe("Hermes to T3 v2 events", () => { · 2 unmodified lines
329 it("decodes session readiness, turn start, streaming text, and completion", () => {422 it("decodes session readiness, turn start, streaming text, and completion", () => {
330 const legacyReady = decodePluginMessage({423 const legacyReady = decodePluginMessage({
331 type: "session.ready",424 type: "session.ready",
332 protocolVersion: 2,425 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
333 requestId: "ensure-1",426 requestId: "ensure-1",
334 threadId: "thread-1",427 threadId: "thread-1",
335 sessionId: "session-1",428 sessionId: "session-1",
@@ -338,7 +431,7 @@ describe("Hermes to T3 v2 events", () => { · 2 unmodified lines
338 expect(legacyReady.type).toBe("session.ready");431 expect(legacyReady.type).toBe("session.ready");
339 const activeReady = decodePluginMessage({432 const activeReady = decodePluginMessage({
340 type: "session.ready",433 type: "session.ready",
341 protocolVersion: 2,434 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
342 requestId: "ensure-2",435 requestId: "ensure-2",
343 threadId: "thread-1",436 threadId: "thread-1",
344 sessionId: "session-1",437 sessionId: "session-1",
packages/contracts/src/hermesGateway.ts+98−3
@@ -7,6 +7,7 @@ · 6 unmodified lines
7 *7 *
8 * @module hermesGateway8 * @module hermesGateway
9 */9 */
10import * as Effect from "effect/Effect";
10import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
11 12
12import {13import {
@@ -21,7 +22,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat · 8 unmodified lines
21import { ProviderInstanceId } from "./providerInstance.ts";22import { ProviderInstanceId } from "./providerInstance.ts";
22import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";23import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
23 24
24export const HERMES_GATEWAY_PROTOCOL_VERSION = 2 as const;25export const HERMES_GATEWAY_PROTOCOL_VERSION = 3 as const;
25 26
26export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);27export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
27export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;28export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
@@ -87,7 +88,7 @@ export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; · 59 unmodified lines
87 *88 *
88 * This deliberately permits capability shapes from a newer protocol so T3 can89 * This deliberately permits capability shapes from a newer protocol so T3 can
89 * return a structured `version-incompatible` rejection instead of failing the90 * return a structured `version-incompatible` rejection instead of failing the
90 * WebSocket frame decoder. Accepted v2 connections must subsequently validate91 * WebSocket frame decoder. Accepted connections must subsequently validate
91 * this advertisement with `HermesGatewayCapabilities`.92 * this advertisement with `HermesGatewayCapabilities`.
92 */93 */
93export const HermesGatewayHelloCapabilities = Schema.Struct({94export const HermesGatewayHelloCapabilities = Schema.Struct({
@@ -256,10 +257,23 @@ export const HermesGatewayAuthentication = Schema.Union([ · 162 unmodified lines
256]);257]);
257export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type;258export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type;
258 259
260/**
261 * What a connecting socket intends to be.
262 *
263 * `gateway` is the instance's one live plugin connection: registered under
264 * generation fencing, pinged for liveness, and displacing any predecessor.
265 * `delivery` is a short-lived socket — an out-of-process cron run dialing in
266 * only to hand over a `home.deliver` and leave. Delivery connections are
267 * authenticated identically but are never registered as the primary
268 * connection, so they cannot kick a healthy gateway socket off its instance.
269 */
270export const HermesGatewayConnectionRole = Schema.Literals(["gateway", "delivery"]);
271export type HermesGatewayConnectionRole = typeof HermesGatewayConnectionRole.Type;
272
259/**273/**
260 * `protocolVersion` accepts any positive integer at the initial boundary so274 * `protocolVersion` accepts any positive integer at the initial boundary so
261 * T3 can reject incompatible plugins with a structured upgrade response.275 * T3 can reject incompatible plugins with a structured upgrade response.
262 * Once accepted, all remaining v2 frames use the literal v2 schema.276 * Once accepted, all remaining frames use the literal current-version schema.
263 */277 */
264export const HermesGatewayConnectionHello = Schema.Struct({278export const HermesGatewayConnectionHello = Schema.Struct({
265 type: Schema.Literal("connection.hello"),279 type: Schema.Literal("connection.hello"),
@@ -278,6 +292,12 @@ export const HermesGatewayConnectionHello = Schema.Struct({ · 12 unmodified lines
278 * value degrades to the generic label rather than failing the handshake.292 * value degrades to the generic label rather than failing the handshake.
279 */293 */
280 model: Schema.optional(TrimmedNonEmptyString),294 model: Schema.optional(TrimmedNonEmptyString),
295 /**
296 * Defaults to `"gateway"` on decode so the field stays honest about intent
297 * rather than making every caller repeat the common case. v3 requires both
298 * sides updated regardless, so this default is ergonomics, not tolerance.
299 */
300 role: HermesGatewayConnectionRole.pipe(Schema.withDecodingDefault(Effect.succeed("gateway"))),
281});301});
282export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type;302export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type;
283 303
@@ -288,6 +308,17 @@ export const HermesGatewayConnectionAccepted = Schema.Struct({ · 4 unmodified lines
288 instanceId: ProviderInstanceId,308 instanceId: ProviderInstanceId,
289 nickname: HermesGatewayNickname,309 nickname: HermesGatewayNickname,
290 credential: Schema.optional(HermesGatewayCredential),310 credential: Schema.optional(HermesGatewayCredential),
311 /**
312 * The instance's durable home thread — where Hermes' proactive output lands
313 * when nothing named a destination. Sent on every successful handshake so
314 * the plugin reconciles its `T3_HOME_CHANNEL` cache each connect; T3's
315 * settings blob is the authoritative designation.
316 *
317 * Optional because resolving it must never fail a handshake: if the thread
318 * could not be created this connect, the plugin keeps whatever it had and
319 * reconciles on the next one.
320 */
321 homeThreadId: Schema.optional(ThreadId),
291});322});
292export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type;323export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type;
293 324
@@ -633,6 +664,68 @@ export const HermesGatewayPong = Schema.Struct({ · 339 unmodified lines
633});664});
634export type HermesGatewayPong = typeof HermesGatewayPong.Type;665export type HermesGatewayPong = typeof HermesGatewayPong.Type;
635 666
667/**
668 * Plugin-minted, stable across retries. T3 dedupes on it, which is what makes
669 * the plugin's queue safe to flush more than once.
670 */
671export const HermesGatewayDeliveryId = TrimmedNonEmptyString.pipe(
672 Schema.brand("HermesGatewayDeliveryId"),
673);
674export type HermesGatewayDeliveryId = typeof HermesGatewayDeliveryId.Type;
675
676/**
677 * What produced a home delivery. Drives both the rendered badge and whether
678 * the delivery raises its hand: everything except `lifecycle` un-settles the
679 * thread and pushes; gateway online/shutdown notices land quietly.
680 *
681 * Classification is best-effort on the plugin side — Hermes' `adapter.send()`
682 * contract carries no structured provenance marker on every path — so a
683 * misclassification costs a wrong badge, never a lost delivery.
684 */
685export const HermesGatewayHomeDeliveryKind = Schema.Literals([
686 "cron",
687 "message",
688 "lifecycle",
689 "handoff",
690 "other",
691]);
692export type HermesGatewayHomeDeliveryKind = typeof HermesGatewayHomeDeliveryKind.Type;
693
694/**
695 * Hermes-initiated delivery into the instance's home thread.
696 *
697 * Deliberately not a turn: there is no provider session, no turn id, and no
698 * request the delivery answers. A delivery may arrive while the home thread
699 * has a live user turn and must not disturb it.
700 */
701export const HermesGatewayHomeDeliver = Schema.Struct({
702 type: Schema.Literal("home.deliver"),
703 protocolVersion: HermesGatewayProtocolVersion,
704 deliveryId: HermesGatewayDeliveryId,
705 threadId: ThreadId,
706 kind: HermesGatewayHomeDeliveryKind,
707 /** Human source label rendered as the badge — "Cron: daily-digest". */
708 label: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
709 text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(120_000)),
710 /**
711 * When Hermes produced the content, not when it reached T3. These diverge
712 * whenever a queued delivery flushes after a reconnect.
713 */
714 createdAt: IsoDateTime,
715});
716export type HermesGatewayHomeDeliver = typeof HermesGatewayHomeDeliver.Type;
717
718/**
719 * Sent only after the delivery is durably written. The plugin purges its
720 * queued copy on this frame and nothing else, so acking early loses messages.
721 */
722export const HermesGatewayHomeDeliverAck = Schema.Struct({
723 type: Schema.Literal("home.deliver.ack"),
724 protocolVersion: HermesGatewayProtocolVersion,
725 deliveryId: HermesGatewayDeliveryId,
726});
727export type HermesGatewayHomeDeliverAck = typeof HermesGatewayHomeDeliverAck.Type;
728
636export const HermesGatewayProtocolErrorCode = Schema.Literals([729export const HermesGatewayProtocolErrorCode = Schema.Literals([
637 "invalid-message",730 "invalid-message",
638 "unsupported-message",731 "unsupported-message",
@@ -666,6 +759,7 @@ export const HermesGatewayT3ToPluginMessage = Schema.Union([ · 27 unmodified lines
666 HermesGatewayDescribeRequest,759 HermesGatewayDescribeRequest,
667 HermesGatewaySkillBodyRequest,760 HermesGatewaySkillBodyRequest,
668 HermesGatewayPing,761 HermesGatewayPing,
762 HermesGatewayHomeDeliverAck,
669]);763]);
670export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type;764export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type;
671 765
@@ -690,6 +784,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ · 18 unmodified lines
690 HermesGatewaySkillBodyResponse,784 HermesGatewaySkillBodyResponse,
691 HermesGatewayPong,785 HermesGatewayPong,
692 HermesGatewayProtocolError,786 HermesGatewayProtocolError,
787 HermesGatewayHomeDeliver,
693]);788]);
694export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type;789export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type;
695 790
packages/contracts/src/orchestration.ts+96−0
@@ -238,6 +238,27 @@ export type OrchestrationProject = typeof OrchestrationProject.Type; · 237 unmodified lines
238export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);238export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);
239export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;239export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;
240 240
241/**
242 * Provenance for a message an agent delivered on its own initiative rather
243 * than as the answer to a turn.
244 *
245 * Present only on proactive deliveries (a cron result, an agent-initiated
246 * `send_message`, a gateway lifecycle notice). Its absence is what makes a
247 * message an ordinary one, so consumers must treat "no notification" as the
248 * normal case and never synthesize a default.
249 */
250export const OrchestrationMessageNotification = Schema.Struct({
251 kind: Schema.Literals(["cron", "message", "lifecycle", "handoff", "other"]),
252 /** Human source label rendered as the badge — "Cron: daily-digest". */
253 label: TrimmedNonEmptyString,
254 /**
255 * The delivering agent's idempotency key. Retained on the row so a replayed
256 * delivery is recognized as one already written rather than appended twice.
257 */
258 deliveryId: TrimmedNonEmptyString,
259});
260export type OrchestrationMessageNotification = typeof OrchestrationMessageNotification.Type;
261
241export const OrchestrationMessage = Schema.Struct({262export const OrchestrationMessage = Schema.Struct({
242 id: MessageId,263 id: MessageId,
243 role: OrchestrationMessageRole,264 role: OrchestrationMessageRole,
@@ -245,6 +266,11 @@ export const OrchestrationMessage = Schema.Struct({ · 1 unmodified line
245 attachments: Schema.optional(Schema.Array(ChatAttachment)),266 attachments: Schema.optional(Schema.Array(ChatAttachment)),
246 turnId: Schema.NullOr(TurnId),267 turnId: Schema.NullOr(TurnId),
247 streaming: Schema.Boolean,268 streaming: Schema.Boolean,
269 /**
270 * Set only on proactive agent deliveries. Optional so every existing
271 * producer, cached snapshot, and persisted row decodes unchanged.
272 */
273 notification: Schema.optional(OrchestrationMessageNotification),
248 createdAt: IsoDateTime,274 createdAt: IsoDateTime,
249 updatedAt: IsoDateTime,275 updatedAt: IsoDateTime,
250});276});
@@ -417,6 +443,24 @@ export const OrchestrationProjectShell = Schema.Struct({ · 166 unmodified lines
417});443});
418export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type;444export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type;
419 445
446/**
447 * The most recent proactive delivery in a thread, flattened onto the shell.
448 *
449 * Shell consumers (sidebar rows, the awareness/push projection) never load a
450 * thread's messages, so the notification provenance that lives on the message
451 * row has to be materialized here or those surfaces cannot see it at all.
452 * `deliveredAt` is the delivering message's `createdAt`, which is what lets a
453 * reader decide whether the notification is still the newest thing that
454 * happened to the thread or has been overtaken by a later turn.
455 */
456export const OrchestrationThreadNotificationSummary = Schema.Struct({
457 kind: OrchestrationMessageNotification.fields.kind,
458 label: TrimmedNonEmptyString,
459 deliveredAt: IsoDateTime,
460});
461export type OrchestrationThreadNotificationSummary =
462 typeof OrchestrationThreadNotificationSummary.Type;
463
420export const OrchestrationThreadShell = Schema.Struct({464export const OrchestrationThreadShell = Schema.Struct({
421 id: ThreadId,465 id: ThreadId,
422 projectId: ProjectId,466 projectId: ProjectId,
@@ -443,6 +487,12 @@ export const OrchestrationThreadShell = Schema.Struct({ · 20 unmodified lines
443 hasPendingApprovals: Schema.Boolean,487 hasPendingApprovals: Schema.Boolean,
444 hasPendingUserInput: Schema.Boolean,488 hasPendingUserInput: Schema.Boolean,
445 hasActionableProposedPlan: Schema.Boolean,489 hasActionableProposedPlan: Schema.Boolean,
490 /**
491 * Newest proactive delivery, or `null`/absent on every thread that has never
492 * received one. Optional (like `snoozedUntil`) so shells produced by
493 * pre-notification servers and every existing cached snapshot still decode.
494 */
495 latestNotification: Schema.optional(Schema.NullOr(OrchestrationThreadNotificationSummary)),
446});496});
447export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;497export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
448 498
@@ -883,6 +933,28 @@ const ThreadActivityAppendCommand = Schema.Struct({ · 434 unmodified lines
883 createdAt: IsoDateTime,933 createdAt: IsoDateTime,
884});934});
885 935
936/**
937 * A proactive delivery from a connected agent into its home thread.
938 *
939 * Internal-only and deliberately outside the turn machinery: there is no
940 * provider session or turn behind it, and it may arrive while the thread has
941 * a live user turn, which it must not disturb. `deliveryId` is the agent's
942 * idempotency key — the decider drops a repeat rather than appending twice,
943 * which is what lets the plugin retry an unacked delivery safely.
944 */
945const ThreadNotificationDeliverCommand = Schema.Struct({
946 type: Schema.Literal("thread.notification.deliver"),
947 commandId: CommandId,
948 threadId: ThreadId,
949 messageId: MessageId,
950 deliveryId: TrimmedNonEmptyString,
951 kind: Schema.Literals(["cron", "message", "lifecycle", "handoff", "other"]),
952 label: TrimmedNonEmptyString,
953 text: Schema.String,
954 /** When the agent produced the content, which may predate its arrival. */
955 createdAt: IsoDateTime,
956});
957
886const ThreadRevertCompleteCommand = Schema.Struct({958const ThreadRevertCompleteCommand = Schema.Struct({
887 type: Schema.Literal("thread.revert.complete"),959 type: Schema.Literal("thread.revert.complete"),
888 commandId: CommandId,960 commandId: CommandId,
@@ -899,6 +971,7 @@ const InternalOrchestrationCommand = Schema.Union([ · 10 unmodified lines
899 ThreadProposedPlanUpsertCommand,971 ThreadProposedPlanUpsertCommand,
900 ThreadTurnDiffCompleteCommand,972 ThreadTurnDiffCompleteCommand,
901 ThreadActivityAppendCommand,973 ThreadActivityAppendCommand,
974 ThreadNotificationDeliverCommand,
902 ThreadRevertCompleteCommand,975 ThreadRevertCompleteCommand,
903]);976]);
904export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;977export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
@@ -936,6 +1009,7 @@ export const OrchestrationEventType = Schema.Literals([ · 31 unmodified lines
936 "thread.proposed-plan-upserted",1009 "thread.proposed-plan-upserted",
937 "thread.turn-diff-completed",1010 "thread.turn-diff-completed",
938 "thread.activity-appended",1011 "thread.activity-appended",
1012 "thread.notification-delivered",
939]);1013]);
940export type OrchestrationEventType = typeof OrchestrationEventType.Type;1014export type OrchestrationEventType = typeof OrchestrationEventType.Type;
941 1015
@@ -1179,6 +1253,23 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ · 237 unmodified lines
1179 activity: OrchestrationThreadActivity,1253 activity: OrchestrationThreadActivity,
1180});1254});
1181 1255
1256/**
1257 * A proactive agent delivery, materialized as an assistant message row.
1258 *
1259 * It is a real transcript message — selectable, copyable, part of history —
1260 * distinguished only by carrying `notification` provenance. It has no
1261 * `turnId`: nothing about it belongs to a turn, and attributing it to the
1262 * thread's live turn would fold it away behind that turn's "Worked for …".
1263 */
1264export const ThreadNotificationDeliveredPayload = Schema.Struct({
1265 threadId: ThreadId,
1266 messageId: MessageId,
1267 text: Schema.String,
1268 notification: OrchestrationMessageNotification,
1269 createdAt: IsoDateTime,
1270 updatedAt: IsoDateTime,
1271});
1272
1182export const OrchestrationEventMetadata = Schema.Struct({1273export const OrchestrationEventMetadata = Schema.Struct({
1183 providerTurnId: Schema.optional(TrimmedNonEmptyString),1274 providerTurnId: Schema.optional(TrimmedNonEmptyString),
1184 providerItemId: Schema.optional(ProviderItemId),1275 providerItemId: Schema.optional(ProviderItemId),
@@ -1331,6 +1422,11 @@ export const OrchestrationEvent = Schema.Union([ · 146 unmodified lines
1331 type: Schema.Literal("thread.activity-appended"),1422 type: Schema.Literal("thread.activity-appended"),
1332 payload: ThreadActivityAppendedPayload,1423 payload: ThreadActivityAppendedPayload,
1333 }),1424 }),
1425 Schema.Struct({
1426 ...EventBaseFields,
1427 type: Schema.Literal("thread.notification-delivered"),
1428 payload: ThreadNotificationDeliveredPayload,
1429 }),
1334]);1430]);
1335export type OrchestrationEvent = typeof OrchestrationEvent.Type;1431export type OrchestrationEvent = typeof OrchestrationEvent.Type;
1336 1432
packages/contracts/src/relay.ts+2−0
@@ -19,6 +19,8 @@ export const RelayAgentAwarenessPhase = Schema.Literals([ · 18 unmodified lines
19 "running",19 "running",
20 "waiting_for_approval",20 "waiting_for_approval",
21 "waiting_for_input",21 "waiting_for_input",
22 // A proactive agent delivery (cron result, agent-initiated message).
23 "notification",
22 "completed",24 "completed",
23 "failed",25 "failed",
24 "stale",26 "stale",
packages/contracts/src/server.ts+9−0
@@ -203,6 +203,15 @@ export const ServerProvider = Schema.Struct({ · 202 unmodified lines
203 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),203 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
204 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),204 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
205 updateState: Schema.optionalKey(ServerProviderUpdateState),205 updateState: Schema.optionalKey(ServerProviderUpdateState),
206 /**
207 * The instance's designated home thread, where its agent's proactive output
208 * lands. Clients use it to pin the thread first in the instance's list and
209 * to withhold Delete.
210 *
211 * Only Hermes instances designate one; absent everywhere else, and absent on
212 * a Hermes instance that has not completed a handshake yet.
213 */
214 homeThreadId: Schema.optional(ThreadId),
206});215});
207export type ServerProvider = typeof ServerProvider.Type;216export type ServerProvider = typeof ServerProvider.Type;
208 217
packages/contracts/src/settings.ts+13−0
@@ -429,6 +429,19 @@ export const HermesSettings = makeProviderSettingsSchema({ · 428 unmodified lines
429 revoked: Schema.optionalKey(Schema.Boolean).pipe(429 revoked: Schema.optionalKey(Schema.Boolean).pipe(
430 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),430 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
431 ),431 ),
432 /**
433 * The instance's durable home thread — the authoritative designation for
434 * where Hermes' proactive output lands.
435 *
436 * Kept here rather than in a dedicated table for the same reason as the
437 * fields above: it is per-instance enrollment metadata, and settings avoids
438 * a migration. Absent means "not yet designated"; the next handshake
439 * converges it on read. The plugin's `T3_HOME_CHANNEL` env var is a synced
440 * cache of this value, never the source of truth.
441 */
442 homeThreadId: Schema.optionalKey(TrimmedString).pipe(
443 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
444 ),
432});445});
433export type HermesSettings = typeof HermesSettings.Type;446export type HermesSettings = typeof HermesSettings.Type;
434 447
packages/shared/src/agentAwareness.test.ts+74−0
@@ -29,6 +29,8 @@ function thread( · 28 unmodified lines
29 | "updatedAt"29 | "updatedAt"
30 | "hasPendingApprovals"30 | "hasPendingApprovals"
31 | "hasPendingUserInput"31 | "hasPendingUserInput"
32 | "latestNotification"
33 | "latestUserMessageAt"
32> {34> {
33 return {35 return {
34 id: "thread-1" as ThreadId,36 id: "thread-1" as ThreadId,
@@ -39,6 +41,8 @@ function thread( · 4 unmodified lines
39 updatedAt: NOW,41 updatedAt: NOW,
40 hasPendingApprovals: false,42 hasPendingApprovals: false,
41 hasPendingUserInput: false,43 hasPendingUserInput: false,
44 latestNotification: null,
45 latestUserMessageAt: null,
42 ...overrides,46 ...overrides,
43 };47 };
44}48}
@@ -102,6 +106,76 @@ describe("projectThreadAwareness", () => { · 57 unmodified lines
102 });106 });
103 });107 });
104 108
109 it("projects a fresh proactive delivery with the delivery's own label as the headline", () => {
110 const state = projectThreadAwareness({
111 environmentId: "env-1" as EnvironmentId,
112 project,
113 thread: thread({
114 latestNotification: {
115 kind: "cron",
116 label: "Cron: daily-digest",
117 deliveredAt: NOW,
118 },
119 }),
120 });
121
122 expect(state?.phase).toBe("notification");
123 // A generic headline would strip the only information the delivery carries.
124 expect(state?.headline).toBe("Cron: daily-digest");
125 });
126
127 it("stops reporting a delivery once later thread activity supersedes it", () => {
128 const delivered = "2026-05-22T11:00:00.000Z";
129 const later = "2026-05-22T11:30:00.000Z";
130 const notification = {
131 kind: "message" as const,
132 label: "Hermes",
133 deliveredAt: delivered,
134 };
135
136 // A user reply after the delivery: the ordinary ladder takes over again.
137 expect(
138 projectThreadAwareness({
139 environmentId: "env-1" as EnvironmentId,
140 project,
141 thread: thread({ latestNotification: notification, latestUserMessageAt: later }),
142 }),
143 ).toBeNull();
144
145 // A turn that started after it likewise wins — otherwise "notification"
146 // would pin forever, since nothing ever marks it read.
147 expect(
148 projectThreadAwareness({
149 environmentId: "env-1" as EnvironmentId,
150 project,
151 thread: thread({
152 latestNotification: notification,
153 latestTurn: {
154 turnId: "turn-1" as TurnId,
155 state: "running",
156 requestedAt: later,
157 startedAt: later,
158 completedAt: null,
159 assistantMessageId: null,
160 },
161 }),
162 })?.phase,
163 ).toBe("running");
164 });
165
166 it("keeps blocked-on-you phases ahead of a delivery", () => {
167 const state = projectThreadAwareness({
168 environmentId: "env-1" as EnvironmentId,
169 project,
170 thread: thread({
171 hasPendingApprovals: true,
172 latestNotification: { kind: "cron", label: "Cron: nightly", deliveredAt: NOW },
173 }),
174 });
175
176 expect(state?.phase).toBe("waiting_for_approval");
177 });
178
105 it("projects completed turns as completed even when teardown settled them as interrupted", () => {179 it("projects completed turns as completed even when teardown settled them as interrupted", () => {
106 const finishedTurn = {180 const finishedTurn = {
107 turnId: "turn-1" as TurnId,181 turnId: "turn-1" as TurnId,
packages/shared/src/agentAwareness.ts+52−2
@@ -10,6 +10,7 @@ export type AgentAwarenessPhase = · 9 unmodified lines
10 | "running"10 | "running"
11 | "waiting_for_approval"11 | "waiting_for_approval"
12 | "waiting_for_input"12 | "waiting_for_input"
13 | "notification"
13 | "completed"14 | "completed"
14 | "failed"15 | "failed"
15 | "stale";16 | "stale";
@@ -40,6 +41,8 @@ export interface ProjectThreadAwarenessInput { · 24 unmodified lines
40 | "updatedAt"41 | "updatedAt"
41 | "hasPendingApprovals"42 | "hasPendingApprovals"
42 | "hasPendingUserInput"43 | "hasPendingUserInput"
44 | "latestNotification"
45 | "latestUserMessageAt"
43 >;46 >;
44}47}
45 48
@@ -66,7 +69,7 @@ export function projectThreadAwareness( · 20 unmodified lines
66 projectTitle: project.title,69 projectTitle: project.title,
67 threadTitle: thread.title,70 threadTitle: thread.title,
68 phase,71 phase,
69 headline: headlineForPhase(phase),72 headline: headlineForPhase(phase, thread),
70 ...(detail === undefined ? {} : { detail }),73 ...(detail === undefined ? {} : { detail }),
71 modelTitle: thread.modelSelection.model,74 modelTitle: thread.modelSelection.model,
72 updatedAt: thread.updatedAt,75 updatedAt: thread.updatedAt,
@@ -83,6 +86,14 @@ function resolveThreadAwarenessPhase( · 10 unmodified lines
83 if (thread.hasPendingUserInput) {86 if (thread.hasPendingUserInput) {
84 return "waiting_for_input";87 return "waiting_for_input";
85 }88 }
89 // A proactive delivery is news the user has not seen, but only while it is
90 // still the newest thing on the thread: once a turn or a user message lands
91 // after it, the ordinary ladder describes the thread better and pinning it
92 // to "notification" would suppress the real completion/running state
93 // indefinitely (there is no read state to clear it).
94 if (isLatestNotificationCurrent(thread)) {
95 return "notification";
96 }
86 if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {97 if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {
87 return "failed";98 return "failed";
88 }99 }
@@ -116,7 +127,42 @@ function resolveThreadAwarenessPhase( · 27 unmodified lines
116 return null;127 return null;
117}128}
118 129
119function headlineForPhase(phase: AgentAwarenessPhase): string {130/**
131 * True while the thread's newest proactive delivery is also the newest thing
132 * that happened to the thread — nothing the user sent and no turn activity
133 * postdates it.
134 */
135function isLatestNotificationCurrent(thread: ProjectThreadAwarenessInput["thread"]): boolean {
136 const notification = thread.latestNotification ?? null;
137 if (!notification) {
138 return false;
139 }
140 const deliveredAtMs = Date.parse(notification.deliveredAt);
141 if (Number.isNaN(deliveredAtMs)) {
142 return false;
143 }
144 const supersedingTimestamps = [
145 thread.latestUserMessageAt,
146 thread.latestTurn?.completedAt ?? null,
147 thread.latestTurn?.startedAt ?? null,
148 thread.latestTurn?.requestedAt ?? null,
149 ];
150 for (const timestamp of supersedingTimestamps) {
151 if (timestamp === null) {
152 continue;
153 }
154 const timestampMs = Date.parse(timestamp);
155 if (!Number.isNaN(timestampMs) && timestampMs > deliveredAtMs) {
156 return false;
157 }
158 }
159 return true;
160}
161
162function headlineForPhase(
163 phase: AgentAwarenessPhase,
164 thread: ProjectThreadAwarenessInput["thread"],
165): string {
120 switch (phase) {166 switch (phase) {
121 case "starting":167 case "starting":
122 return "Starting agent";168 return "Starting agent";
@@ -126,6 +172,10 @@ function headlineForPhase(phase: AgentAwarenessPhase): string { · 3 unmodified lines
126 return "Approval needed";172 return "Approval needed";
127 case "waiting_for_input":173 case "waiting_for_input":
128 return "Waiting for input";174 return "Waiting for input";
175 // The delivering agent's own label ("Cron: daily-digest") is the headline:
176 // a generic one would strip the only information the delivery carries.
177 case "notification":
178 return thread.latestNotification?.label ?? "New message";
129 case "completed":179 case "completed":
130 return "Agent finished";180 return "Agent finished";
131 case "failed":181 case "failed":