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.
Unified · Split
| @@ -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-300 | 82 | 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-300 | 84 | 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-300 | 88 | 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": |
| @@ -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 | }), |
| @@ -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 | }), |
| @@ -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; |
| @@ -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"; |
| 37 | import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; | 37 | import { |
| 38 | type ProjectionThread, | ||
| 39 | ProjectionThreadRepository, | ||
| 40 | } from "../../persistence/Services/ProjectionThreads.ts"; | ||
| 38 | import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; | 41 | import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; |
| 39 | import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; | 42 | import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; |
| 40 | import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; | 43 | import { 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, |
| @@ -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 | ||
| @@ -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 | ); |
| 78 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; | 81 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; |
| 79 | const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( | 82 | const 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 | ); |
| 84 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | 93 | const 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_threads | 362 | FROM projection_threads |
| 353 | ORDER BY created_at ASC, thread_id ASC | 363 | 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_threads | 395 | FROM projection_threads |
| 385 | WHERE deleted_at IS NULL | 396 | 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_threads | 430 | FROM projection_threads |
| 419 | WHERE deleted_at IS NULL | 431 | 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_threads | 850 | 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; |
| @@ -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 | |||
| 49 | export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; | 50 | export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; |
| 50 | export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; | 51 | export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; |
| 51 | export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; | 52 | export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; |
| 53 | export const ThreadNotificationDeliveredPayload = ContractsThreadNotificationDeliveredPayloadSchema; | ||
| 52 | 54 | ||
| 53 | export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; | 55 | export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; |
| 54 | export const ThreadTurnInterruptRequestedPayload = | 56 | export const ThreadTurnInterruptRequestedPayload = |
| @@ -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 | */ |
| @@ -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 | }); |
| @@ -0,0 +1,237 @@ | |||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | MessageId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ThreadId, | ||
| 7 | type OrchestrationReadModel, | ||
| 8 | type OrchestrationThread, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 11 | import { expect, it } from "@effect/vitest"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | |||
| 14 | import { decideOrchestrationCommand } from "./decider.ts"; | ||
| 15 | |||
| 16 | const NOW = "2026-07-01T00:00:00.000Z"; | ||
| 17 | const DELIVERED_AT = "2026-06-30T09:00:00.000Z"; | ||
| 18 | const SETTLED_AT = "2026-06-29T00:00:00.000Z"; | ||
| 19 | const SNOOZED_UNTIL = "2026-07-02T00:00:00.000Z"; | ||
| 20 | |||
| 21 | function 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 | |||
| 60 | function 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 | |||
| 80 | const 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 | |||
| 95 | it.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 | }); | ||
| @@ -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 }; |
| @@ -0,0 +1,408 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 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"; | ||
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Ref from "effect/Ref"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | |||
| 19 | import { ServerConfig } from "../config.ts"; | ||
| 20 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 21 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | ||
| 22 | import { | ||
| 23 | HOME_THREAD_TITLE, | ||
| 24 | getDesignatedHomeThreadId, | ||
| 25 | getOrCreateHomeThread, | ||
| 26 | isHomeThread, | ||
| 27 | readHomeThreadId, | ||
| 28 | } from "./homeThreads.ts"; | ||
| 29 | import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; | ||
| 30 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | ||
| 31 | |||
| 32 | const INSTANCE_ID = ProviderInstanceId.make("hermes-siva-local-abc123"); | ||
| 33 | const OTHER_INSTANCE_ID = ProviderInstanceId.make("hermes-other-def456"); | ||
| 34 | const HOME_THREAD_ID = ThreadId.make("thread-home-1"); | ||
| 35 | const BASE_DIR = "/tmp/t3-home-threads-test"; | ||
| 36 | |||
| 37 | const 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 | |||
| 50 | const 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 | |||
| 73 | const 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 | */ | ||
| 84 | const 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 | |||
| 121 | const 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 | |||
| 144 | const configLayer = Layer.succeed(ServerConfig, { | ||
| 145 | baseDir: BASE_DIR, | ||
| 146 | } as unknown as ServerConfig["Service"]); | ||
| 147 | |||
| 148 | const 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 | |||
| 170 | it("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 | |||
| 185 | it.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 | |||
| 213 | it.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 | |||
| 253 | it.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 | |||
| 283 | it.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 | |||
| 314 | it.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 | |||
| 339 | it.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 | |||
| 382 | it.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 | ); | ||
| @@ -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 | */ | ||
| 37 | import { | ||
| 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"; | ||
| 46 | import * as Cause from "effect/Cause"; | ||
| 47 | import * as Crypto from "effect/Crypto"; | ||
| 48 | import * as DateTime from "effect/DateTime"; | ||
| 49 | import * as Effect from "effect/Effect"; | ||
| 50 | import * as Option from "effect/Option"; | ||
| 51 | |||
| 52 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 53 | import { getOrCreateAgentProject } from "./agentProjects.ts"; | ||
| 54 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | ||
| 55 | import { 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 | */ | ||
| 61 | export const HOME_THREAD_TITLE = "Home"; | ||
| 62 | |||
| 63 | /** Read the designated home thread id out of a Hermes instance envelope. */ | ||
| 64 | export 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 | */ | ||
| 80 | export 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 | */ | ||
| 95 | export 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 | */ | ||
| 113 | const 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. */ | ||
| 120 | const 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 | */ | ||
| 156 | export 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 | }); | ||
| @@ -0,0 +1,149 @@ | |||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | EventId, | ||
| 4 | MessageId, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | type OrchestrationEvent, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { expect, it } from "@effect/vitest"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | ||
| 14 | |||
| 15 | const CREATED_AT = "2026-07-01T09:00:00.000Z"; | ||
| 16 | const DELIVERED_AT = "2026-07-01T09:05:00.000Z"; | ||
| 17 | |||
| 18 | function 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 | |||
| 39 | const 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 | |||
| 56 | const 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 | |||
| 74 | const 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 | |||
| 90 | it.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 | |||
| 105 | it.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 | |||
| 118 | it.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 | ); | ||
| @@ -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, |
| @@ -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 | ||
| @@ -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; |
| @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | |||
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Struct from "effect/Struct"; | 7 | import * as Struct from "effect/Struct"; |
| 8 | import { ChatAttachment } from "@t3tools/contracts"; | 8 | import { ChatAttachment, OrchestrationMessageNotification } from "@t3tools/contracts"; |
| 9 | 9 | ||
| 10 | import { toPersistenceSqlError } from "../Errors.ts"; | 10 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 11 | import { | 11 | import { |
| @@ -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_at | 66 | 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_json | 102 | 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_at | 110 | 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" |
| @@ -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"; |
| 17 | import { ModelSelection } from "@t3tools/contracts"; | 17 | import { ModelSelection, OrchestrationThreadNotificationSummary } from "@t3tools/contracts"; |
| 18 | 18 | ||
| 19 | const ProjectionThreadDbRow = ProjectionThread.mapFields( | 19 | const 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 | ); |
| 24 | type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; | 27 | type 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_at | 60 | 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_at | 108 | 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_threads | 141 | 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_threads | 174 | FROM projection_threads |
| 164 | WHERE project_id = ${projectId} | 175 | WHERE project_id = ${projectId} |
| @@ -48,6 +48,8 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; · 47 unmodified lines | |||
| 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; | 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; |
| 50 | import Migration0035 from "./Migrations/035_ProjectionProjectsAgentInstance.ts"; | 50 | import Migration0035 from "./Migrations/035_ProjectionProjectsAgentInstance.ts"; |
| 51 | import Migration0036 from "./Migrations/036_ProjectionThreadMessageNotification.ts"; | ||
| 52 | import 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 | ||
| 100 | export const makeMigrationLoader = (throughId?: number) => | 104 | export const makeMigrationLoader = (throughId?: number) => |
| @@ -0,0 +1,109 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { OrchestrationMessageNotification } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 7 | |||
| 8 | import { runMigrations } from "../Migrations.ts"; | ||
| 9 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | ||
| 10 | |||
| 11 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | ||
| 12 | |||
| 13 | layer("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 | }); | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export 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 | }); | ||
| @@ -0,0 +1,80 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 5 | |||
| 6 | import { runMigrations } from "../Migrations.ts"; | ||
| 7 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | ||
| 8 | |||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | ||
| 10 | |||
| 11 | layer("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 | }); | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * 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 | */ | ||
| 15 | export 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 | }); | ||
| @@ -9,6 +9,7 @@ · 8 unmodified lines | |||
| 9 | import { | 9 | import { |
| 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, |
| @@ -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 | }); |
| 49 | export type ProjectionThread = typeof ProjectionThread.Type; | 56 | export type ProjectionThread = typeof ProjectionThread.Type; |
| @@ -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 | }); |
| @@ -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"; |
| 8 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| 9 | import * as DateTime from "effect/DateTime"; | 10 | import * as DateTime from "effect/DateTime"; |
| 10 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Option from "effect/Option"; | ||
| 11 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 12 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| 13 | 15 | ||
| 16 | import { readHomeThreadId } from "../../orchestration/homeThreads.ts"; | ||
| 17 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 14 | import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; | 18 | import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 15 | import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; | 19 | import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; |
| 16 | import { | 20 | import { |
| @@ -23,7 +27,7 @@ import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts"; · 6 unmodified lines | |||
| 23 | 27 | ||
| 24 | const decodeHermesSettings = Schema.decodeSync(HermesSettings); | 28 | const decodeHermesSettings = Schema.decodeSync(HermesSettings); |
| 25 | 29 | ||
| 26 | export type HermesDriverEnv = Crypto.Crypto; | 30 | export type HermesDriverEnv = Crypto.Crypto | ServerSettingsService; |
| 27 | 31 | ||
| 28 | const unsupportedTextGeneration = ( | 32 | const 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* broker | 78 | 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 } : {}), |
| @@ -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 | */ | ||
| 47 | const UNSUPPORTED_PROTOCOL_VERSION = HERMES_GATEWAY_PROTOCOL_VERSION + 1; | ||
| 48 | |||
| 42 | const instanceId = ProviderInstanceId.make("hermes_remote"); | 49 | const instanceId = ProviderInstanceId.make("hermes_remote"); |
| 43 | const otherInstanceId = ProviderInstanceId.make("hermes_other"); | 50 | const otherInstanceId = ProviderInstanceId.make("hermes_other"); |
| 44 | const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); | 51 | const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); |
| @@ -94,7 +101,10 @@ const testLayer = Layer.mergeAll( · 49 unmodified lines | |||
| 94 | const hello = ( | 101 | const 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 | |||
| 1244 | it.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 | ); | ||
| @@ -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 a | 1047 | // 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"); |
| @@ -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) |
| @@ -1,5 +1,6 @@ | |||
| 1 | import type { | 1 | import 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 | ||
| 28 | export interface HermesGatewayConnectionRegistration { | 29 | export 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" } |
| @@ -1,8 +1,14 @@ | |||
| 1 | import { | 1 | import { |
| 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"; |
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| @@ -11,6 +17,9 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines | |||
| 11 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 17 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 12 | import * as Socket from "effect/unstable/socket/Socket"; | 18 | import * as Socket from "effect/unstable/socket/Socket"; |
| 13 | 19 | ||
| 20 | import { getOrCreateHomeThread } from "../orchestration/homeThreads.ts"; | ||
| 21 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; | ||
| 22 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 14 | import { | 23 | import { |
| 15 | HermesGatewayBroker, | 24 | HermesGatewayBroker, |
| 16 | type HermesGatewayConnectionRegistration, | 25 | type HermesGatewayConnectionRegistration, |
| @@ -28,6 +37,107 @@ const isConnectionHello = Schema.is(HermesGatewayConnectionHello); · 11 unmodified lines | |||
| 28 | export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( | 37 | export 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) => |
| @@ -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", () => { |
| @@ -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" || |
| @@ -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 | }), |
| @@ -73,6 +73,7 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; · 72 unmodified lines | |||
| 73 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; | 73 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 74 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 74 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 75 | import { getOrCreateAgentProject } from "./orchestration/agentProjects.ts"; | 75 | import { getOrCreateAgentProject } from "./orchestration/agentProjects.ts"; |
| 76 | import { isHomeThread } from "./orchestration/homeThreads.ts"; | ||
| 76 | import { | 77 | import { |
| 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* projectionSnapshotQuery | 1151 | ? yield* projectionSnapshotQuery |
| @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" · 1 unmodified line | |||
| 2 | import { | 2 | import { |
| 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 | ||
| 46 | import { sortThreads, type ThreadSortInput } from "../lib/threadSort"; | ||
| 45 | import { | 47 | import { |
| 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 | |||
| 1586 | describe("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 | |||
| 1638 | describe("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 | }); |
| @@ -1,5 +1,10 @@ | |||
| 1 | import * as React from "react"; | 1 | import * as React from "react"; |
| 2 | import type { ContextMenuItem, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; | 2 | import type { |
| 3 | ContextMenuItem, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ServerProvider, | ||
| 6 | ThreadId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 3 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 8 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 4 | import type { ProviderStatusKey } from "./settings/providerStatus"; | 9 | import type { ProviderStatusKey } from "./settings/providerStatus"; |
| 5 | import { | 10 | import { |
| @@ -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 | */ | ||
| 907 | export 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 | ||
| 884 | const AGENT_STATUS_LABELS: Record<ProviderStatusKey, string> = { | 922 | const 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 | ||
| @@ -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 | |||
| 174 | import { | 175 | import { |
| 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’s proactive messages land here | ||
| 735 | </TooltipPopup> | ||
| 736 | </Tooltip> | ||
| 737 | )} | ||
| 708 | {renamingThreadKey === threadKey ? ( | 738 | {renamingThreadKey === threadKey ? ( |
| 709 | <input | 739 | <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.branch | 2205 | ...(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, |
| @@ -1,6 +1,7 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ProviderInstanceDescription, | 2 | ProviderInstanceDescription, |
| 3 | ProviderInstanceId, | 3 | ProviderInstanceId, |
| 4 | ServerProvider, | ||
| 4 | ServerProviderSkill, | 5 | ServerProviderSkill, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { 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 | |||
| 341 | describe("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 | }); | ||
| @@ -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"; |
| 9 | import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | ||
| 7 | 10 | ||
| 8 | import { getProviderVersionLabel, type ProviderStatusKey } from "../settings/providerStatus"; | 11 | import { getProviderVersionLabel, type ProviderStatusKey } from "../settings/providerStatus"; |
| 9 | import { searchProviderSkills } from "../../providerSkillSearch"; | 12 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 13 | import { 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 | */ | ||
| 299 | export 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 | */ | ||
| 315 | export 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 | * |
| @@ -1,18 +1,22 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 6 | import type { | 7 | import type { |
| 7 | ProviderInstanceDescription, | 8 | ProviderInstanceDescription, |
| 8 | ProviderInstanceId, | 9 | ProviderInstanceId, |
| 9 | ServerProviderSkill, | 10 | ServerProviderSkill, |
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import type { SidebarThreadSortOrder, TimestampFormat } from "@t3tools/contracts/settings"; | ||
| 11 | import { useNavigate } from "@tanstack/react-router"; | 13 | import { useNavigate } from "@tanstack/react-router"; |
| 12 | import { | 14 | import { |
| 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"; |
| 24 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 29 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 25 | 30 | ||
| 26 | import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; | 31 | import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; |
| 32 | import { useClientSettings } from "../../hooks/useSettings"; | ||
| 33 | import { useThreadActions } from "../../hooks/useThreadActions"; | ||
| 27 | import { cn } from "../../lib/utils"; | 34 | import { cn } from "../../lib/utils"; |
| 28 | import { formatProviderDriverKindLabel } from "../../providerModels"; | 35 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 29 | import { | 36 | import { |
| 30 | formatProviderSkillDisplayName, | 37 | formatProviderSkillDisplayName, |
| 31 | formatProviderSkillInstallSource, | 38 | formatProviderSkillInstallSource, |
| 32 | } from "../../providerSkillPresentation"; | 39 | } from "../../providerSkillPresentation"; |
| 40 | import { useProjects, useThreadShellsForProjectRefs } from "../../state/entities"; | ||
| 33 | import { usePrimaryEnvironment } from "../../state/environments"; | 41 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 34 | import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; | 42 | import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; |
| 35 | import { useAtomCommand } from "../../state/use-atom-command"; | 43 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 44 | import { buildThreadRouteParams } from "../../threadRoutes"; | ||
| 45 | import { formatShortTimestamp } from "../../timestampFormat"; | ||
| 36 | import ChatMarkdown from "../ChatMarkdown"; | 46 | import ChatMarkdown from "../ChatMarkdown"; |
| 37 | import { hermesGatewayProviderSignal } from "../settings/HermesGatewayInstanceSection.logic"; | 47 | import { hermesGatewayProviderSignal } from "../settings/HermesGatewayInstanceSection.logic"; |
| 38 | import { PROVIDER_STATUS_STYLES } from "../settings/providerStatus"; | 48 | import { 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 | */ | ||
| 195 | function 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 | |||
| 176 | export function AgentPage({ instanceId }: { readonly instanceId: ProviderInstanceId }) { | 243 | export 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"> |
| @@ -39,6 +39,7 @@ import { · 38 unmodified lines | |||
| 39 | } from "../../lib/diffRendering"; | 39 | } from "../../lib/diffRendering"; |
| 40 | import ChatMarkdown from "../ChatMarkdown"; | 40 | import ChatMarkdown from "../ChatMarkdown"; |
| 41 | import { | 41 | import { |
| 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"; |
| 85 | import { | ||
| 86 | resolveNotificationBadge, | ||
| 87 | type NotificationBadgePresentation, | ||
| 88 | } from "./notificationBadge.logic"; | ||
| 84 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | 89 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 85 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 90 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 86 | import { | 91 | import { |
| @@ -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 | */ | ||
| 1030 | function 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 | |||
| 1018 | function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | 1047 | function 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 | <ChatMarkdown | 1056 | <ChatMarkdown |
| 1026 | text={messageText} | 1057 | text={messageText} |
| 1027 | cwd={ctx.markdownCwd} | 1058 | cwd={ctx.markdownCwd} |
| @@ -0,0 +1,63 @@ | |||
| 1 | import type { OrchestrationMessageNotification } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { resolveNotificationBadge } from "./notificationBadge.logic"; | ||
| 5 | |||
| 6 | function 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 | |||
| 17 | describe("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 | }); | ||
| @@ -0,0 +1,59 @@ | |||
| 1 | import 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 | */ | ||
| 10 | export 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 | */ | ||
| 28 | const 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 | */ | ||
| 48 | export 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 | } | ||
| @@ -23,7 +23,10 @@ Three independent gates block Hermes-initiated messages into T3: · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | 1. **Plugin turn gate** — every emitter requires `_active_turns[thread_id]`, populated | 24 | 1. **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. | ||
| 27 | 2. **Contract gate** — no plugin→T3 frame in protocol v2 opens a session or turn | 30 | 2. **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 carrying | 31 | (`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 path | 194 | ### Adapter delivery path |
| 192 | 195 | ||
| 193 | `T3PlatformAdapter.send(chat_id, content, metadata)` grows a proactive branch: when | 196 | `T3PlatformAdapter.send(chat_id, content, metadata)` grows a proactive branch for the |
| 194 | there is **no active turn** for the target and `chat_id` equals the home thread id, | 197 | home thread. **The gate must be provenance, not merely turn absence.** The naive rule |
| 195 | emit `home.deliver` instead of failing with `"no active T3 turn"`. Kind/label | 198 | ("no active turn for this thread → `home.deliver`") deadlocks against the |
| 196 | classification is best-effort from what Hermes passes in `metadata` and the send | 199 | notify-completion contract when the home thread itself has a live turn: a cron or |
| 197 | context (cron deliveries are identifiable; gateway startup/shutdown broadcasts match | 200 | `send_message` delivery targeting the home chat while the user is mid-conversation |
| 198 | known message shapes; everything else defaults to `kind: "message"`, label "Hermes"). | 201 | there would fall into the active-turn path, be streamed as that turn's assistant |
| 199 | Sends to a non-home thread without an active turn keep the current explicit error — | 202 | content, and — because final cron deliveries arrive notify-stamped — **complete the |
| 200 | scope creep into "message any thread unprompted" is deliberately excluded. | 203 | user's live turn with the cron output as its answer**. That is the same |
| 201 | 204 | keyed-off-the-wrong-signal defect class COMPATIBILITY.md documents for `finalize`. | |
| 202 | The `/sethome` nudge suppression (`adapter.py:41-46`, `:211-214`, `:258-259`) stays: | 205 | |
| 203 | once `env_enablement_fn` seeds a home channel Hermes stops emitting the nudge | 206 | The discriminator already exists: `_gateway_session_key()` (`adapter.py`, added in |
| 207 | `3f71c601a`) reads `HERMES_SESSION_KEY` from Hermes' session contextvar. A genuine | ||
| 208 | turn reply is produced inside the turn's session context, so the key resolves to this | ||
| 209 | plugin'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 | ||
| 211 | session. 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 | |||
| 220 | Kind/label classification is best-effort from `metadata` and the send context (cron | ||
| 221 | deliveries are identifiable; gateway startup/shutdown broadcasts match known message | ||
| 222 | shapes; everything else defaults to `kind: "message"`, label "Hermes"). | ||
| 223 | |||
| 224 | Three constraints from the turn-lifecycle fixes (`3f71c601a`, `a4000b86d`, | ||
| 225 | COMPATIBILITY.md "Turn completion is keyed off `notify`, never `finalize`") shape | ||
| 226 | this 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 | |||
| 247 | The `/sethome` nudge suppression (`adapter.py:41-46`; checks at `:211`, `:258`) | ||
| 248 | stays: once `env_enablement_fn` seeds a home channel Hermes stops emitting the nudge | ||
| 204 | naturally, but the suppression covers the pre-designation window (first connect before | 249 | naturally, but the suppression covers the pre-designation window (first connect before |
| 205 | any `connection.accepted` has written the env var). | 250 | any `connection.accepted` has written the env var). Note the nudge check now also |
| 251 | gates on `notify` for turn completion (`adapter.py:211-214`) — the suppression tests | ||
| 252 | in `tests/test_adapter.py` pin this and must keep passing. | ||
| 206 | 253 | ||
| 207 | ### Durable plugin-side queue | 254 | ### 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 an | 286 | - 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 | |||
| 298 | Not home-channel machinery, but it lands in the same surfaces (Hermes activity | ||
| 299 | ordering in the timeline) and the home thread will render notification + turn | ||
| 300 | activity 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 | ||
| 306 | assistant message, outside the fold. Both belong above. | ||
| 307 | |||
| 308 | **What's already fixed.** `a4000b86d` reordered `_complete_turn` to close the live | ||
| 309 | status item before the assistant message, because T3's fold only captures entries | ||
| 310 | preceding the turn's terminal message — the status item was being stamped ~2ms after | ||
| 311 | the answer and escaping. That fix is correct but insufficient: the symptom still | ||
| 312 | reproduces (observed post-fix with a "Work Log — Hermes activity" section below the | ||
| 313 | answer), 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 | |||
| 333 | 1. **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. | ||
| 340 | 2. **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`? | ||
| 344 | 3. **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 | |||
| 350 | The decisive experiment: reproduce, then inspect the projected thread — the escaping | ||
| 351 | activity row's `turnId` in `projection_thread_activities`. Null confirms attribution | ||
| 352 | loss (mechanisms 1/3); a correct `turnId` with the row still visible would falsify | ||
| 353 | the 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 | |||
| 366 | Regression test: replay the recorded frame sequence of a real Hermes turn (the | ||
| 367 | existing `test_adapter.py` frame-replay pattern) plus a late `item.completed`, and | ||
| 368 | assert the derived timeline rows place no work/activity row after the terminal | ||
| 369 | assistant 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 | |||
| 259 | 6. **Web UI** — snapshot `homeThreadId`, pinned row, notification badges, delete | 388 | 6. **Web UI** — snapshot `homeThreadId`, pinned row, notification badges, delete |
| 260 | suppression. | 389 | suppression. |
| 261 | 7. **Push** — `AgentAwarenessRelay` acceptance + headline mapping. | 390 | 7. **Push** — `AgentAwarenessRelay` acceptance + headline mapping. |
| 391 | 8. **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 | ||
| 263 | Steps 1–3 make T3 able to receive; step 4 makes the flagship flows (cron via live | 395 | Steps 1–3 make T3 able to receive; step 4 makes the flagship flows (cron via live |
| 264 | gateway, `send_message`, lifecycle, `/handoff`) work end to end; 5–7 complete it. | 396 | gateway, `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). |
| 294 | 9. Regression: Claude/Codex threads and non-home Hermes threads unchanged; unsolicited | 426 | 9. 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. |
| 428 | 10. 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). | ||
| 433 | 11. 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 | ||
| 297 | Automated: `pnpm typecheck`, `pnpm test`, `pnpm lint`; plugin tests in | 437 | Automated: `pnpm typecheck`, `pnpm test`, `pnpm lint`; plugin tests in |
| 298 | `integrations/hermes-t3-gateway/tests` (queue FIFO + ack purge, nudge suppression | 438 | `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. | ||
| @@ -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": |
| @@ -3,6 +3,11 @@ · 2 unmodified lines | |||
| 3 | The plugin deliberately uses only the public Hermes plugin and platform-adapter | 3 | The plugin deliberately uses only the public Hermes plugin and platform-adapter |
| 4 | surfaces audited at Hermes Agent upstream commit `62e07223` (v0.19.0). | 4 | surfaces audited at Hermes Agent upstream commit `62e07223` (v0.19.0). |
| 5 | 5 | ||
| 6 | Scope note: this file inventories **upstream Hermes** surfaces only. T3-side | ||
| 7 | machinery the plugin talks to over the wire — `withHermesConfig`, the broker's | ||
| 8 | generation fencing, `getOrCreateHomeThread` — is not a Python concern and is | ||
| 9 | documented on the T3 side; only the wire contract those produce appears here. | ||
| 10 | |||
| 6 | Audited surfaces, all present at that commit: | 11 | Audited 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 | ||
| 38 | This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn | 43 | Home-channel surfaces, added for protocol v3: |
| 39 | recovery in `session.ready` and authoritative `content.snapshot` replacement; | 44 | |
| 40 | older 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 | |||
| 69 | This inventory describes gateway wire protocol v3. Protocol v2 added active-turn | ||
| 70 | recovery in `session.ready` and authoritative `content.snapshot` replacement; v3 | ||
| 71 | adds `role` on `connection.hello`, `homeThreadId` on `connection.accepted`, and | ||
| 72 | the `home.deliver` / `home.deliver.ack` pair. Older server/plugin pairs are | ||
| 73 | rejected during the handshake — the version policy stays fail-closed. | ||
| 41 | 74 | ||
| 42 | ## Mapped in the initial scope | 75 | ## 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 limitations | 93 | ## 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 correlated | 229 | 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-stability | 231 | - 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 documents | 268 | `finalize` reads like "last edit of the response", and the base class documents |
| 224 | it that way (`gateway/platforms/base.py:3178-3186`). It is **not** a turn | 269 | it that way (`gateway/platforms/base.py:3176-3183`). It is **not** a turn |
| 225 | boundary. Two upstream paths set it mid-turn: | 270 | boundary. Two upstream paths set it mid-turn: |
| 226 | 271 | ||
| 227 | 1. **The tool-progress loop.** When an adapter declares | 272 | 1. **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 and | 311 | `turn.completed`. That is the opposite failure mode from the original bug and |
| 267 | would show up as spinners that never resolve, not truncated answers. | 312 | would show up as spinners that never resolve, not truncated answers. |
| 268 | 313 | ||
| 314 | ## Home-channel delivery: the gate is provenance, not turn absence | ||
| 315 | |||
| 316 | Hermes-initiated output — cron results, `send_message` with a bare `t3` target, | ||
| 317 | gateway lifecycle notices, `/handoff t3` — has no T3-issued turn to stream into. | ||
| 318 | It is emitted as `home.deliver` against the instance's durable home thread. | ||
| 319 | |||
| 320 | ### The deadlock this design exists to avoid | ||
| 321 | |||
| 322 | The naive rule — "no active turn for this thread → deliver" — is wrong, and | ||
| 323 | wrong in the same keyed-off-the-wrong-signal way as the `finalize` bug above. | ||
| 324 | When the home thread itself has a live user turn, a cron delivery targeting it | ||
| 325 | would fall into the active-turn path, stream as that turn's assistant content, | ||
| 326 | and — because final cron deliveries arrive notify-stamped via | ||
| 327 | `_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete the user's | ||
| 328 | live turn with the cron output as its answer**. | ||
| 329 | |||
| 330 | The discriminator is `HERMES_SESSION_KEY`. The gateway binds it onto the turn's | ||
| 331 | context 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 | ||
| 333 | inside 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 | ||
| 336 | cleared (`cron/scheduler.py:3066-3091`), and lifecycle broadcasts run in no | ||
| 337 | session at all. | ||
| 338 | |||
| 339 | `_is_proactive_delivery` therefore decides in this order: | ||
| 340 | |||
| 341 | 1. 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. | ||
| 343 | 2. 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. | ||
| 346 | 3. Home thread, no active turn → delivery. There is nothing it could belong to. | ||
| 347 | 4. 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 | |||
| 354 | Ordering inside `send()` is load-bearing: `_capture_steer_control_response` | ||
| 355 | stays first, because steer acknowledgements arrive with `notify=True` and must | ||
| 356 | never be read as deliveries. | ||
| 357 | |||
| 358 | `edit_message` has **no** proactive branch and keeps returning `"no active T3 | ||
| 359 | turn"` outside a turn. A delivery is an atomic document, not a streaming | ||
| 360 | surface. 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 | |||
| 365 | All 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 | ||
| 409 | blob 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` | ||
| 412 | so a running gateway needs no restart. A hand-edited value is overwritten on | ||
| 413 | the next reconnect — documented in the README. A read-only or managed `.env` | ||
| 414 | degrades to the in-process mirror only: routing works for the life of the | ||
| 415 | process and re-reconciles on the next connect. | ||
| 416 | |||
| 417 | ### Queue and state location | ||
| 418 | |||
| 419 | The plugin previously persisted nothing to disk. It now keeps one JSONL outbox | ||
| 420 | at `<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 | ||
| 424 | adapter state (`plugins/platforms/discord/adapter.py:52`, `:272`, `:1694`). | ||
| 425 | Resolving through that accessor rather than `~/.hermes` makes the queue | ||
| 426 | profile-scoped: a second profile cannot replay another profile's deliveries | ||
| 427 | into its own home thread. | ||
| 428 | |||
| 429 | Correctness 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 | ||
| 431 | that died before writing, a plugin restart — leaves the entry to be replayed, | ||
| 432 | which is safe because T3 dedupes on `deliveryId`. Acking before the durable | ||
| 433 | write on the server side would break this. The queue is capped at 300 entries | ||
| 434 | and drops oldest-first with a logged warning; one flush replays at most 50 | ||
| 435 | entries 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. | ||
| 440 | The hooks are process-global, so a cron job running tools while exactly one T3 | ||
| 441 | turn happens to be live would resolve through that fallback and paint the cron | ||
| 442 | job's tool calls into an unrelated live conversation. Cron runs are identifiable | ||
| 443 | by the `cron_<job>_<timestamp>` session id the scheduler mints | ||
| 444 | (`cron/scheduler.py:3017`, passed to the agent at `:3484`) — the exact value the | ||
| 445 | hooks receive. Upstream treats the same routing hazard as real, clearing the | ||
| 446 | process-global session env vars for it (`cron/scheduler.py:3066-3091`). A cron | ||
| 447 | job's activity belongs to the eventual `home.deliver`, never to a live turn. | ||
| 448 | |||
| 449 | Prefix matching carries the usual drift risk: if upstream renames the shape, | ||
| 450 | this degrades to the previous behaviour (cron tool rows may again be | ||
| 451 | misattributed to a sole live turn) rather than breaking anything. | ||
| 452 | |||
| 269 | ## Tool-progress chrome: the `format_tool_event` override is not the defence | 453 | ## Tool-progress chrome: the `format_tool_event` override is not the defence |
| 270 | 454 | ||
| 271 | The plugin overrides `format_tool_event` to return `None` | 455 | The plugin overrides `format_tool_event` to return `None` |
| @@ -4,7 +4,7 @@ Experimental Hermes platform plugin for connecting one already-running Hermes · 3 unmodified lines | |||
| 4 | process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes | 4 | process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes |
| 5 | does not need to listen on a public port. | 5 | does not need to listen on a public port. |
| 6 | 6 | ||
| 7 | The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated | 7 | The gateway wire protocol is v3. The T3 server and Hermes plugin must be updated |
| 8 | together; mismatched versions fail the connection handshake closed. | 8 | together; mismatched versions fail the connection handshake closed. |
| 9 | 9 | ||
| 10 | Each T3 thread maps deterministically to one Hermes gateway session. A new T3 | 10 | Each 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 | |||
| 54 | truthful label in its picker. It is read-only — Hermes owns model selection — | 54 | truthful label in its picker. It is read-only — Hermes owns model selection — |
| 55 | and is omitted entirely if it cannot be read. | 55 | and is omitted entirely if it cannot be read. |
| 56 | 56 | ||
| 57 | ## The Home thread | ||
| 58 | |||
| 59 | Every enrolled instance gets one **Home** thread in T3, created automatically — | ||
| 60 | there is nothing to set up and nothing to choose. It receives all of Hermes' | ||
| 61 | proactive output: cron results (`deliver=t3`), the agent's `send_message` tool | ||
| 62 | with a bare `t3` target, gateway online/shutdown notices, and `/handoff t3`. | ||
| 63 | Replying 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 | ||
| 66 | plugin writes that variable itself: T3 republishes the home thread id on every | ||
| 67 | successful handshake, and the plugin compares and persists it with Hermes' | ||
| 68 | profile-aware `save_env_value` helper. A hand-edited `T3_HOME_CHANNEL` will | ||
| 69 | therefore be **overwritten on the next reconnect** — to move the Home thread, | ||
| 70 | change it in T3, not in `.env`. (`/sethome` is likewise inert for this platform: | ||
| 71 | the designation is fixed, so Hermes' "set a home channel" nudge is suppressed.) | ||
| 72 | |||
| 73 | Deliveries are durable in both directions. Each one is written to a small JSONL | ||
| 74 | queue at `<hermes home>/gateway/t3_home_delivery_queue.jsonl` before it is sent | ||
| 75 | and removed only once T3 acknowledges it, so nothing is lost if either side | ||
| 76 | restarts mid-flight; queued deliveries flush on the next connect, and T3 | ||
| 77 | deduplicates so a replay is harmless. The queue is capped and drops oldest-first | ||
| 78 | with a logged warning rather than growing without bound. | ||
| 79 | |||
| 80 | Cron works whether or not the gateway is co-resident. When `hermes cron` runs in | ||
| 81 | its own process there is no live adapter, so the plugin dials T3 over a | ||
| 82 | short-lived delivery connection — authenticated the same way, but never | ||
| 83 | registered 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 | ||
| 85 | still reports success. | ||
| 86 | |||
| 57 | ## Initial scope | 87 | ## Initial scope |
| 58 | 88 | ||
| 59 | - Text input and live assistant streaming | 89 | - 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 backoff | 96 | - Reconnect with bounded backoff |
| 67 | - Version-incompatible and revoked credentials fail closed | 97 | - Version-incompatible and revoked credentials fail closed |
| 68 | 98 | ||
| 69 | T3 threads are session-only in the initial scope. The adapter suppresses Hermes' exact | 99 | - Proactive delivery into the Home thread: cron, `send_message`, lifecycle |
| 70 | first-chat `/sethome` notice without assigning a home channel or redirecting | 100 | notices, `/handoff` — with a durable queue and out-of-process cron support |
| 71 | cron and cross-platform delivery. | 101 | |
| 102 | Non-home T3 threads remain session-only: Hermes cannot message them unprompted, | ||
| 103 | and an unsolicited send to one still fails with `no active T3 turn`. | ||
| 72 | 104 | ||
| 73 | Attachments intentionally advertise `false`. Adding bounded image/file input is | 105 | Attachments intentionally advertise `false`. Adding bounded image/file input is |
| 74 | the first post-stability feature and should not reuse arbitrary raw payloads. | 106 | the first post-stability feature and should not reuse arbitrary raw payloads. |
| @@ -10,6 +10,8 @@ from .adapter import ( · 9 unmodified lines | |||
| 10 | validate_config, | 10 | validate_config, |
| 11 | ) | 11 | ) |
| 12 | from .cli import register_cli, t3_command | 12 | from .cli import register_cli, t3_command |
| 13 | from .home import HOME_CHANNEL_ENV, standalone_send | ||
| 14 | from .home import HOME_CHANNEL_ENV, standalone_send | ||
| 13 | 15 | ||
| 14 | 16 | ||
| 15 | def _pre_tool_call( | 17 | def _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, |
| @@ -23,6 +23,15 @@ from gateway.session import build_session_key · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV | 24 | from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV |
| 25 | from .connection import T3GatewayConnection, dependency_available | 25 | from .connection import T3GatewayConnection, dependency_available |
| 26 | from .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 | ) | ||
| 26 | from .protocol import ( | 35 | from .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 weak | 152 | # 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 one | 153 | # 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 captured | 217 | 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 = 40 | 447 | 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-threaded | 1146 | 3. The sole active turn, when exactly one exists — a single-threaded |
| 967 | Hermes process has no ambiguity to resolve, and dropping the | 1147 | 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 None | 1175 | 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 | @staticmethod | 1189 | @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 | ||
| 1184 | def env_enablement() -> dict[str, str] | None: | 1392 | def 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 None | 1419 | 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 | ||
| @@ -21,6 +21,8 @@ except ImportError: # pragma: no cover - Hermes currently installs websockets · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | MessageHandler = Callable[[dict[str, Any]], Awaitable[None]] | 22 | MessageHandler = Callable[[dict[str, Any]], Awaitable[None]] |
| 23 | StateHandler = Callable[[bool, str | None], Awaitable[None] | None] | 23 | StateHandler = Callable[[bool, str | None], Awaitable[None] | None] |
| 24 | AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]] | ||
| 25 | AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]] | ||
| 24 | 26 | ||
| 25 | 27 | ||
| 26 | class ConnectionRejected(RuntimeError): | 28 | class 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_id | 175 | self.instance_id = instance_id |
| @@ -172,6 +177,7 @@ class T3GatewayConnection: · 1 unmodified line | |||
| 172 | self.hermes_version = hermes_version | 177 | self.hermes_version = hermes_version |
| 173 | self._on_message = on_message | 178 | self._on_message = on_message |
| 174 | self._on_state = on_state | 179 | self._on_state = on_state |
| 180 | self._on_accepted = on_accepted | ||
| 175 | self._socket: Any = None | 181 | self._socket: Any = None |
| 176 | self._supervisor: asyncio.Task[None] | None = None | 182 | 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 = socket | 274 | 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.0 | 292 | 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 | return | 365 | return |
| @@ -0,0 +1,605 @@ | |||
| 1 | """Home-channel delivery: durable queue, classification, standalone sender. | ||
| 2 | |||
| 3 | Hermes-initiated output — cron results, the agent's `send_message` tool with a | ||
| 4 | bare `t3` target, gateway lifecycle notices, `/handoff t3` — has no T3-issued | ||
| 5 | turn to stream into. It is delivered as a `home.deliver` frame against the | ||
| 6 | instance's durable **home thread**, whose id T3 owns and republishes on every | ||
| 7 | `connection.accepted`. | ||
| 8 | |||
| 9 | Three 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 | |||
| 21 | from __future__ import annotations | ||
| 22 | |||
| 23 | import json | ||
| 24 | import logging | ||
| 25 | import os | ||
| 26 | import threading | ||
| 27 | from pathlib import Path | ||
| 28 | from typing import Any | ||
| 29 | |||
| 30 | from .protocol import ( | ||
| 31 | HOME_DELIVERY_KINDS, | ||
| 32 | delivery_id, | ||
| 33 | home_deliver, | ||
| 34 | iso_now, | ||
| 35 | ) | ||
| 36 | |||
| 37 | logger = logging.getLogger(__name__) | ||
| 38 | |||
| 39 | try: # POSIX advisory locking; absent on Windows. | ||
| 40 | import fcntl | ||
| 41 | except 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. | ||
| 53 | HOME_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. | ||
| 62 | QUEUE_SUBDIR = "gateway" | ||
| 63 | QUEUE_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. | ||
| 70 | MAX_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. | ||
| 75 | MAX_FLUSH_PER_CONNECT = 50 | ||
| 76 | |||
| 77 | |||
| 78 | def 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 | |||
| 104 | def queue_path() -> Path: | ||
| 105 | return hermes_home() / QUEUE_SUBDIR / QUEUE_FILENAME | ||
| 106 | |||
| 107 | |||
| 108 | def 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 | |||
| 113 | def 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 | |||
| 147 | class 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 | |||
| 353 | def 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 | |||
| 404 | def _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 | |||
| 412 | def 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 | |||
| 440 | async 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 | |||
| 544 | async 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 | |||
| 599 | def _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" | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | name: hermes-t3-gateway | 1 | name: hermes-t3-gateway |
| 2 | label: T3 Code | 2 | label: T3 Code |
| 3 | kind: platform | 3 | kind: platform |
| 4 | version: 0.2.0 | 4 | version: 0.3.0 |
| 5 | description: > | 5 | description: > |
| 6 | Experimental outbound gateway that exposes one Hermes Agent process as a | 6 | 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. |
| @@ -7,10 +7,17 @@ import uuid · 6 unmodified lines | |||
| 7 | from datetime import datetime, timezone | 7 | from datetime import datetime, timezone |
| 8 | from typing import Any | 8 | from typing import Any |
| 9 | 9 | ||
| 10 | PROTOCOL_VERSION = 2 | 10 | PROTOCOL_VERSION = 3 |
| 11 | PLUGIN_VERSION = "0.2.0" | 11 | PLUGIN_VERSION = "0.3.0" |
| 12 | WEBSOCKET_PATH = "/api/hermes-gateway/ws" | 12 | WEBSOCKET_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. | ||
| 19 | CONNECTION_ROLES = frozenset({"gateway", "delivery"}) | ||
| 20 | |||
| 14 | CAPABILITIES = { | 21 | CAPABILITIES = { |
| 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". | ||
| 50 | HOME_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. | ||
| 56 | MAX_HOME_DELIVERY_LABEL_CHARS = 200 | ||
| 57 | MAX_HOME_DELIVERY_TEXT_CHARS = 120_000 | ||
| 58 | |||
| 40 | # Ceiling on a single skill body crossing the wire. Skill markdown is | 59 | # Ceiling on a single skill body crossing the wire. Skill markdown is |
| 41 | # human-authored documentation, not data: 512 KiB is far past any real | 60 | # 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 | ||
| 73 | def 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 | |||
| 83 | def 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 | |||
| 54 | def iso_now() -> str: | 93 | def 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 a | 326 | # 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 hello | 330 | return hello |
| 280 | 331 | ||
| 281 | 332 | ||
| 333 | def 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 | |||
| 377 | def 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 | |||
| 282 | def protocol_error( | 421 | def protocol_error( |
| 283 | code: str, | 422 | code: str, |
| 284 | message: str, | 423 | message: str, |
| @@ -7,6 +7,7 @@ import enum · 6 unmodified lines | |||
| 7 | import importlib.util | 7 | import importlib.util |
| 8 | import pathlib | 8 | import pathlib |
| 9 | import sys | 9 | import sys |
| 10 | import tempfile | ||
| 10 | import types | 11 | import types |
| 11 | import unittest | 12 | import unittest |
| 12 | import unittest.mock | 13 | import 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] = package | 149 | 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 | ||
| 160 | adapter_module = load_plugin_modules() | 161 | adapter_module = load_plugin_modules() |
| 161 | protocol_module = sys.modules[f"{PACKAGE}.protocol"] | 162 | protocol_module = sys.modules[f"{PACKAGE}.protocol"] |
| 163 | home_module = sys.modules[f"{PACKAGE}.home"] | ||
| 162 | 164 | ||
| 163 | 165 | ||
| 164 | @contextlib.contextmanager | 166 | @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 | |||
| 1589 | class 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 | |||
| 1934 | class 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 | ||
| 1539 | if __name__ == "__main__": | 1977 | if __name__ == "__main__": |
| 1540 | unittest.main() | 1978 | unittest.main() |
| @@ -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 | ||
| @@ -0,0 +1,473 @@ | |||
| 1 | from __future__ import annotations | ||
| 2 | |||
| 3 | import importlib.util | ||
| 4 | import json | ||
| 5 | import pathlib | ||
| 6 | import sys | ||
| 7 | import tempfile | ||
| 8 | import types | ||
| 9 | import unittest | ||
| 10 | import unittest.mock | ||
| 11 | |||
| 12 | ROOT = pathlib.Path(__file__).resolve().parents[1] | ||
| 13 | PACKAGE = "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. | ||
| 18 | package = types.ModuleType(PACKAGE) | ||
| 19 | package.__path__ = [str(ROOT)] | ||
| 20 | sys.modules.setdefault(PACKAGE, package) | ||
| 21 | |||
| 22 | for _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 | |||
| 31 | home = sys.modules[f"{PACKAGE}.home"] | ||
| 32 | protocol = sys.modules[f"{PACKAGE}.protocol"] | ||
| 33 | connection = sys.modules[f"{PACKAGE}.connection"] | ||
| 34 | |||
| 35 | |||
| 36 | def make_delivery(text: str, thread_id: str = "home-thread", **kwargs): | ||
| 37 | return home.build_delivery(thread_id=thread_id, text=text, **kwargs) | ||
| 38 | |||
| 39 | |||
| 40 | class 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 | |||
| 153 | class 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 | |||
| 208 | class 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 | |||
| 269 | class 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 | |||
| 314 | class 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 | |||
| 428 | class 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 | |||
| 472 | if __name__ == "__main__": | ||
| 473 | unittest.main() | ||
| @@ -66,7 +66,7 @@ def fake_hermes_skills(skills_list=None, skill_view=None): · 65 unmodified lines | |||
| 66 | 66 | ||
| 67 | 67 | ||
| 68 | class ProtocolTests(unittest.TestCase): | 68 | class 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 | ||
| @@ -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 = { |
| @@ -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 a | 360 | // Leaving the "running" session status is the turn-end signal: settle a |
| @@ -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 | }); |
| @@ -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 | */ | ||
| 72 | export function sortThreads<T extends { readonly id: string } & ThreadSortInput>( | 85 | export 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 | }), |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | PROVIDER_DISPLAY_NAMES, | 8 | PROVIDER_DISPLAY_NAMES, |
| 9 | } from "./model.ts"; | 9 | } from "./model.ts"; |
| 10 | import { | 10 | import { |
| 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 | ||
| 224 | describe("T3 to Hermes v2 messages", () => { | 225 | describe("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 | |||
| 373 | describe("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 | ||
| 321 | describe("Hermes to T3 v2 events", () => { | 414 | describe("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", |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | |||
| 7 | * | 7 | * |
| 8 | * @module hermesGateway | 8 | * @module hermesGateway |
| 9 | */ | 9 | */ |
| 10 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 11 | 12 | ||
| 12 | import { | 13 | import { |
| @@ -21,7 +22,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat · 8 unmodified lines | |||
| 21 | import { ProviderInstanceId } from "./providerInstance.ts"; | 22 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 22 | import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; | 23 | import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; |
| 23 | 24 | ||
| 24 | export const HERMES_GATEWAY_PROTOCOL_VERSION = 2 as const; | 25 | export const HERMES_GATEWAY_PROTOCOL_VERSION = 3 as const; |
| 25 | 26 | ||
| 26 | export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); | 27 | export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); |
| 27 | export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; | 28 | export 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 can | 89 | * This deliberately permits capability shapes from a newer protocol so T3 can |
| 89 | * return a structured `version-incompatible` rejection instead of failing the | 90 | * return a structured `version-incompatible` rejection instead of failing the |
| 90 | * WebSocket frame decoder. Accepted v2 connections must subsequently validate | 91 | * WebSocket frame decoder. Accepted connections must subsequently validate |
| 91 | * this advertisement with `HermesGatewayCapabilities`. | 92 | * this advertisement with `HermesGatewayCapabilities`. |
| 92 | */ | 93 | */ |
| 93 | export const HermesGatewayHelloCapabilities = Schema.Struct({ | 94 | export const HermesGatewayHelloCapabilities = Schema.Struct({ |
| @@ -256,10 +257,23 @@ export const HermesGatewayAuthentication = Schema.Union([ · 162 unmodified lines | |||
| 256 | ]); | 257 | ]); |
| 257 | export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type; | 258 | export 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 | */ | ||
| 270 | export const HermesGatewayConnectionRole = Schema.Literals(["gateway", "delivery"]); | ||
| 271 | export type HermesGatewayConnectionRole = typeof HermesGatewayConnectionRole.Type; | ||
| 272 | |||
| 259 | /** | 273 | /** |
| 260 | * `protocolVersion` accepts any positive integer at the initial boundary so | 274 | * `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 | */ |
| 264 | export const HermesGatewayConnectionHello = Schema.Struct({ | 278 | export 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 | }); |
| 282 | export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type; | 302 | export 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 | }); |
| 292 | export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type; | 323 | export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type; |
| 293 | 324 | ||
| @@ -633,6 +664,68 @@ export const HermesGatewayPong = Schema.Struct({ · 339 unmodified lines | |||
| 633 | }); | 664 | }); |
| 634 | export type HermesGatewayPong = typeof HermesGatewayPong.Type; | 665 | export 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 | */ | ||
| 671 | export const HermesGatewayDeliveryId = TrimmedNonEmptyString.pipe( | ||
| 672 | Schema.brand("HermesGatewayDeliveryId"), | ||
| 673 | ); | ||
| 674 | export 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 | */ | ||
| 685 | export const HermesGatewayHomeDeliveryKind = Schema.Literals([ | ||
| 686 | "cron", | ||
| 687 | "message", | ||
| 688 | "lifecycle", | ||
| 689 | "handoff", | ||
| 690 | "other", | ||
| 691 | ]); | ||
| 692 | export 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 | */ | ||
| 701 | export 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 | }); | ||
| 716 | export 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 | */ | ||
| 722 | export const HermesGatewayHomeDeliverAck = Schema.Struct({ | ||
| 723 | type: Schema.Literal("home.deliver.ack"), | ||
| 724 | protocolVersion: HermesGatewayProtocolVersion, | ||
| 725 | deliveryId: HermesGatewayDeliveryId, | ||
| 726 | }); | ||
| 727 | export type HermesGatewayHomeDeliverAck = typeof HermesGatewayHomeDeliverAck.Type; | ||
| 728 | |||
| 636 | export const HermesGatewayProtocolErrorCode = Schema.Literals([ | 729 | export 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 | ]); |
| 670 | export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type; | 764 | export 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 | ]); |
| 694 | export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type; | 789 | export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type; |
| 695 | 790 | ||
| @@ -238,6 +238,27 @@ export type OrchestrationProject = typeof OrchestrationProject.Type; · 237 unmodified lines | |||
| 238 | export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]); | 238 | export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]); |
| 239 | export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type; | 239 | export 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 | */ | ||
| 250 | export 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 | }); | ||
| 260 | export type OrchestrationMessageNotification = typeof OrchestrationMessageNotification.Type; | ||
| 261 | |||
| 241 | export const OrchestrationMessage = Schema.Struct({ | 262 | export 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 | }); |
| 418 | export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; | 444 | export 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 | */ | ||
| 456 | export const OrchestrationThreadNotificationSummary = Schema.Struct({ | ||
| 457 | kind: OrchestrationMessageNotification.fields.kind, | ||
| 458 | label: TrimmedNonEmptyString, | ||
| 459 | deliveredAt: IsoDateTime, | ||
| 460 | }); | ||
| 461 | export type OrchestrationThreadNotificationSummary = | ||
| 462 | typeof OrchestrationThreadNotificationSummary.Type; | ||
| 463 | |||
| 420 | export const OrchestrationThreadShell = Schema.Struct({ | 464 | export 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 | }); |
| 447 | export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; | 497 | export 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 | */ | ||
| 945 | const 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 | |||
| 886 | const ThreadRevertCompleteCommand = Schema.Struct({ | 958 | const 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 | ]); |
| 904 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; | 977 | export 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 | ]); |
| 940 | export type OrchestrationEventType = typeof OrchestrationEventType.Type; | 1014 | export 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 | */ | ||
| 1264 | export 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 | |||
| 1182 | export const OrchestrationEventMetadata = Schema.Struct({ | 1273 | export 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 | ]); |
| 1335 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; | 1431 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; |
| 1336 | 1432 | ||
| @@ -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", |
| @@ -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 | }); |
| 207 | export type ServerProvider = typeof ServerProvider.Type; | 216 | export type ServerProvider = typeof ServerProvider.Type; |
| 208 | 217 | ||
| @@ -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 | }); |
| 433 | export type HermesSettings = typeof HermesSettings.Type; | 446 | export type HermesSettings = typeof HermesSettings.Type; |
| 434 | 447 | ||
| @@ -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, |
| @@ -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 | ||
| 119 | function 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 | */ | ||
| 135 | function 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 | |||
| 162 | function 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": |