History

fix: harden Hermes gateway streaming lifecycle

Ben Davis committed 3 months ago

Commit 5faee611 parent 0b837b829 files · +1884 −137Browse files

Unified · Split

29 files
  1. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +111 −0
  2. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +3 −0
  3. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +43 −0
  4. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +1 −2
  5. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +437 −1
  6. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +102 −0
  7. apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts +73 −0
  8. apps/server/src/orchestration/decider.ts +28 −0
  9. apps/server/src/orchestration/projector.test.ts +77 −0
  10. apps/server/src/orchestration/projector.ts +7 −4
  11. apps/server/src/provider/Layers/HermesAdapter.test.ts +211 −9
  12. apps/server/src/provider/Layers/HermesAdapter.ts +34 −31
  13. apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +4 −4
  14. apps/server/src/provider/Layers/ProviderService.test.ts +95 −0
  15. apps/server/src/provider/Layers/ProviderService.ts +93 −1
  16. integrations/hermes-t3-gateway/COMPATIBILITY.md +14 −8
  17. integrations/hermes-t3-gateway/README.md +9 −1
  18. integrations/hermes-t3-gateway/adapter.py +41 −11
  19. integrations/hermes-t3-gateway/connection.py +2 −2
  20. integrations/hermes-t3-gateway/plugin.yaml +1 −1
  21. integrations/hermes-t3-gateway/protocol.py +2 −2
  22. integrations/hermes-t3-gateway/tests/test_adapter.py +330 −13
  23. integrations/hermes-t3-gateway/tests/test_connection.py +22 −2
  24. integrations/hermes-t3-gateway/tests/test_protocol.py +5 −4
  25. packages/contracts/src/hermesGateway.test.ts +64 −37
  26. packages/contracts/src/hermesGateway.ts +17 −4
  27. packages/contracts/src/orchestration.ts +12 −0
  28. packages/contracts/src/providerRuntime.test.ts +28 −0
  29. packages/contracts/src/providerRuntime.ts +18 −0
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+111−0
@@ -1652,6 +1652,117 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 1651 unmodified lines
16521652 }),
16531653 );
16541654
1655+ it.effect("replaces an assistant snapshot between legacy appended deltas", () =>
1656+ Effect.gen(function* () {
1657+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
1658+ const eventStore = yield* OrchestrationEventStore;
1659+ const sql = yield* SqlClient.SqlClient;
1660+ const now = "2026-07-24T12:00:00.000Z";
1661+ const projectId = ProjectId.make("project-replacement");
1662+ const threadId = ThreadId.make("thread-replacement");
1663+ const messageId = MessageId.make("assistant-replacement");
1664+
1665+ yield* eventStore.append({
1666+ type: "project.created",
1667+ eventId: EventId.make("evt-replacement-1"),
1668+ aggregateKind: "project",
1669+ aggregateId: projectId,
1670+ occurredAt: now,
1671+ commandId: CommandId.make("cmd-replacement-1"),
1672+ causationEventId: null,
1673+ correlationId: CorrelationId.make("cmd-replacement-1"),
1674+ metadata: {},
1675+ payload: {
1676+ projectId,
1677+ title: "Project Replacement",
1678+ workspaceRoot: "/tmp/project-replacement",
1679+ defaultModelSelection: null,
1680+ scripts: [],
1681+ createdAt: now,
1682+ updatedAt: now,
1683+ },
1684+ });
1685+
1686+ yield* eventStore.append({
1687+ type: "thread.created",
1688+ eventId: EventId.make("evt-replacement-2"),
1689+ aggregateKind: "thread",
1690+ aggregateId: threadId,
1691+ occurredAt: now,
1692+ commandId: CommandId.make("cmd-replacement-2"),
1693+ causationEventId: null,
1694+ correlationId: CorrelationId.make("cmd-replacement-2"),
1695+ metadata: {},
1696+ payload: {
1697+ threadId,
1698+ projectId,
1699+ title: "Thread Replacement",
1700+ modelSelection: {
1701+ instanceId: ProviderInstanceId.make("hermes"),
1702+ model: "hermes",
1703+ },
1704+ runtimeMode: "full-access",
1705+ branch: null,
1706+ worktreePath: null,
1707+ createdAt: now,
1708+ updatedAt: now,
1709+ },
1710+ });
1711+
1712+ const appendMessage = (input: {
1713+ eventNumber: number;
1714+ text: string;
1715+ streaming: boolean;
1716+ textOperation?: "replace";
1717+ }) =>
1718+ eventStore.append({
1719+ type: "thread.message-sent",
1720+ eventId: EventId.make(`evt-replacement-${input.eventNumber}`),
1721+ aggregateKind: "thread",
1722+ aggregateId: threadId,
1723+ occurredAt: now,
1724+ commandId: CommandId.make(`cmd-replacement-${input.eventNumber}`),
1725+ causationEventId: null,
1726+ correlationId: CorrelationId.make(`cmd-replacement-${input.eventNumber}`),
1727+ metadata: {},
1728+ payload: {
1729+ threadId,
1730+ messageId,
1731+ role: "assistant",
1732+ text: input.text,
1733+ turnId: null,
1734+ streaming: input.streaming,
1735+ ...(input.textOperation !== undefined ? { textOperation: input.textOperation } : {}),
1736+ createdAt: now,
1737+ updatedAt: now,
1738+ },
1739+ });
1740+
1741+ yield* appendMessage({ eventNumber: 3, text: "Hello wor", streaming: true });
1742+ yield* appendMessage({
1743+ eventNumber: 4,
1744+ text: "Hello there",
1745+ streaming: true,
1746+ textOperation: "replace",
1747+ });
1748+ yield* appendMessage({ eventNumber: 5, text: "!", streaming: true });
1749+ yield* appendMessage({ eventNumber: 6, text: "", streaming: false });
1750+
1751+ yield* projectionPipeline.bootstrap;
1752+
1753+ const messageRows = yield* sql<{ readonly text: string; readonly isStreaming: unknown }>`
1754+ SELECT
1755+ text,
1756+ is_streaming AS "isStreaming"
1757+ FROM projection_thread_messages
1758+ WHERE message_id = ${messageId}
1759+ `;
1760+ assert.equal(messageRows.length, 1);
1761+ assert.equal(messageRows[0]?.text, "Hello there!");
1762+ assert.isFalse(Boolean(messageRows[0]?.isStreaming));
1763+ }),
1764+ );
1765+
16551766 it.effect(
16561767 "resolves turn-count conflicts when checkpoint completion rewrites provisional turns",
16571768 () =>
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3−0
@@ -887,6 +887,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 886 unmodified lines
887887 const nextText = Option.match(existingMessage, {
888888 onNone: () => event.payload.text,
889889 onSome: (message) => {
890+ if (event.payload.textOperation === "replace") {
891+ return event.payload.text;
892+ }
890893 if (event.payload.streaming) {
891894 return `${message.text}${event.payload.text}`;
892895 }
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+43−0
@@ -477,6 +477,49 @@ describe("ProviderCommandReactor", () => { · 476 unmodified lines
477477 expect(thread?.session?.runtimeMode).toBe("approval-required");
478478 });
479479
480+ effectIt.effect("adopts a recovered active provider turn while binding a pending start", () =>
481+ Effect.gen(function* () {
482+ const recoveredTurnId = asTurnId("turn-recovered-during-start");
483+ const harness = yield* Effect.promise(() =>
484+ createHarness({
485+ startSessionEffect: (session) =>
486+ Effect.succeed({
487+ ...session,
488+ status: "running",
489+ activeTurnId: recoveredTurnId,
490+ }),
491+ }),
492+ );
493+ const now = "2026-01-01T00:00:00.000Z";
494+
495+ yield* harness.engine.dispatch({
496+ type: "thread.turn.start",
497+ commandId: CommandId.make("cmd-turn-start-recovered-provider-turn"),
498+ threadId: ThreadId.make("thread-1"),
499+ message: {
500+ messageId: asMessageId("user-message-recovered-provider-turn"),
501+ role: "user",
502+ text: "steer the recovered turn",
503+ attachments: [],
504+ },
505+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
506+ runtimeMode: "approval-required",
507+ createdAt: now,
508+ });
509+
510+ yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));
511+ const readModel = yield* Effect.promise(() => harness.readModel());
512+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
513+
514+ expect(thread?.session?.status).toBe("running");
515+ expect(thread?.session?.activeTurnId).toBe(recoveredTurnId);
516+ expect(thread?.latestTurn).toMatchObject({
517+ turnId: recoveredTurnId,
518+ state: "running",
519+ });
520+ }),
521+ );
522+
480523 effectIt.effect("projects starting before a slow provider session finishes", () =>
481524 Effect.gen(function* () {
482525 const releaseStart = yield* Deferred.make<void>();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+1−2
@@ -527,8 +527,7 @@ const make = Effect.gen(function* () { · 526 unmodified lines
527527 providerName: session.provider,
528528 providerInstanceId: session.providerInstanceId,
529529 runtimeMode: desiredRuntimeMode,
530− // Provider turn ids are not orchestration turn ids.
531− activeTurnId: null,
530+ activeTurnId: session.activeTurnId ?? null,
532531 lastError: session.lastError ?? null,
533532 updatedAt: session.updatedAt,
534533 },
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+437−1
@@ -27,6 +27,7 @@ import * as Effect from "effect/Effect"; · 26 unmodified lines
2727 import * as Exit from "effect/Exit";
2828 import * as Layer from "effect/Layer";
2929 import * as ManagedRuntime from "effect/ManagedRuntime";
30+import * as Option from "effect/Option";
3031 import * as PubSub from "effect/PubSub";
3132 import * as Scope from "effect/Scope";
3233 import * as Stream from "effect/Stream";
@@ -35,7 +36,9 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; · 2 unmodified lines
3536
3637 import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
3738 import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
39+import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
3840 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
41+import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
3942 import {
4043 ProviderService,
4144 type ProviderServiceShape,
@@ -192,7 +195,10 @@ async function waitForThread( · 150 unmodified lines
192195
193196 describe("ProviderRuntimeIngestion", () => {
194197 let runtime: ManagedRuntime.ManagedRuntime<
195− OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery,
198+ | OrchestrationEngineService
199+ | ProviderRuntimeIngestionService
200+ | ProjectionSnapshotQuery
201+ | ProjectionTurnRepository,
196202 unknown
197203 > | null = null;
198204 let scope: Scope.Closeable | null = null;
@@ -234,9 +240,13 @@ describe("ProviderRuntimeIngestion", () => { · 35 unmodified lines
234240 Layer.provide(RepositoryIdentityResolver.layer),
235241 Layer.provide(SqlitePersistenceMemory),
236242 );
243+ const projectionTurnLayer = ProjectionTurnRepositoryLive.pipe(
244+ Layer.provide(SqlitePersistenceMemory),
245+ );
237246 const layer = ProviderRuntimeIngestionLive.pipe(
238247 Layer.provideMerge(orchestrationLayer),
239248 Layer.provideMerge(projectionSnapshotLayer),
249+ Layer.provideMerge(projectionTurnLayer),
240250 Layer.provideMerge(SqlitePersistenceMemory),
241251 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
242252 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
@@ -247,6 +257,7 @@ describe("ProviderRuntimeIngestion", () => { · 4 unmodified lines
247257 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
248258 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
249259 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));
260+ const turnRepository = await runtime.runPromise(Effect.service(ProjectionTurnRepository));
250261 scope = await Effect.runPromise(Scope.make("sequential"));
251262 await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope)));
252263 const drain = () => Effect.runPromise(ingestion.drain);
@@ -315,6 +326,7 @@ describe("ProviderRuntimeIngestion", () => { · 62 unmodified lines
315326 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
316327 emit: provider.emit,
317328 setProviderSession: provider.setSession,
329+ turnRepository,
318330 drain,
319331 };
320332 }
@@ -946,6 +958,209 @@ describe("ProviderRuntimeIngestion", () => { · 625 unmodified lines
946958 expect(message?.streaming).toBe(false);
947959 });
948960
961+ it("replaces streaming assistant text from authoritative snapshots without changing identity", async () => {
962+ const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } });
963+ const now = "2026-01-01T00:00:00.000Z";
964+ const itemId = asItemId("item-streaming-snapshot");
965+ const turnId = asTurnId("turn-streaming-snapshot");
966+
967+ harness.emit({
968+ type: "content.delta",
969+ eventId: asEventId("evt-streaming-snapshot-delta"),
970+ provider: ProviderDriverKind.make("hermes"),
971+ createdAt: now,
972+ threadId: asThreadId("thread-1"),
973+ turnId,
974+ itemId,
975+ payload: {
976+ streamKind: "assistant_text",
977+ delta: "draft",
978+ },
979+ });
980+ harness.emit({
981+ type: "content.snapshot",
982+ eventId: asEventId("evt-streaming-snapshot-replace"),
983+ provider: ProviderDriverKind.make("hermes"),
984+ createdAt: now,
985+ threadId: asThreadId("thread-1"),
986+ turnId,
987+ itemId,
988+ payload: {
989+ streamKind: "assistant_text",
990+ text: "authoritative",
991+ },
992+ });
993+ harness.emit({
994+ type: "content.delta",
995+ eventId: asEventId("evt-streaming-snapshot-tail"),
996+ provider: ProviderDriverKind.make("hermes"),
997+ createdAt: now,
998+ threadId: asThreadId("thread-1"),
999+ turnId,
1000+ itemId,
1001+ payload: {
1002+ streamKind: "assistant_text",
1003+ delta: " answer",
1004+ },
1005+ });
1006+ harness.emit({
1007+ type: "item.completed",
1008+ eventId: asEventId("evt-streaming-snapshot-completed"),
1009+ provider: ProviderDriverKind.make("hermes"),
1010+ createdAt: now,
1011+ threadId: asThreadId("thread-1"),
1012+ turnId,
1013+ itemId,
1014+ payload: {
1015+ itemType: "assistant_message",
1016+ status: "completed",
1017+ },
1018+ });
1019+
1020+ const thread = await waitForThread(harness.readModel, (entry) =>
1021+ entry.messages.some(
1022+ (message: ProviderRuntimeTestMessage) =>
1023+ message.id === `assistant:${itemId}` &&
1024+ message.text === "authoritative answer" &&
1025+ !message.streaming,
1026+ ),
1027+ );
1028+ expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength(
1029+ 1,
1030+ );
1031+ });
1032+
1033+ it("applies repeated empty snapshots idempotently to a streaming assistant message", async () => {
1034+ const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } });
1035+ const now = "2026-01-01T00:00:00.000Z";
1036+ const itemId = asItemId("item-empty-snapshot");
1037+ const turnId = asTurnId("turn-empty-snapshot");
1038+
1039+ harness.emit({
1040+ type: "content.delta",
1041+ eventId: asEventId("evt-empty-snapshot-delta"),
1042+ provider: ProviderDriverKind.make("hermes"),
1043+ createdAt: now,
1044+ threadId: asThreadId("thread-1"),
1045+ turnId,
1046+ itemId,
1047+ payload: {
1048+ streamKind: "assistant_text",
1049+ delta: "remove me",
1050+ },
1051+ });
1052+ for (const eventId of ["evt-empty-snapshot-one", "evt-empty-snapshot-two"]) {
1053+ harness.emit({
1054+ type: "content.snapshot",
1055+ eventId: asEventId(eventId),
1056+ provider: ProviderDriverKind.make("hermes"),
1057+ createdAt: now,
1058+ threadId: asThreadId("thread-1"),
1059+ turnId,
1060+ itemId,
1061+ payload: {
1062+ streamKind: "assistant_text",
1063+ text: "",
1064+ },
1065+ });
1066+ }
1067+ harness.emit({
1068+ type: "item.completed",
1069+ eventId: asEventId("evt-empty-snapshot-completed"),
1070+ provider: ProviderDriverKind.make("hermes"),
1071+ createdAt: now,
1072+ threadId: asThreadId("thread-1"),
1073+ turnId,
1074+ itemId,
1075+ payload: {
1076+ itemType: "assistant_message",
1077+ status: "completed",
1078+ },
1079+ });
1080+
1081+ const thread = await waitForThread(harness.readModel, (entry) =>
1082+ entry.messages.some(
1083+ (message: ProviderRuntimeTestMessage) =>
1084+ message.id === `assistant:${itemId}` && message.text === "" && !message.streaming,
1085+ ),
1086+ );
1087+ expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength(
1088+ 1,
1089+ );
1090+ });
1091+
1092+ it("authoritatively replaces buffered assistant text before completion", async () => {
1093+ const harness = await createHarness();
1094+ const now = "2026-01-01T00:00:00.000Z";
1095+ const itemId = asItemId("item-buffered-snapshot");
1096+ const turnId = asTurnId("turn-buffered-snapshot");
1097+
1098+ harness.emit({
1099+ type: "content.delta",
1100+ eventId: asEventId("evt-buffered-snapshot-delta"),
1101+ provider: ProviderDriverKind.make("hermes"),
1102+ createdAt: now,
1103+ threadId: asThreadId("thread-1"),
1104+ turnId,
1105+ itemId,
1106+ payload: {
1107+ streamKind: "assistant_text",
1108+ delta: "draft",
1109+ },
1110+ });
1111+ harness.emit({
1112+ type: "content.snapshot",
1113+ eventId: asEventId("evt-buffered-snapshot-replace"),
1114+ provider: ProviderDriverKind.make("hermes"),
1115+ createdAt: now,
1116+ threadId: asThreadId("thread-1"),
1117+ turnId,
1118+ itemId,
1119+ payload: {
1120+ streamKind: "assistant_text",
1121+ text: "final",
1122+ },
1123+ });
1124+ harness.emit({
1125+ type: "content.delta",
1126+ eventId: asEventId("evt-buffered-snapshot-tail"),
1127+ provider: ProviderDriverKind.make("hermes"),
1128+ createdAt: now,
1129+ threadId: asThreadId("thread-1"),
1130+ turnId,
1131+ itemId,
1132+ payload: {
1133+ streamKind: "assistant_text",
1134+ delta: " answer",
1135+ },
1136+ });
1137+ harness.emit({
1138+ type: "item.completed",
1139+ eventId: asEventId("evt-buffered-snapshot-completed"),
1140+ provider: ProviderDriverKind.make("hermes"),
1141+ createdAt: now,
1142+ threadId: asThreadId("thread-1"),
1143+ turnId,
1144+ itemId,
1145+ payload: {
1146+ itemType: "assistant_message",
1147+ status: "completed",
1148+ },
1149+ });
1150+
1151+ const thread = await waitForThread(harness.readModel, (entry) =>
1152+ entry.messages.some(
1153+ (message: ProviderRuntimeTestMessage) =>
1154+ message.id === `assistant:${itemId}` &&
1155+ message.text === "final answer" &&
1156+ !message.streaming,
1157+ ),
1158+ );
1159+ expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength(
1160+ 1,
1161+ );
1162+ });
1163+
9491164 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
9501165 const harness = await createHarness();
9511166 const now = "2026-01-01T00:00:00.000Z";
@@ -1573,6 +1788,85 @@ describe("ProviderRuntimeIngestion", () => { · 621 unmodified lines
15731788 expect(threadAfterSteer.latestTurn?.state).toBe("running");
15741789 });
15751790
1791+ effectIt.effect(
1792+ "consumes a follow-up pending start when Hermes acknowledges the active turn",
1793+ () =>
1794+ Effect.gen(function* () {
1795+ const harness = yield* Effect.promise(() => createHarness());
1796+ const threadId = asThreadId("thread-1");
1797+ const activeTurnId = asTurnId("turn-hermes-active");
1798+ const followUpMessageId = asMessageId("msg-hermes-follow-up");
1799+ const createdAt = "2026-01-01T00:00:00.000Z";
1800+
1801+ harness.setProviderSession({
1802+ provider: ProviderDriverKind.make("hermes"),
1803+ status: "running",
1804+ runtimeMode: "approval-required",
1805+ threadId,
1806+ createdAt,
1807+ updatedAt: createdAt,
1808+ activeTurnId,
1809+ });
1810+ harness.emit({
1811+ type: "turn.started",
1812+ eventId: asEventId("evt-hermes-active-turn-started"),
1813+ provider: ProviderDriverKind.make("hermes"),
1814+ createdAt,
1815+ threadId,
1816+ turnId: activeTurnId,
1817+ });
1818+ yield* Effect.promise(() =>
1819+ waitForThread(
1820+ harness.readModel,
1821+ (thread) =>
1822+ thread.session?.status === "running" && thread.session.activeTurnId === activeTurnId,
1823+ 2_000,
1824+ threadId,
1825+ ),
1826+ );
1827+
1828+ yield* harness.engine.dispatch({
1829+ type: "thread.turn.start",
1830+ commandId: CommandId.make("cmd-hermes-follow-up"),
1831+ threadId,
1832+ message: {
1833+ messageId: followUpMessageId,
1834+ role: "user",
1835+ text: "steer the active Hermes turn",
1836+ attachments: [],
1837+ },
1838+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1839+ runtimeMode: "approval-required",
1840+ createdAt: "2026-01-01T00:00:01.000Z",
1841+ });
1842+
1843+ const pendingBeforeAck = yield* harness.turnRepository.getPendingTurnStartByThreadId({
1844+ threadId,
1845+ });
1846+ expect(Option.getOrUndefined(pendingBeforeAck)?.messageId).toBe(followUpMessageId);
1847+
1848+ harness.emit({
1849+ type: "turn.started",
1850+ eventId: asEventId("evt-hermes-follow-up-ack"),
1851+ provider: ProviderDriverKind.make("hermes"),
1852+ createdAt: "2026-01-01T00:00:02.000Z",
1853+ threadId,
1854+ turnId: activeTurnId,
1855+ });
1856+ yield* Effect.promise(() => harness.drain());
1857+
1858+ const pendingAfterAck = yield* harness.turnRepository.getPendingTurnStartByThreadId({
1859+ threadId,
1860+ });
1861+ const activeTurn = yield* harness.turnRepository.getByTurnId({
1862+ threadId,
1863+ turnId: activeTurnId,
1864+ });
1865+ expect(Option.isNone(pendingAfterAck)).toBe(true);
1866+ expect(Option.getOrUndefined(activeTurn)?.pendingMessageId).toBe(followUpMessageId);
1867+ }),
1868+ );
1869+
15761870 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {
15771871 const harness = await createHarness();
15781872 const sourceThreadId = asThreadId("thread-plan");
@@ -2543,6 +2837,65 @@ describe("ProviderRuntimeIngestion", () => { · 964 unmodified lines
25432837 expect(message?.streaming).toBe(false);
25442838 });
25452839
2840+ it("replaces a spilled buffered projection when an authoritative snapshot arrives", async () => {
2841+ const harness = await createHarness();
2842+ const now = "2026-01-01T00:00:00.000Z";
2843+ const itemId = asItemId("item-buffer-spill-snapshot");
2844+ const turnId = asTurnId("turn-buffer-spill-snapshot");
2845+
2846+ harness.emit({
2847+ type: "content.delta",
2848+ eventId: asEventId("evt-buffer-spill-before-snapshot"),
2849+ provider: ProviderDriverKind.make("hermes"),
2850+ createdAt: now,
2851+ threadId: asThreadId("thread-1"),
2852+ turnId,
2853+ itemId,
2854+ payload: {
2855+ streamKind: "assistant_text",
2856+ delta: "x".repeat(40_000),
2857+ },
2858+ });
2859+ harness.emit({
2860+ type: "content.snapshot",
2861+ eventId: asEventId("evt-buffer-spill-snapshot"),
2862+ provider: ProviderDriverKind.make("hermes"),
2863+ createdAt: now,
2864+ threadId: asThreadId("thread-1"),
2865+ turnId,
2866+ itemId,
2867+ payload: {
2868+ streamKind: "assistant_text",
2869+ text: "authoritative after spill",
2870+ },
2871+ });
2872+ harness.emit({
2873+ type: "item.completed",
2874+ eventId: asEventId("evt-buffer-spill-snapshot-completed"),
2875+ provider: ProviderDriverKind.make("hermes"),
2876+ createdAt: now,
2877+ threadId: asThreadId("thread-1"),
2878+ turnId,
2879+ itemId,
2880+ payload: {
2881+ itemType: "assistant_message",
2882+ status: "completed",
2883+ },
2884+ });
2885+
2886+ const thread = await waitForThread(harness.readModel, (entry) =>
2887+ entry.messages.some(
2888+ (message: ProviderRuntimeTestMessage) =>
2889+ message.id === `assistant:${itemId}` &&
2890+ message.text === "authoritative after spill" &&
2891+ !message.streaming,
2892+ ),
2893+ );
2894+ expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength(
2895+ 1,
2896+ );
2897+ });
2898+
25462899 it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => {
25472900 const harness = await createHarness();
25482901 const now = "2026-01-01T00:00:00.000Z";
@@ -2849,6 +3202,89 @@ describe("ProviderRuntimeIngestion", () => { · 300 unmodified lines
28493202 ).toBe(true);
28503203 });
28513204
3205+ it("upserts unknown provider item lifecycle into one meaningful activity", async () => {
3206+ const harness = await createHarness();
3207+ const itemId = asItemId("item-generic-activity");
3208+ const turnId = asTurnId("turn-generic-activity");
3209+
3210+ harness.emit({
3211+ type: "item.started",
3212+ eventId: asEventId("evt-generic-activity-started"),
3213+ provider: ProviderDriverKind.make("hermes"),
3214+ createdAt: "2026-01-01T00:00:00.000Z",
3215+ threadId: asThreadId("thread-1"),
3216+ turnId,
3217+ itemId,
3218+ payload: {
3219+ itemType: "unknown",
3220+ status: "inProgress",
3221+ title: "Reading skill hermes-agent",
3222+ detail: "Opening the skill instructions.",
3223+ data: { unsafeRawPayload: "not projected" },
3224+ },
3225+ });
3226+ harness.emit({
3227+ type: "item.updated",
3228+ eventId: asEventId("evt-generic-activity-updated"),
3229+ provider: ProviderDriverKind.make("hermes"),
3230+ createdAt: "2026-01-01T00:00:01.000Z",
3231+ threadId: asThreadId("thread-1"),
3232+ turnId,
3233+ itemId,
3234+ payload: {
3235+ itemType: "unknown",
3236+ status: "inProgress",
3237+ title: "Reading skill hermes-agent",
3238+ detail: "Parsing the workflow.",
3239+ },
3240+ });
3241+ harness.emit({
3242+ type: "item.completed",
3243+ eventId: asEventId("evt-generic-activity-completed"),
3244+ provider: ProviderDriverKind.make("hermes"),
3245+ createdAt: "2026-01-01T00:00:02.000Z",
3246+ threadId: asThreadId("thread-1"),
3247+ turnId,
3248+ itemId,
3249+ payload: {
3250+ itemType: "unknown",
3251+ status: "completed",
3252+ title: "Read skill hermes-agent",
3253+ detail: "Skill instructions loaded.",
3254+ },
3255+ });
3256+
3257+ const activityId = `provider:hermes:item:${itemId}`;
3258+ const thread = await waitForThread(harness.readModel, (entry) =>
3259+ entry.activities.some(
3260+ (activity: ProviderRuntimeTestActivity) =>
3261+ activity.id === activityId &&
3262+ activity.payload &&
3263+ typeof activity.payload === "object" &&
3264+ (activity.payload as Record<string, unknown>).status === "completed",
3265+ ),
3266+ );
3267+ const activities = thread.activities.filter((activity) => activity.id === activityId);
3268+ const activity = activities[0];
3269+ const payload =
3270+ activity?.payload && typeof activity.payload === "object"
3271+ ? (activity.payload as Record<string, unknown>)
3272+ : undefined;
3273+
3274+ expect(activities).toHaveLength(1);
3275+ expect(activity?.kind).toBe("provider.activity");
3276+ expect(activity?.tone).toBe("info");
3277+ expect(activity?.summary).toBe("Read skill hermes-agent");
3278+ expect(payload).toMatchObject({
3279+ itemType: "unknown",
3280+ status: "completed",
3281+ providerItemId: itemId,
3282+ title: "Read skill hermes-agent",
3283+ detail: "Skill instructions loaded.",
3284+ });
3285+ expect(payload?.data).toBeUndefined();
3286+ });
3287+
28523288 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
28533289 const harness = await createHarness();
28543290 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+102−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 ApprovalRequestId,
33 type AssistantDeliveryMode,
44 CommandId,
5+ EventId,
56 MessageId,
67 type OrchestrationEvent,
78 type OrchestrationMessage,
@@ -306,6 +307,44 @@ function requestKindFromCanonicalRequestType( · 298 unmodified lines
306307 }
307308 }
308309
310+function genericProviderActivityId(event: ProviderRuntimeEvent): EventId {
311+ return EventId.make(
312+ event.itemId
313+ ? `provider:${event.provider}:item:${event.itemId}`
314+ : `provider:${event.provider}:event:${event.eventId}`,
315+ );
316+}
317+
318+function genericProviderActivity(
319+ event: Extract<
320+ ProviderRuntimeEvent,
321+ { type: "item.started" | "item.updated" | "item.completed" }
322+ >,
323+ maybeSequence: { readonly sequence?: number },
324+): OrchestrationThreadActivity {
325+ const title = event.payload.title ? truncateDetail(event.payload.title, 120) : undefined;
326+ const detail = event.payload.detail ? truncateDetail(event.payload.detail) : undefined;
327+ const status =
328+ event.payload.status ?? (event.type === "item.completed" ? "completed" : "inProgress");
329+
330+ return {
331+ id: genericProviderActivityId(event),
332+ createdAt: event.createdAt,
333+ tone: "info",
334+ kind: "provider.activity",
335+ summary: title ?? (detail ? truncateDetail(detail, 120) : "Provider activity"),
336+ payload: {
337+ itemType: event.payload.itemType,
338+ status,
339+ ...(event.itemId ? { providerItemId: event.itemId } : {}),
340+ ...(title ? { title } : {}),
341+ ...(detail ? { detail } : {}),
342+ },
343+ turnId: toTurnId(event.turnId) ?? null,
344+ ...maybeSequence,
345+ };
346+}
347+
309348 export function runtimeEventToActivities(
310349 event: ProviderRuntimeEvent,
311350 taskTitle?: string,
@@ -614,6 +653,9 @@ export function runtimeEventToActivities( · 302 unmodified lines
614653 }
615654
616655 case "item.updated": {
656+ if (event.payload.itemType === "unknown") {
657+ return [genericProviderActivity(event, maybeSequence)];
658+ }
617659 if (!isToolLifecycleItemType(event.payload.itemType)) {
618660 return [];
619661 }
@@ -637,6 +679,9 @@ export function runtimeEventToActivities( · 17 unmodified lines
637679 }
638680
639681 case "item.completed": {
682+ if (event.payload.itemType === "unknown") {
683+ return [genericProviderActivity(event, maybeSequence)];
684+ }
640685 if (!isToolLifecycleItemType(event.payload.itemType)) {
641686 return [];
642687 }
@@ -659,6 +704,9 @@ export function runtimeEventToActivities( · 16 unmodified lines
659704 }
660705
661706 case "item.started": {
707+ if (event.payload.itemType === "unknown") {
708+ return [genericProviderActivity(event, maybeSequence)];
709+ }
662710 if (!isToolLifecycleItemType(event.payload.itemType)) {
663711 return [];
664712 }
@@ -899,6 +947,9 @@ const make = Effect.gen(function* () { · 234 unmodified lines
899947 ),
900948 );
901949
950+ const replaceBufferedAssistantText = (messageId: MessageId, text: string) =>
951+ Cache.set(bufferedAssistantTextByMessageId, messageId, text);
952+
902953 const takeBufferedAssistantText = (messageId: MessageId) =>
903954 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
904955 Effect.flatMap((existingText) =>
@@ -1463,6 +1514,10 @@ const make = Effect.gen(function* () { · 558 unmodified lines
14631514 event.type === "content.delta" && event.payload.streamKind === "assistant_text"
14641515 ? event.payload.delta
14651516 : undefined;
1517+ const assistantSnapshot =
1518+ event.type === "content.snapshot" && event.payload.streamKind === "assistant_text"
1519+ ? event.payload.text
1520+ : undefined;
14661521 const proposedPlanDelta =
14671522 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;
14681523
@@ -1507,6 +1562,53 @@ const make = Effect.gen(function* () { · 38 unmodified lines
15071562 }
15081563 }
15091564
1565+ if (assistantSnapshot !== undefined) {
1566+ const turnId = toTurnId(event.turnId);
1567+ const assistantMessageId = yield* getOrCreateAssistantMessageId({
1568+ threadId: thread.id,
1569+ event,
1570+ ...(turnId ? { turnId } : {}),
1571+ });
1572+ if (turnId) {
1573+ yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1574+ }
1575+
1576+ const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1577+ serverSettingsService.getSettings,
1578+ (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
1579+ );
1580+ if (assistantDeliveryMode === "buffered") {
1581+ const detailedThread = yield* getLoadedThreadDetail();
1582+ const hasProjectedMessage =
1583+ findMessageById(detailedThread?.messages ?? [], assistantMessageId) !== undefined;
1584+ if (hasProjectedMessage) {
1585+ yield* orchestrationEngine.dispatch({
1586+ type: "thread.message.assistant.replace",
1587+ commandId: yield* providerCommandId(event, "assistant-snapshot-buffer-spill"),
1588+ threadId: thread.id,
1589+ messageId: assistantMessageId,
1590+ text: assistantSnapshot,
1591+ ...(turnId ? { turnId } : {}),
1592+ createdAt: now,
1593+ });
1594+ yield* clearBufferedAssistantText(assistantMessageId);
1595+ } else {
1596+ yield* replaceBufferedAssistantText(assistantMessageId, assistantSnapshot);
1597+ }
1598+ } else {
1599+ yield* orchestrationEngine.dispatch({
1600+ type: "thread.message.assistant.replace",
1601+ commandId: yield* providerCommandId(event, "assistant-snapshot"),
1602+ threadId: thread.id,
1603+ messageId: assistantMessageId,
1604+ text: assistantSnapshot,
1605+ ...(turnId ? { turnId } : {}),
1606+ createdAt: now,
1607+ });
1608+ yield* clearBufferedAssistantText(assistantMessageId);
1609+ }
1610+ }
1611+
15101612 const pauseForUserTurnId =
15111613 event.type === "request.opened" || event.type === "user-input.requested"
15121614 ? toTurnId(event.turnId)
apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts+73−0
@@ -0,0 +1,73 @@
1+import {
2+ CommandId,
3+ DEFAULT_PROVIDER_INTERACTION_MODE,
4+ EventId,
5+ MessageId,
6+ ProjectId,
7+ ProviderInstanceId,
8+ ThreadId,
9+ TurnId,
10+} from "@t3tools/contracts";
11+import * as NodeServices from "@effect/platform-node/NodeServices";
12+import * as Effect from "effect/Effect";
13+import { expect, it } from "@effect/vitest";
14+
15+import { decideOrchestrationCommand } from "./decider.ts";
16+import { createEmptyReadModel, projectEvent } from "./projector.ts";
17+
18+it.layer(NodeServices.layer)("assistant message replacement", (it) => {
19+ it.effect("emits a streaming replacement message event", () =>
20+ Effect.gen(function* () {
21+ const now = "2026-07-24T12:00:00.000Z";
22+ const threadId = ThreadId.make("thread-replacement");
23+ const readModel = yield* projectEvent(createEmptyReadModel(now), {
24+ sequence: 1,
25+ eventId: EventId.make("event-thread-created"),
26+ aggregateKind: "thread",
27+ aggregateId: threadId,
28+ type: "thread.created",
29+ occurredAt: now,
30+ commandId: CommandId.make("command-thread-created"),
31+ causationEventId: null,
32+ correlationId: CommandId.make("command-thread-created"),
33+ metadata: {},
34+ payload: {
35+ threadId,
36+ projectId: ProjectId.make("project-replacement"),
37+ title: "Replacement",
38+ modelSelection: {
39+ instanceId: ProviderInstanceId.make("hermes"),
40+ model: "hermes",
41+ },
42+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
43+ runtimeMode: "full-access",
44+ branch: null,
45+ worktreePath: null,
46+ createdAt: now,
47+ updatedAt: now,
48+ },
49+ });
50+
51+ const decided = yield* decideOrchestrationCommand({
52+ command: {
53+ type: "thread.message.assistant.replace",
54+ commandId: CommandId.make("command-message-replace"),
55+ threadId,
56+ messageId: MessageId.make("assistant:message-1"),
57+ text: "Hello there",
58+ turnId: TurnId.make("turn-1"),
59+ createdAt: now,
60+ },
61+ readModel,
62+ });
63+
64+ const event = Array.isArray(decided) ? decided[0] : decided;
65+ if (event?.type !== "thread.message-sent") {
66+ throw new Error("expected thread.message-sent");
67+ }
68+ expect(event.payload.text).toBe("Hello there");
69+ expect(event.payload.streaming).toBe(true);
70+ expect(event.payload.textOperation).toBe("replace");
71+ }),
72+ );
73+});
apps/server/src/orchestration/decider.ts+28−0
@@ -1018,6 +1018,34 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 1017 unmodified lines
10181018 };
10191019 }
10201020
1021+ case "thread.message.assistant.replace": {
1022+ yield* requireThread({
1023+ readModel,
1024+ command,
1025+ threadId: command.threadId,
1026+ });
1027+ return {
1028+ ...(yield* withEventBase({
1029+ aggregateKind: "thread",
1030+ aggregateId: command.threadId,
1031+ occurredAt: command.createdAt,
1032+ commandId: command.commandId,
1033+ })),
1034+ type: "thread.message-sent",
1035+ payload: {
1036+ threadId: command.threadId,
1037+ messageId: command.messageId,
1038+ role: "assistant",
1039+ text: command.text,
1040+ turnId: command.turnId ?? null,
1041+ streaming: true,
1042+ textOperation: "replace",
1043+ createdAt: command.createdAt,
1044+ updatedAt: command.createdAt,
1045+ },
1046+ };
1047+ }
1048+
10211049 case "thread.message.assistant.complete": {
10221050 yield* requireThread({
10231051 readModel,
apps/server/src/orchestration/projector.test.ts+77−0
@@ -488,6 +488,83 @@ describe("orchestration projector", () => { · 487 unmodified lines
488488 expect(message?.updatedAt).toBe(completeAt);
489489 });
490490
491+ it("replaces an assistant snapshot between legacy appended deltas", async () => {
492+ const createdAt = "2026-02-23T09:00:00.000Z";
493+ const model = createEmptyReadModel(createdAt);
494+ const afterCreate = await Effect.runPromise(
495+ projectEvent(
496+ model,
497+ makeEvent({
498+ sequence: 1,
499+ type: "thread.created",
500+ aggregateKind: "thread",
501+ aggregateId: "thread-1",
502+ occurredAt: createdAt,
503+ commandId: "cmd-create",
504+ payload: {
505+ threadId: "thread-1",
506+ projectId: "project-1",
507+ title: "demo",
508+ modelSelection: {
509+ provider: ProviderDriverKind.make("hermes"),
510+ model: "hermes",
511+ },
512+ runtimeMode: "full-access",
513+ branch: null,
514+ worktreePath: null,
515+ createdAt,
516+ updatedAt: createdAt,
517+ },
518+ }),
519+ ),
520+ );
521+
522+ const messageEvent = (input: {
523+ sequence: number;
524+ text: string;
525+ streaming: boolean;
526+ textOperation?: "replace";
527+ }) =>
528+ makeEvent({
529+ sequence: input.sequence,
530+ type: "thread.message-sent",
531+ aggregateKind: "thread",
532+ aggregateId: "thread-1",
533+ occurredAt: createdAt,
534+ commandId: `cmd-message-${input.sequence}`,
535+ payload: {
536+ threadId: "thread-1",
537+ messageId: "assistant:msg-replace",
538+ role: "assistant",
539+ text: input.text,
540+ turnId: "turn-1",
541+ streaming: input.streaming,
542+ ...(input.textOperation !== undefined ? { textOperation: input.textOperation } : {}),
543+ createdAt,
544+ updatedAt: createdAt,
545+ },
546+ });
547+
548+ let projected = afterCreate;
549+ for (const event of [
550+ messageEvent({ sequence: 2, text: "Hello wor", streaming: true }),
551+ messageEvent({
552+ sequence: 3,
553+ text: "Hello there",
554+ streaming: true,
555+ textOperation: "replace",
556+ }),
557+ messageEvent({ sequence: 4, text: "!", streaming: true }),
558+ messageEvent({ sequence: 5, text: "", streaming: false }),
559+ ]) {
560+ projected = await Effect.runPromise(projectEvent(projected, event));
561+ }
562+
563+ const message = projected.threads[0]?.messages[0];
564+ expect(message?.text).toBe("Hello there!");
565+ expect(message?.streaming).toBe(false);
566+ });
567+
491568 it("prunes reverted turn messages from in-memory thread snapshot", async () => {
492569 const createdAt = "2026-02-23T10:00:00.000Z";
493570 const model = createEmptyReadModel(createdAt);
apps/server/src/orchestration/projector.ts+7−4
@@ -471,11 +471,14 @@ export function projectEvent( · 470 unmodified lines
471471 entry.id === message.id
472472 ? {
473473 ...entry,
474− text: message.streaming
475− ? `${entry.text}${message.text}`
476− : message.text.length > 0
474+ text:
475+ payload.textOperation === "replace"
477476 ? message.text
478− : entry.text,
477+ : message.streaming
478+ ? `${entry.text}${message.text}`
479+ : message.text.length > 0
480+ ? message.text
481+ : entry.text,
479482 streaming: message.streaming,
480483 updatedAt: message.updatedAt,
481484 turnId: message.turnId,
apps/server/src/provider/Layers/HermesAdapter.test.ts+211−9
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 HermesGatewaySessionId,
77 ProviderInstanceId,
88 ThreadId,
9+ TurnId,
910 type HermesGatewayT3ToPluginMessage,
1011 } from "@t3tools/contracts";
1112 import * as Effect from "effect/Effect";
@@ -23,10 +24,9 @@ import { · 11 unmodified lines
2324 makeHermesAdapter,
2425 sanitizeHermesItemData,
2526 sanitizeHermesRequestArgs,
26− shouldProjectHermesTurnStarted,
2727 } from "./HermesAdapter.ts";
2828
29−it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => {
29+it("sanitizes persisted gateway payloads", () => {
3030 assert.deepEqual(
3131 sanitizeHermesItemData("command_execution", {
3232 command: "git status",
@@ -43,12 +43,6 @@ it("sanitizes persisted gateway payloads and suppresses steering acknowledgement · 10 unmodified lines
4343 }),
4444 { command: "git push" },
4545 );
46− const steerRequestId = HermesGatewayRequestId.make("steer-request");
47− const startRequestId = HermesGatewayRequestId.make("start-request");
48− const pendingSteerRequestIds = new Set([steerRequestId]);
49− assert.isFalse(shouldProjectHermesTurnStarted(pendingSteerRequestIds, steerRequestId));
50− assert.isTrue(shouldProjectHermesTurnStarted(pendingSteerRequestIds, startRequestId));
51− assert.isFalse(pendingSteerRequestIds.has(steerRequestId));
5246 });
5347
5448 it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () =>
@@ -101,6 +95,19 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 46 unmodified lines
10195 Effect.provideService(HermesGatewayBroker, broker),
10296 );
10397 yield* Effect.yieldNow;
98+ const firstEventAfterOrphanExitFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
99+ Effect.forkChild({ startImmediately: true }),
100+ );
101+ yield* PubSub.publish(brokerEvents, {
102+ instanceId,
103+ message: {
104+ type: "session.exited",
105+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
106+ threadId,
107+ sessionId: HermesGatewaySessionId.make("session-before-context"),
108+ recoverable: false,
109+ },
110+ });
104111 const session = yield* adapter.startSession({
105112 threadId,
106113 providerInstanceId: instanceId,
@@ -135,13 +142,17 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 28 unmodified lines
135142 });
136143 const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber));
137144 assert.equal(startEvent?.type, "turn.started");
145+ const firstEventAfterOrphanExit = Option.getOrUndefined(
146+ yield* Fiber.join(firstEventAfterOrphanExitFiber),
147+ );
148+ assert.equal(firstEventAfterOrphanExit?.type, "session.started");
138149
139150 yield* adapter.sendTurn({ threadId, input: "follow up" });
140151 const turnSteer = sent.find((message) => message.type === "turn.steer");
141152 if (!turnSteer || turnSteer.type !== "turn.steer") {
142153 return yield* Effect.die(new Error("turn.steer was not sent"));
143154 }
144− const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
155+ const steerEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
145156 Effect.forkChild({ startImmediately: true }),
146157 );
147158 yield* PubSub.publish(brokerEvents, {
@@ -155,6 +166,12 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 7 unmodified lines
155166 turnId: turnSteer.turnId,
156167 },
157168 });
169+ const steerEvent = Option.getOrUndefined(yield* Fiber.join(steerEventFiber));
170+ assert.equal(steerEvent?.type, "turn.started");
171+
172+ const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
173+ Effect.forkChild({ startImmediately: true }),
174+ );
158175 yield* PubSub.publish(brokerEvents, {
159176 instanceId,
160177 message: {
@@ -169,5 +186,190 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 8 unmodified lines
169186 });
170187 const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber));
171188 assert.equal(nextEvent?.type, "content.delta");
189+
190+ const newerTurnId = TurnId.make("newer-turn");
191+ const newerTurnFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
192+ Effect.forkChild({ startImmediately: true }),
193+ );
194+ yield* PubSub.publish(brokerEvents, {
195+ instanceId,
196+ message: {
197+ type: "turn.started",
198+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
199+ requestId: HermesGatewayRequestId.make("newer-turn-request"),
200+ threadId,
201+ sessionId,
202+ turnId: newerTurnId,
203+ },
204+ });
205+ yield* Fiber.join(newerTurnFiber);
206+
207+ const afterStaleExitFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
208+ Effect.forkChild({ startImmediately: true }),
209+ );
210+ yield* PubSub.publish(brokerEvents, {
211+ instanceId,
212+ message: {
213+ type: "session.exited",
214+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
215+ threadId,
216+ sessionId: HermesGatewaySessionId.make("session-stale"),
217+ recoverable: false,
218+ },
219+ });
220+ yield* PubSub.publish(brokerEvents, {
221+ instanceId,
222+ message: {
223+ type: "content.delta",
224+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
225+ threadId,
226+ sessionId,
227+ turnId: newerTurnId,
228+ streamKind: "assistant_text",
229+ delta: "still active",
230+ },
231+ });
232+ const afterStaleExit = Option.getOrUndefined(yield* Fiber.join(afterStaleExitFiber));
233+ assert.equal(afterStaleExit?.type, "content.delta");
234+ const afterStaleExitSession = (yield* adapter.listSessions())[0];
235+ assert.equal(afterStaleExitSession?.status, "running");
236+ assert.equal(afterStaleExitSession?.activeTurnId, newerTurnId);
237+
238+ const staleCompletionFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
239+ Effect.forkChild({ startImmediately: true }),
240+ );
241+ yield* PubSub.publish(brokerEvents, {
242+ instanceId,
243+ message: {
244+ type: "turn.completed",
245+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
246+ threadId,
247+ sessionId,
248+ turnId: turnStart.turnId,
249+ state: "completed",
250+ },
251+ });
252+ yield* Fiber.join(staleCompletionFiber);
253+
254+ const afterStaleCompletion = (yield* adapter.listSessions())[0];
255+ assert.equal(afterStaleCompletion?.status, "running");
256+ assert.equal(afterStaleCompletion?.activeTurnId, newerTurnId);
257+
258+ const activeCompletionFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
259+ Effect.forkChild({ startImmediately: true }),
260+ );
261+ yield* PubSub.publish(brokerEvents, {
262+ instanceId,
263+ message: {
264+ type: "turn.completed",
265+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
266+ threadId,
267+ sessionId,
268+ turnId: newerTurnId,
269+ state: "completed",
270+ },
271+ });
272+ yield* Fiber.join(activeCompletionFiber);
273+
274+ const afterActiveCompletion = (yield* adapter.listSessions())[0];
275+ assert.equal(afterActiveCompletion?.status, "ready");
276+ assert.equal(afterActiveCompletion?.activeTurnId, undefined);
277+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
278+);
279+
280+it.effect("resumes active Hermes turns for steering and projects authoritative snapshots", () =>
281+ Effect.gen(function* () {
282+ const instanceId = ProviderInstanceId.make("hermes_reconnected");
283+ const threadId = ThreadId.make("thread-reconnected");
284+ const sessionId = HermesGatewaySessionId.make("session-reconnected");
285+ const activeTurnId = TurnId.make("turn-already-running");
286+ const sent: Array<HermesGatewayT3ToPluginMessage> = [];
287+ const brokerEvents = yield* PubSub.unbounded<HermesGatewayEnvelope>();
288+ const broker: HermesGatewayBrokerShape = {
289+ createEnrollment: () => Effect.die(new Error("unused")),
290+ getInstanceStatus: () => Effect.die(new Error("unused")),
291+ listInstances: Effect.succeed([]),
292+ renameInstance: () => Effect.die(new Error("unused")),
293+ revokeInstance: () => Effect.die(new Error("unused")),
294+ removeInstance: () => Effect.die(new Error("unused")),
295+ registerConnection: () => Effect.die(new Error("unused")),
296+ receive: () => Effect.void,
297+ disconnect: () => Effect.void,
298+ request: (_instanceId, message) => {
299+ sent.push(message);
300+ if (message.type === "session.ensure") {
301+ return Effect.succeed({
302+ type: "session.ready",
303+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
304+ requestId: message.requestId,
305+ threadId: message.threadId,
306+ sessionId,
307+ resumed: true,
308+ activeTurnId,
309+ });
310+ }
311+ if (message.type === "turn.steer") {
312+ return Effect.succeed({
313+ type: "turn.started",
314+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
315+ requestId: message.requestId,
316+ threadId: message.threadId,
317+ sessionId: message.sessionId,
318+ turnId: message.turnId,
319+ });
320+ }
321+ return Effect.die(new Error(`unexpected request ${message.type}`));
322+ },
323+ send: (_instanceId, message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid),
324+ isConnected: () => Effect.succeed(true),
325+ stream: Stream.fromPubSub(brokerEvents),
326+ streamStatuses: Stream.empty,
327+ };
328+ const adapter = yield* makeHermesAdapter({ instanceId }).pipe(
329+ Effect.provideService(HermesGatewayBroker, broker),
330+ );
331+ yield* Effect.yieldNow;
332+
333+ const session = yield* adapter.startSession({
334+ threadId,
335+ providerInstanceId: instanceId,
336+ runtimeMode: "full-access",
337+ resumeCursor: {
338+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
339+ sessionId,
340+ },
341+ });
342+ assert.equal(session.status, "running");
343+ assert.equal(session.activeTurnId, activeTurnId);
344+
345+ yield* adapter.sendTurn({ threadId, input: "steer the restored turn" });
346+ const steer = sent.find((message) => message.type === "turn.steer");
347+ assert.equal(steer?.type === "turn.steer" ? steer.turnId : undefined, activeTurnId);
348+
349+ const snapshotEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
350+ Effect.forkChild({ startImmediately: true }),
351+ );
352+ yield* PubSub.publish(brokerEvents, {
353+ instanceId,
354+ message: {
355+ type: "content.snapshot",
356+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
357+ threadId,
358+ sessionId,
359+ turnId: activeTurnId,
360+ streamKind: "assistant_text",
361+ text: "the complete restored answer",
362+ contentIndex: 0,
363+ },
364+ });
365+ const snapshotEvent = Option.getOrUndefined(yield* Fiber.join(snapshotEventFiber));
366+ assert.equal(snapshotEvent?.type, "content.snapshot");
367+ if (snapshotEvent?.type === "content.snapshot") {
368+ assert.deepEqual(snapshotEvent.payload, {
369+ streamKind: "assistant_text",
370+ text: "the complete restored answer",
371+ contentIndex: 0,
372+ });
373+ }
172374 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
173375 );
apps/server/src/provider/Layers/HermesAdapter.ts+34−31
@@ -107,11 +107,6 @@ export const sanitizeHermesRequestArgs = ( · 106 unmodified lines
107107 return Object.keys(sanitized).length > 0 ? sanitized : undefined;
108108 };
109109
110−export const shouldProjectHermesTurnStarted = (
111− pendingSteerRequestIds: Set<HermesGatewayRequestId>,
112− incomingRequestId: HermesGatewayRequestId,
113−) => !pendingSteerRequestIds.delete(incomingRequestId);
114−
115110 export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: {
116111 readonly instanceId: ProviderInstanceId;
117112 }) {
@@ -119,7 +114,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 1 unmodified line
119114 const broker = yield* HermesGatewayBroker;
120115 const events = yield* PubSub.unbounded<ProviderRuntimeEvent>();
121116 const sessions = new Map<ThreadId, SessionContext>();
122− const pendingSteerRequestIds = new Set<HermesGatewayRequestId>();
123117
124118 const randomId = crypto.randomUUIDv4.pipe(
125119 Effect.mapError(
@@ -188,10 +182,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 62 unmodified lines
188182 case "session.ready":
189183 return undefined;
190184 case "turn.started": {
191− const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted(
192− pendingSteerRequestIds,
193− message.requestId,
194− );
195185 if (context) {
196186 updateSession(context, {
197187 status: "running",
@@ -201,7 +191,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 3 unmodified lines
201191 context.turns.push({ id: TurnId.make(message.turnId), items: [] });
202192 }
203193 }
204− if (isSteeringAcknowledgement) return undefined;
205194 return {
206195 ...base,
207196 type: "turn.started",
@@ -218,6 +207,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 10 unmodified lines
218207 ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}),
219208 },
220209 };
210+ case "content.snapshot":
211+ return {
212+ ...base,
213+ type: "content.snapshot",
214+ payload: {
215+ streamKind: message.streamKind,
216+ text: message.text,
217+ ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}),
218+ },
219+ };
221220 case "item.started":
222221 case "item.updated":
223222 case "item.completed": {
@@ -267,7 +266,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 43 unmodified lines
267266 payload: { answers: message.answers },
268267 };
269268 case "turn.completed":
270− if (context) {
269+ if (context?.session.activeTurnId === message.turnId) {
271270 updateSession(context, { status: message.state === "failed" ? "error" : "ready" });
272271 const { activeTurnId: _activeTurnId, ...session } = context.session;
273272 context.session = session;
@@ -282,7 +281,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 8 unmodified lines
282281 },
283282 };
284283 case "turn.aborted":
285− if (context) {
284+ if (context?.session.activeTurnId === message.turnId) {
286285 const { activeTurnId: _activeTurnId, ...session } = context.session;
287286 context.session = { ...session, status: "ready", updatedAt: nowIso() };
288287 }
@@ -292,7 +291,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 3 unmodified lines
292291 payload: { reason: message.reason },
293292 };
294293 case "session.exited":
295− if (context) updateSession(context, { status: message.recoverable ? "error" : "closed" });
294+ if (!context || context.hermesSessionId !== message.sessionId) {
295+ return undefined;
296+ }
297+ const { activeTurnId: _activeTurnId, ...session } = context.session;
298+ context.session = {
299+ ...session,
300+ status: message.recoverable ? "error" : "closed",
301+ updatedAt: nowIso(),
302+ };
296303 return {
297304 ...base,
298305 type: "session.exited",
@@ -365,13 +372,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 66 unmodified lines
365372 });
366373 }
367374 const createdAt = nowIso();
375+ const activeTurnId = response.activeTurnId;
368376 const session = {
369377 provider: PROVIDER,
370378 providerInstanceId: input.instanceId,
371− status: "ready",
379+ status: activeTurnId === undefined ? "ready" : "running",
372380 runtimeMode: sessionInput.runtimeMode,
373381 ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}),
374382 ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}),
383+ ...(activeTurnId !== undefined ? { activeTurnId } : {}),
375384 threadId: sessionInput.threadId,
376385 resumeCursor: {
377386 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
@@ -383,7 +392,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 5 unmodified lines
383392 sessions.set(sessionInput.threadId, {
384393 session,
385394 hermesSessionId: response.sessionId,
386− turns: [],
395+ turns: activeTurnId === undefined ? [] : [{ id: activeTurnId, items: [] }],
387396 });
388397 yield* emit({
389398 ...(yield* eventBase({ threadId: sessionInput.threadId })),
@@ -422,22 +431,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 32 unmodified lines
422431 const activeTurnId = context.session.activeTurnId;
423432 const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`);
424433 const outboundRequestId = yield* requestId;
425− if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId);
426− const response = yield* broker
427− .request(input.instanceId, {
428− type: activeTurnId ? "turn.steer" : "turn.start",
429− protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
430− requestId: outboundRequestId,
431− threadId: turnInput.threadId,
432− sessionId: context.hermesSessionId,
433− turnId,
434− text,
435− })
436− .pipe(
437− Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))),
438− );
434+ const response = yield* broker.request(input.instanceId, {
435+ type: activeTurnId ? "turn.steer" : "turn.start",
436+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
437+ requestId: outboundRequestId,
438+ threadId: turnInput.threadId,
439+ sessionId: context.hermesSessionId,
440+ turnId,
441+ text,
442+ });
439443 if (response.type !== "turn.started") {
440− pendingSteerRequestIds.delete(outboundRequestId);
441444 return yield* new ProviderAdapterRequestError({
442445 provider: PROVIDER,
443446 method: activeTurnId ? "turn.steer" : "turn.start",
apps/server/src/provider/Layers/HermesGatewayBroker.test.ts+4−4
@@ -87,7 +87,7 @@ const hello = ( · 86 unmodified lines
8787 type: "connection.hello",
8888 requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),
8989 protocolVersion,
90− pluginVersion: "0.1.0",
90+ pluginVersion: "0.2.0",
9191 hermesVersion: "1.0.0",
9292 capabilities: { ...capabilities, protocolVersion },
9393 authentication,
@@ -157,7 +157,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 63 unmodified lines
157157 instanceId,
158158 credential: HermesGatewayCredential.make("not-the-real-credential"),
159159 },
160− 2,
160+ 3,
161161 ),
162162 secondTransport,
163163 ),
@@ -185,7 +185,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 21 unmodified lines
185185 instanceId,
186186 credential: first.accepted.credential!,
187187 },
188− 2,
188+ 3,
189189 ),
190190 secondTransport,
191191 ),
@@ -201,7 +201,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 9 unmodified lines
201201 });
202202 const incompatibleEnrollment = yield* Effect.flip(
203203 broker.registerConnection(
204− hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2),
204+ hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 3),
205205 secondTransport,
206206 ),
207207 );
apps/server/src/provider/Layers/ProviderService.test.ts+95−0
@@ -1280,6 +1280,101 @@ routing.layer("ProviderServiceLive routing", (it) => { · 1279 unmodified lines
12801280 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
12811281 }
12821282 }
1283+
1284+ yield* advanceTestClock(50);
1285+ routing.codex.emit({
1286+ type: "turn.completed",
1287+ eventId: asEventId("evt-runtime-status-stale-completion"),
1288+ provider: ProviderDriverKind.make("codex"),
1289+ createdAt: "2026-01-01T00:00:01.000Z",
1290+ threadId: session.threadId,
1291+ turnId: asTurnId("turn-from-an-older-request"),
1292+ payload: {
1293+ state: "completed",
1294+ },
1295+ });
1296+ yield* advanceTestClock(50);
1297+
1298+ const afterStaleCompletion = yield* runtimeRepository.getByThreadId({
1299+ threadId: session.threadId,
1300+ });
1301+ assert.equal(Option.isSome(afterStaleCompletion), true);
1302+ if (Option.isSome(afterStaleCompletion)) {
1303+ assert.equal(afterStaleCompletion.value.status, "running");
1304+ const payload = afterStaleCompletion.value.runtimePayload;
1305+ assert.equal(payload !== null && typeof payload === "object", true);
1306+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
1307+ assert.equal(
1308+ "activeTurnId" in payload ? payload.activeTurnId : undefined,
1309+ `turn-${String(session.threadId)}`,
1310+ );
1311+ assert.equal(
1312+ "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined,
1313+ "turn.completed",
1314+ );
1315+ }
1316+ }
1317+
1318+ routing.codex.emit({
1319+ type: "turn.completed",
1320+ eventId: asEventId("evt-runtime-status-active-completion"),
1321+ provider: ProviderDriverKind.make("codex"),
1322+ createdAt: "2026-01-01T00:00:02.000Z",
1323+ threadId: session.threadId,
1324+ turnId: asTurnId(`turn-${String(session.threadId)}`),
1325+ payload: {
1326+ state: "completed",
1327+ },
1328+ });
1329+ yield* advanceTestClock(50);
1330+
1331+ const afterActiveCompletion = yield* runtimeRepository.getByThreadId({
1332+ threadId: session.threadId,
1333+ });
1334+ assert.equal(Option.isSome(afterActiveCompletion), true);
1335+ if (Option.isSome(afterActiveCompletion)) {
1336+ assert.equal(afterActiveCompletion.value.status, "running");
1337+ const payload = afterActiveCompletion.value.runtimePayload;
1338+ assert.equal(payload !== null && typeof payload === "object", true);
1339+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
1340+ assert.equal("activeTurnId" in payload ? payload.activeTurnId : undefined, null);
1341+ assert.equal("cwd" in payload ? payload.cwd : undefined, session.cwd);
1342+ assert.equal(
1343+ "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined,
1344+ "turn.completed",
1345+ );
1346+ }
1347+ }
1348+
1349+ routing.codex.emit({
1350+ type: "session.exited",
1351+ eventId: asEventId("evt-runtime-status-session-exit"),
1352+ provider: ProviderDriverKind.make("codex"),
1353+ createdAt: "2026-01-01T00:00:03.000Z",
1354+ threadId: session.threadId,
1355+ payload: {
1356+ exitKind: "error",
1357+ recoverable: true,
1358+ },
1359+ });
1360+ yield* advanceTestClock(50);
1361+
1362+ const afterSessionExit = yield* runtimeRepository.getByThreadId({
1363+ threadId: session.threadId,
1364+ });
1365+ assert.equal(Option.isSome(afterSessionExit), true);
1366+ if (Option.isSome(afterSessionExit)) {
1367+ assert.equal(afterSessionExit.value.status, "error");
1368+ const payload = afterSessionExit.value.runtimePayload;
1369+ assert.equal(payload !== null && typeof payload === "object", true);
1370+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
1371+ assert.equal("activeTurnId" in payload ? payload.activeTurnId : undefined, null);
1372+ assert.equal(
1373+ "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined,
1374+ "session.exited",
1375+ );
1376+ }
1377+ }
12831378 }),
12841379 );
12851380
apps/server/src/provider/Layers/ProviderService.ts+93−1
@@ -162,6 +162,19 @@ function readPersistedCwd( · 161 unmodified lines
162162 return trimmed.length > 0 ? trimmed : undefined;
163163 }
164164
165+function readPersistedActiveTurnId(
166+ runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"],
167+): string | null | undefined {
168+ if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
169+ return undefined;
170+ }
171+ const rawActiveTurnId =
172+ "activeTurnId" in runtimePayload ? runtimePayload.activeTurnId : undefined;
173+ return typeof rawActiveTurnId === "string" || rawActiveTurnId === null
174+ ? rawActiveTurnId
175+ : undefined;
176+}
177+
165178 const dieOnMissingBindingInstanceId = (
166179 operation: string,
167180 payload: {
@@ -281,6 +294,70 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 113 unmodified lines
281294 });
282295 });
283296
297+ const persistRuntimeLifecycle = (event: ProviderRuntimeEvent) =>
298+ Effect.gen(function* () {
299+ if (
300+ event.type !== "turn.started" &&
301+ event.type !== "turn.completed" &&
302+ event.type !== "turn.aborted" &&
303+ event.type !== "session.exited"
304+ ) {
305+ return;
306+ }
307+
308+ const binding = Option.getOrUndefined(yield* directory.getBinding(event.threadId));
309+ if (!binding || event.providerInstanceId === undefined) {
310+ return;
311+ }
312+ if (
313+ binding.provider !== event.provider ||
314+ binding.providerInstanceId !== event.providerInstanceId
315+ ) {
316+ yield* Effect.logWarning("provider.session.runtime.lifecycle-binding-mismatch", {
317+ threadId: event.threadId,
318+ eventProvider: event.provider,
319+ eventProviderInstanceId: event.providerInstanceId,
320+ bindingProvider: binding.provider,
321+ bindingProviderInstanceId: binding.providerInstanceId,
322+ eventType: event.type,
323+ });
324+ return;
325+ }
326+
327+ const persistedActiveTurnId = readPersistedActiveTurnId(binding.runtimePayload);
328+ const terminalMatchesActiveTurn =
329+ (event.type === "turn.completed" || event.type === "turn.aborted") &&
330+ event.turnId !== undefined &&
331+ persistedActiveTurnId === event.turnId;
332+ const isSessionExit = event.type === "session.exited";
333+ const status =
334+ event.type === "turn.started" || terminalMatchesActiveTurn
335+ ? "running"
336+ : isSessionExit
337+ ? event.payload.recoverable === true || event.payload.exitKind === "error"
338+ ? "error"
339+ : "stopped"
340+ : undefined;
341+ const activeTurnId =
342+ event.type === "turn.started"
343+ ? (event.turnId ?? persistedActiveTurnId)
344+ : terminalMatchesActiveTurn || isSessionExit
345+ ? null
346+ : persistedActiveTurnId;
347+
348+ yield* directory.upsert({
349+ threadId: event.threadId,
350+ provider: event.provider,
351+ providerInstanceId: event.providerInstanceId,
352+ ...(status !== undefined ? { status } : {}),
353+ runtimePayload: {
354+ ...(activeTurnId !== undefined ? { activeTurnId } : {}),
355+ lastRuntimeEvent: event.type,
356+ lastRuntimeEventAt: event.createdAt,
357+ },
358+ });
359+ });
360+
284361 const processRuntimeEvent = (
285362 source: {
286363 readonly instanceId: ProviderInstanceId;
@@ -293,7 +370,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 6 unmodified lines
293370 increment(providerRuntimeEventsTotal, {
294371 provider: canonicalEvent.provider,
295372 eventType: canonicalEvent.type,
296− }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))),
373+ }).pipe(
374+ Effect.andThen(
375+ persistRuntimeLifecycle(canonicalEvent).pipe(
376+ Effect.catchCause((cause) =>
377+ Effect.logWarning("provider.session.runtime.lifecycle-persist-failed", {
378+ threadId: canonicalEvent.threadId,
379+ provider: canonicalEvent.provider,
380+ providerInstanceId: canonicalEvent.providerInstanceId,
381+ eventType: canonicalEvent.type,
382+ errorTag: causeErrorTag(cause),
383+ }),
384+ ),
385+ ),
386+ ),
387+ Effect.andThen(publishRuntimeEvent(canonicalEvent)),
388+ ),
297389 ),
298390 );
299391
integrations/hermes-t3-gateway/COMPATIBILITY.md+14−8
@@ -4,11 +4,15 @@ The plugin deliberately uses only the public Hermes plugin and platform-adapter · 3 unmodified lines
44 surfaces audited at Hermes Agent commit
55 `3910ab28c0892fcf846fc61318d2fd15689eddf1`.
66
7−## Mapped in V1
7+This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn
8+recovery in `session.ready` and authoritative `content.snapshot` replacement;
9+older server/plugin pairs are rejected during the handshake.
10+
11+## Mapped in the initial scope
812
913 | Hermes surface | T3 gateway event |
1014 | ----------------------------------------- | ---------------------------------------------- |
11−| Cumulative `send` / `edit_message` output | `content.delta` |
15+| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` |
1216 | Final stream edit | `item.completed`, `turn.completed` |
1317 | `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` |
1418 | Live adapter status text | Generic `unknown` activity item |
@@ -20,14 +24,16 @@ surfaces audited at Hermes Agent commit · 5 unmodified lines
2024 ## Known limitations
2125
2226 - The platform adapter receives cumulative rendered text, not the underlying
23− token stream category. V1 maps it to `assistant_text`; Hermes reasoning,
27+ token stream category. The current adapter maps it to `assistant_text`; Hermes reasoning,
2428 plan, and command-output stream categories are not publicly exposed here.
25−- The delta contract has no replacement operation. Rare edits that revise
26− already-emitted text become a meaningful generic activity instead of
27− corrupting the T3 transcript.
29+- Prefix-extending cumulative edits emit `content.delta`; edits that revise or
30+ clear already-emitted text emit an authoritative `content.snapshot`.
31+- Hermes' exact first-chat T3 home-channel notice is suppressed at the adapter
32+ output boundary. The plugin does not assign a home channel or redirect
33+ proactive delivery; other Hermes platform notices pass through unchanged.
2834 - Hermes' documented tool hook surface exposes a `task_id`, tool name,
2935 arguments, string result, and duration. The audited runtime additionally
30− supplies `session_id` and `tool_call_id`; V1 uses them when present and falls
36+ supplies `session_id` and `tool_call_id`; the adapter uses them when present and falls
3137 back to the documented IDs for older versions. It projects only canonical,
3238 whitelisted fields (command/cwd, file path, search query, image path, or MCP
3339 server/operation); arbitrary arguments and raw results never cross the wire.
@@ -45,4 +51,4 @@ surfaces audited at Hermes Agent commit · 11 unmodified lines
4551 Hermes success response begins with `⏩ Steer queued`. Unknown future response
4652 shapes fail closed with `protocol.error` rather than completing the turn.
4753 - Attachments are not accepted. They are the first planned post-stability
48− feature; the capability is reserved and fixed to `false` in protocol V1.
54+ feature; the capability is reserved and fixed to `false` in protocol v2.
integrations/hermes-t3-gateway/README.md+9−1
@@ -4,6 +4,9 @@ Experimental Hermes platform plugin for connecting one already-running Hermes · 3 unmodified lines
44 process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes
55 does not need to listen on a public port.
66
7+The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated
8+together; mismatched versions fail the connection handshake closed.
9+
710 Each T3 thread maps deterministically to one Hermes gateway session. A new T3
811 thread creates a new session identity, later messages use the same identity,
912 active-turn messages use Hermes' native `/steer` path, and stopping a turn does
@@ -43,9 +46,10 @@ The long-lived credential is never printed. Restart `hermes gateway` after · 33 unmodified lines
4346 enrollment. `hermes t3 status` reports the local enrollment without revealing
4447 the credential.
4548
46−## V1 scope
49+## Initial scope
4750
4851 - Text input and live assistant streaming
52+- Authoritative text replacement when Hermes revises cumulative streamed output
4953 - Multiple concurrent Hermes sessions in one process
5054 - Active-turn steering and interrupt
5155 - Dangerous-command approvals
@@ -54,6 +58,10 @@ the credential. · 2 unmodified lines
5458 - Reconnect with bounded backoff
5559 - Version-incompatible and revoked credentials fail closed
5660
61+T3 threads are session-only in the initial scope. The adapter suppresses Hermes' exact
62+first-chat `/sethome` notice without assigning a home channel or redirecting
63+cron and cross-platform delivery.
64+
5765 Attachments intentionally advertise `false`. Adding bounded image/file input is
5866 the first post-stability feature and should not reuse arbitrary raw payloads.
5967
integrations/hermes-t3-gateway/adapter.py+41−11
@@ -35,6 +35,13 @@ from .protocol import ( · 34 unmodified lines
3535
3636 logger = logging.getLogger(__name__)
3737
38+_T3_HOME_CHANNEL_NOTICE = (
39+ "📬 No home channel is set for T3. "
40+ "A home channel is where Hermes delivers cron job results "
41+ "and cross-platform messages.\n\n"
42+ "Type /sethome to make this chat your home channel, or ignore to skip."
43+)
44+
3845
3946 def _hermes_version() -> str:
4047 try:
@@ -163,6 +170,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 122 unmodified lines
163170 if turn is None:
164171 return SendResult(success=False, error="no active T3 turn")
165172 try:
173+ if content == _T3_HOME_CHANNEL_NOTICE:
174+ if bool((metadata or {}).get("notify")):
175+ await self._complete_turn(turn)
176+ return SendResult(success=True, message_id=turn.message_id)
166177 await self._emit_assistant_content(turn, content)
167178 if bool((metadata or {}).get("notify")):
168179 await self._complete_turn(turn)
@@ -187,6 +198,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 18 unmodified lines
187198 if turn is None:
188199 return SendResult(success=False, error="no active T3 turn")
189200 try:
201+ if content == _T3_HOME_CHANNEL_NOTICE:
202+ if finalize:
203+ await self._complete_turn(turn)
204+ return SendResult(success=True, message_id=message_id)
190205 await self._emit_assistant_content(turn, content)
191206 if finalize:
192207 await self._complete_turn(turn)
@@ -343,6 +358,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 150 unmodified lines
343358 self._sessions[thread_id] = session_id
344359 self._active_session_threads.add(thread_id)
345360 self._thread_by_session[session_id] = thread_id
361+ active_turn = self._active_turns.get(thread_id)
346362 await self._send_frame(
347363 frame(
348364 "session.ready",
@@ -350,6 +366,11 @@ class T3PlatformAdapter(BasePlatformAdapter): · 1 unmodified line
350366 threadId=thread_id,
351367 sessionId=session_id,
352368 resumed=bool(resume_id and resume_id == session_id),
369+ **(
370+ {"activeTurnId": active_turn.turn_id}
371+ if active_turn is not None
372+ else {}
373+ ),
353374 )
354375 )
355376 await self._send_status()
@@ -647,7 +668,6 @@ class T3PlatformAdapter(BasePlatformAdapter): · 291 unmodified lines
647668 async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None:
648669 visible = str(content or "").replace(" ▉", "").replace("▉", "")
649670 if not turn.assistant_started:
650− turn.assistant_started = True
651671 await self._send_frame(
652672 frame(
653673 "item.started",
@@ -660,9 +680,9 @@ class T3PlatformAdapter(BasePlatformAdapter): · 6 unmodified lines
660680 title="Hermes response",
661681 )
662682 )
683+ turn.assistant_started = True
663684 if visible.startswith(turn.visible_text):
664685 delta = visible[len(turn.visible_text) :]
665− turn.visible_text = visible
666686 if delta:
667687 await self._send_frame(
668688 frame(
@@ -676,13 +696,21 @@ class T3PlatformAdapter(BasePlatformAdapter): · 7 unmodified lines
676696 contentIndex=0,
677697 )
678698 )
699+ turn.visible_text = visible
679700 elif visible != turn.visible_text:
680− # Hermes' public platform edit hook exposes cumulative rendered text,
681− # but the v1 T3 delta contract has no replacement operation. Preserve
682− # the valid prefix and report the rare rewrite as generic activity.
683− await self._emit_generic_activity(
684− turn, "Hermes revised already-streamed text; replacement is deferred."
701+ await self._send_frame(
702+ frame(
703+ "content.snapshot",
704+ threadId=turn.thread_id,
705+ sessionId=turn.session_id,
706+ turnId=turn.turn_id,
707+ itemId=turn.message_id,
708+ streamKind="assistant_text",
709+ text=visible,
710+ contentIndex=0,
711+ )
685712 )
713+ turn.visible_text = visible
686714
687715 async def _complete_turn(self, turn: _TurnState) -> None:
688716 if self._active_turns.get(turn.thread_id) is not turn:
@@ -737,9 +765,9 @@ class T3PlatformAdapter(BasePlatformAdapter): · 48 unmodified lines
737765 normalized_detail = str(detail)[:2_000]
738766 if turn.generic_activity_detail == normalized_detail:
739767 return
740− turn.generic_activity_detail = normalized_detail
741− if turn.generic_activity_id is None:
742− turn.generic_activity_id = item_id()
768+ activity_id = turn.generic_activity_id
769+ if activity_id is None:
770+ activity_id = item_id()
743771 event_type = "item.started"
744772 else:
745773 event_type = "item.updated"
@@ -749,13 +777,15 @@ class T3PlatformAdapter(BasePlatformAdapter): · 3 unmodified lines
749777 threadId=turn.thread_id,
750778 sessionId=turn.session_id,
751779 turnId=turn.turn_id,
752− itemId=turn.generic_activity_id,
780+ itemId=activity_id,
753781 itemType="unknown",
754782 status="inProgress",
755783 title="Hermes activity",
756784 detail=normalized_detail,
757785 )
758786 )
787+ turn.generic_activity_id = activity_id
788+ turn.generic_activity_detail = normalized_detail
759789
760790 def emit_tool_started(
761791 self,
integrations/hermes-t3-gateway/connection.py+2−2
@@ -10,7 +10,7 @@ from contextlib import suppress · 9 unmodified lines
1010 from typing import Any
1111 from urllib.parse import urlsplit, urlunsplit
1212
13−from .protocol import WEBSOCKET_PATH, connection_hello
13+from .protocol import PROTOCOL_VERSION, WEBSOCKET_PATH, connection_hello
1414
1515 logger = logging.getLogger(__name__)
1616
@@ -93,7 +93,7 @@ async def authenticate_socket( · 76 unmodified lines
9393 raise RuntimeError(
9494 f"expected connection.accepted, received {message.get('type')!r}"
9595 )
96− if message.get("protocolVersion") != 1:
96+ if message.get("protocolVersion") != PROTOCOL_VERSION:
9797 raise RuntimeError("T3 accepted the connection with an incompatible version")
9898 return message
9999
integrations/hermes-t3-gateway/plugin.yaml+1−1
@@ -1,7 +1,7 @@
11 name: hermes-t3-gateway
22 label: T3 Code
33 kind: platform
4−version: 0.1.0
4+version: 0.2.0
55 description: >
66 Experimental outbound gateway that exposes one Hermes Agent process as a
77 named T3 Code provider instance over an authenticated WebSocket.
integrations/hermes-t3-gateway/protocol.py+2−2
@@ -6,8 +6,8 @@ import uuid · 5 unmodified lines
66 from datetime import datetime, timezone
77 from typing import Any
88
9−PROTOCOL_VERSION = 1
10−PLUGIN_VERSION = "0.1.0"
9+PROTOCOL_VERSION = 2
10+PLUGIN_VERSION = "0.2.0"
1111 WEBSOCKET_PATH = "/api/hermes-gateway/ws"
1212
1313 CAPABILITIES = {
integrations/hermes-t3-gateway/tests/test_adapter.py+330−13
@@ -176,11 +176,34 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 175 unmodified lines
176176 self.connection = FakeConnection()
177177 self.adapter._connection = self.connection
178178
179+ async def _start_turn(self, thread_id: str, turn_id: str):
180+ await self.adapter._handle_server_frame(
181+ {
182+ "type": "session.ensure",
183+ "protocolVersion": 2,
184+ "requestId": f"ensure-{thread_id}",
185+ "threadId": thread_id,
186+ }
187+ )
188+ session_id = self.adapter._sessions[thread_id]
189+ await self.adapter._handle_server_frame(
190+ {
191+ "type": "turn.start",
192+ "protocolVersion": 2,
193+ "requestId": f"start-{thread_id}",
194+ "threadId": thread_id,
195+ "sessionId": session_id,
196+ "turnId": turn_id,
197+ "text": "Start",
198+ }
199+ )
200+ return session_id
201+
179202 async def test_thread_ensure_start_stream_and_complete(self):
180203 await self.adapter._handle_server_frame(
181204 {
182205 "type": "session.ensure",
183− "protocolVersion": 1,
206+ "protocolVersion": 2,
184207 "requestId": "ensure-1",
185208 "threadId": "thread-1",
186209 }
@@ -193,7 +216,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 6 unmodified lines
193216 await self.adapter._handle_server_frame(
194217 {
195218 "type": "turn.start",
196− "protocolVersion": 1,
219+ "protocolVersion": 2,
197220 "requestId": "start-1",
198221 "threadId": "thread-1",
199222 "sessionId": ready["sessionId"],
@@ -216,11 +239,305 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 16 unmodified lines
216239 ]
217240 self.assertEqual(deltas, ["Hello", " world"])
218241
242+ async def test_cumulative_edits_emit_delta_snapshot_delta_then_finalize(self):
243+ await self._start_turn("thread-snapshot", "turn-snapshot")
244+ content_start = len(self.connection.messages)
245+
246+ await self.adapter.send("thread-snapshot", "Hello")
247+ duplicate_start = len(self.connection.messages)
248+ await self.adapter.edit_message("thread-snapshot", "message", "Hello")
249+ self.assertEqual(len(self.connection.messages), duplicate_start)
250+
251+ await self.adapter.edit_message("thread-snapshot", "message", "Help")
252+ snapshot_duplicate_start = len(self.connection.messages)
253+ await self.adapter.edit_message("thread-snapshot", "message", "Help")
254+ self.assertEqual(len(self.connection.messages), snapshot_duplicate_start)
255+
256+ await self.adapter.edit_message(
257+ "thread-snapshot",
258+ "message",
259+ "Helpful",
260+ finalize=True,
261+ )
262+
263+ content_frames = self.connection.messages[content_start:]
264+ self.assertEqual(
265+ [message["type"] for message in content_frames],
266+ [
267+ "item.started",
268+ "content.delta",
269+ "content.snapshot",
270+ "content.delta",
271+ "item.completed",
272+ "turn.completed",
273+ "connection.status",
274+ ],
275+ )
276+ self.assertEqual(content_frames[1]["delta"], "Hello")
277+ self.assertEqual(content_frames[2]["text"], "Help")
278+ self.assertEqual(content_frames[3]["delta"], "ful")
279+
280+ async def test_empty_and_duplicate_cumulative_edits_are_reconciled(self):
281+ await self._start_turn("thread-empty", "turn-empty")
282+ content_start = len(self.connection.messages)
283+
284+ await self.adapter.send("thread-empty", "")
285+ duplicate_start = len(self.connection.messages)
286+ await self.adapter.edit_message("thread-empty", "message", "")
287+ self.assertEqual(len(self.connection.messages), duplicate_start)
288+
289+ await self.adapter.edit_message("thread-empty", "message", "Visible")
290+ await self.adapter.edit_message("thread-empty", "message", "")
291+ empty_snapshot_end = len(self.connection.messages)
292+ await self.adapter.edit_message("thread-empty", "message", "")
293+ self.assertEqual(len(self.connection.messages), empty_snapshot_end)
294+ await self.adapter.edit_message(
295+ "thread-empty",
296+ "message",
297+ "",
298+ finalize=True,
299+ )
300+
301+ content_frames = self.connection.messages[content_start:]
302+ self.assertEqual(
303+ [message["type"] for message in content_frames],
304+ [
305+ "item.started",
306+ "content.delta",
307+ "content.snapshot",
308+ "item.completed",
309+ "turn.completed",
310+ "connection.status",
311+ ],
312+ )
313+ self.assertEqual(content_frames[1]["delta"], "Visible")
314+ self.assertEqual(content_frames[2]["text"], "")
315+
316+ async def test_failed_content_sends_do_not_advance_visible_text(self):
317+ await self._start_turn("thread-retry", "turn-retry")
318+ await self.adapter.send("thread-retry", "Hello")
319+ original_send = self.connection.send
320+
321+ async def fail_content(message):
322+ if message["type"] in {"content.delta", "content.snapshot"}:
323+ raise ConnectionError("send failed")
324+ await original_send(message)
325+
326+ self.connection.send = fail_content
327+ failed = await self.adapter.edit_message(
328+ "thread-retry",
329+ "message",
330+ "Hello world",
331+ )
332+ self.assertFalse(failed.success)
333+ self.assertEqual(
334+ self.adapter._active_turns["thread-retry"].visible_text,
335+ "Hello",
336+ )
337+
338+ self.connection.send = original_send
339+ retried = await self.adapter.edit_message(
340+ "thread-retry",
341+ "message",
342+ "Hello world",
343+ )
344+ self.assertTrue(retried.success)
345+ self.assertEqual(self.connection.messages[-1]["delta"], " world")
346+
347+ self.connection.send = fail_content
348+ failed_snapshot = await self.adapter.edit_message(
349+ "thread-retry",
350+ "message",
351+ "Hi",
352+ )
353+ self.assertFalse(failed_snapshot.success)
354+ self.assertEqual(
355+ self.adapter._active_turns["thread-retry"].visible_text,
356+ "Hello world",
357+ )
358+
359+ self.connection.send = original_send
360+ retried_snapshot = await self.adapter.edit_message(
361+ "thread-retry",
362+ "message",
363+ "Hi",
364+ )
365+ self.assertTrue(retried_snapshot.success)
366+ self.assertEqual(self.connection.messages[-1]["type"], "content.snapshot")
367+ self.assertEqual(self.connection.messages[-1]["text"], "Hi")
368+
369+ async def test_failed_generic_activity_start_retries_the_full_lifecycle(self):
370+ await self._start_turn("thread-activity-retry", "turn-activity-retry")
371+ turn = self.adapter._active_turns["thread-activity-retry"]
372+ original_send = self.connection.send
373+
374+ async def fail_activity_start(message):
375+ if message["type"] == "item.started":
376+ raise ConnectionError("send failed")
377+ await original_send(message)
378+
379+ self.connection.send = fail_activity_start
380+ with self.assertRaisesRegex(ConnectionError, "send failed"):
381+ await self.adapter._emit_generic_activity(turn, "Reading repository")
382+
383+ self.assertIsNone(turn.generic_activity_id)
384+ self.assertIsNone(turn.generic_activity_detail)
385+
386+ self.connection.send = original_send
387+ await self.adapter._emit_generic_activity(turn, "Reading repository")
388+ started = self.connection.messages[-1]
389+ self.assertEqual(started["type"], "item.started")
390+ self.assertEqual(started["detail"], "Reading repository")
391+ self.assertEqual(turn.generic_activity_id, started["itemId"])
392+ self.assertEqual(turn.generic_activity_detail, "Reading repository")
393+
394+ await self.adapter._emit_generic_activity(turn, "Running tests")
395+ updated = self.connection.messages[-1]
396+ self.assertEqual(updated["type"], "item.updated")
397+ self.assertEqual(updated["itemId"], started["itemId"])
398+ self.assertEqual(updated["detail"], "Running tests")
399+
400+ async def test_exact_t3_home_channel_notice_is_suppressed(self):
401+ await self._start_turn("thread-notice", "turn-notice")
402+ content_start = len(self.connection.messages)
403+ notice = (
404+ "📬 No home channel is set for T3. "
405+ "A home channel is where Hermes delivers cron job results "
406+ "and cross-platform messages.\n\n"
407+ "Type /sethome to make this chat your home channel, or ignore to skip."
408+ )
409+
410+ suppressed = await self.adapter.send("thread-notice", notice)
411+ self.assertTrue(suppressed.success)
412+ self.assertEqual(len(self.connection.messages), content_start)
413+ self.assertFalse(
414+ self.adapter._active_turns["thread-notice"].assistant_started
415+ )
416+
417+ await self.adapter.edit_message(
418+ "thread-notice",
419+ "message",
420+ "The actual Hermes response",
421+ finalize=True,
422+ )
423+ content_frames = self.connection.messages[content_start:]
424+ self.assertEqual(
425+ [message["type"] for message in content_frames],
426+ [
427+ "item.started",
428+ "content.delta",
429+ "item.completed",
430+ "turn.completed",
431+ "connection.status",
432+ ],
433+ )
434+ self.assertEqual(content_frames[1]["delta"], "The actual Hermes response")
435+
436+ async def test_terminal_send_suppresses_exact_home_notice_and_completes_turn(self):
437+ await self._start_turn("thread-terminal-notice-send", "turn-terminal-notice-send")
438+ content_start = len(self.connection.messages)
439+ notice = (
440+ "📬 No home channel is set for T3. "
441+ "A home channel is where Hermes delivers cron job results "
442+ "and cross-platform messages.\n\n"
443+ "Type /sethome to make this chat your home channel, or ignore to skip."
444+ )
445+
446+ suppressed = await self.adapter.send(
447+ "thread-terminal-notice-send",
448+ notice,
449+ metadata={"notify": True},
450+ )
451+
452+ self.assertTrue(suppressed.success)
453+ self.assertNotIn("thread-terminal-notice-send", self.adapter._active_turns)
454+ self.assertEqual(
455+ [
456+ message["type"]
457+ for message in self.connection.messages[content_start:]
458+ ],
459+ ["turn.completed", "connection.status"],
460+ )
461+
462+ async def test_terminal_edit_suppresses_exact_home_notice_and_completes_turn(self):
463+ await self._start_turn("thread-terminal-notice-edit", "turn-terminal-notice-edit")
464+ content_start = len(self.connection.messages)
465+ notice = (
466+ "📬 No home channel is set for T3. "
467+ "A home channel is where Hermes delivers cron job results "
468+ "and cross-platform messages.\n\n"
469+ "Type /sethome to make this chat your home channel, or ignore to skip."
470+ )
471+
472+ suppressed = await self.adapter.edit_message(
473+ "thread-terminal-notice-edit",
474+ "message",
475+ notice,
476+ finalize=True,
477+ )
478+
479+ self.assertTrue(suppressed.success)
480+ self.assertNotIn("thread-terminal-notice-edit", self.adapter._active_turns)
481+ self.assertEqual(
482+ [
483+ message["type"]
484+ for message in self.connection.messages[content_start:]
485+ ],
486+ ["turn.completed", "connection.status"],
487+ )
488+
489+ async def test_near_match_home_channel_text_is_not_suppressed(self):
490+ await self._start_turn("thread-notice-near-match", "turn-notice-near-match")
491+ content_start = len(self.connection.messages)
492+ await self.adapter.edit_message(
493+ "thread-notice-near-match",
494+ "message",
495+ (
496+ "📬 No home channel is set for T3. "
497+ "A home channel is where Hermes delivers cron job results "
498+ "and cross-platform messages.\n\n"
499+ "Type /sethome to make this chat your home channel, "
500+ "or ignore to skip. "
501+ ),
502+ finalize=True,
503+ )
504+ self.assertEqual(
505+ [
506+ message["type"]
507+ for message in self.connection.messages[content_start:]
508+ ],
509+ [
510+ "item.started",
511+ "content.delta",
512+ "item.completed",
513+ "turn.completed",
514+ "connection.status",
515+ ],
516+ )
517+
518+ async def test_session_ready_reports_an_active_turn_on_reconnect(self):
519+ session_id = await self._start_turn("thread-reconnect", "turn-reconnect")
520+
521+ await self.adapter._handle_server_frame(
522+ {
523+ "type": "session.ensure",
524+ "protocolVersion": 2,
525+ "requestId": "ensure-reconnect",
526+ "threadId": "thread-reconnect",
527+ "resumeSessionId": session_id,
528+ }
529+ )
530+
531+ ready = self.connection.messages[-2]
532+ self.assertEqual(ready["type"], "session.ready")
533+ self.assertTrue(ready["resumed"])
534+ self.assertEqual(ready["activeTurnId"], "turn-reconnect")
535+
219536 async def test_steer_uses_official_hermes_command(self):
220537 await self.adapter._handle_server_frame(
221538 {
222539 "type": "session.ensure",
223− "protocolVersion": 1,
540+ "protocolVersion": 2,
224541 "requestId": "ensure-2",
225542 "threadId": "thread-2",
226543 }
@@ -229,7 +546,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
229546 await self.adapter._handle_server_frame(
230547 {
231548 "type": "turn.start",
232− "protocolVersion": 1,
549+ "protocolVersion": 2,
233550 "requestId": "start-2",
234551 "threadId": "thread-2",
235552 "sessionId": session_id,
@@ -248,7 +565,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines
248565 await self.adapter._handle_server_frame(
249566 {
250567 "type": "turn.steer",
251− "protocolVersion": 1,
568+ "protocolVersion": 2,
252569 "requestId": "steer-2",
253570 "threadId": "thread-2",
254571 "sessionId": session_id,
@@ -283,7 +600,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 28 unmodified lines
283600 await self.adapter._handle_server_frame(
284601 {
285602 "type": "session.ensure",
286− "protocolVersion": 1,
603+ "protocolVersion": 2,
287604 "requestId": "ensure-rejected-steer",
288605 "threadId": "thread-rejected-steer",
289606 }
@@ -292,7 +609,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
292609 await self.adapter._handle_server_frame(
293610 {
294611 "type": "turn.start",
295− "protocolVersion": 1,
612+ "protocolVersion": 2,
296613 "requestId": "start-rejected-steer",
297614 "threadId": "thread-rejected-steer",
298615 "sessionId": session_id,
@@ -309,7 +626,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
309626 await self.adapter._handle_server_frame(
310627 {
311628 "type": "turn.steer",
312− "protocolVersion": 1,
629+ "protocolVersion": 2,
313630 "requestId": "steer-rejected",
314631 "threadId": "thread-rejected-steer",
315632 "sessionId": session_id,
@@ -339,7 +656,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 23 unmodified lines
339656 await self.adapter._handle_server_frame(
340657 {
341658 "type": "session.ensure",
342− "protocolVersion": 1,
659+ "protocolVersion": 2,
343660 "requestId": "ensure-failed-steer",
344661 "threadId": "thread-failed-steer",
345662 }
@@ -348,7 +665,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
348665 await self.adapter._handle_server_frame(
349666 {
350667 "type": "turn.start",
351− "protocolVersion": 1,
668+ "protocolVersion": 2,
352669 "requestId": "start-failed-steer",
353670 "threadId": "thread-failed-steer",
354671 "sessionId": session_id,
@@ -365,7 +682,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
365682 await self.adapter._handle_server_frame(
366683 {
367684 "type": "turn.steer",
368− "protocolVersion": 1,
685+ "protocolVersion": 2,
369686 "requestId": "steer-failed",
370687 "threadId": "thread-failed-steer",
371688 "sessionId": session_id,
@@ -386,7 +703,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines
386703 await self.adapter._handle_server_frame(
387704 {
388705 "type": "session.ensure",
389− "protocolVersion": 1,
706+ "protocolVersion": 2,
390707 "requestId": "ensure-3",
391708 "threadId": "thread-3",
392709 }
@@ -396,7 +713,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 3 unmodified lines
396713 await self.adapter._handle_server_frame(
397714 {
398715 "type": "session.stop",
399− "protocolVersion": 1,
716+ "protocolVersion": 2,
400717 "requestId": "stop-3",
401718 "threadId": "thread-3",
402719 "sessionId": session_id,
integrations/hermes-t3-gateway/tests/test_connection.py+22−2
@@ -60,7 +60,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 59 unmodified lines
6060 socket = FakeSocket(
6161 {
6262 "type": "connection.accepted",
63− "protocolVersion": 1,
63+ "protocolVersion": 2,
6464 "instanceId": "provider-instance",
6565 "nickname": "Research",
6666 "credential": "persistent-secret",
@@ -77,13 +77,33 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
7777 {"type": "enrollment-token", "token": "once"},
7878 )
7979
80+ async def test_accepted_handshake_rejects_an_incompatible_protocol(self):
81+ socket = FakeSocket(
82+ {
83+ "type": "connection.accepted",
84+ "protocolVersion": 1,
85+ "instanceId": "provider-instance",
86+ "nickname": "Research",
87+ }
88+ )
89+ with self.assertRaisesRegex(RuntimeError, "incompatible version"):
90+ await connection.authenticate_socket(
91+ socket,
92+ authentication={
93+ "type": "instance-credential",
94+ "instanceId": "provider-instance",
95+ "credential": "secret",
96+ },
97+ hermes_version="0.19.0",
98+ )
99+
80100 async def test_rejected_handshake_fails_closed(self):
81101 socket = FakeSocket(
82102 {
83103 "type": "connection.rejected",
84104 "code": "version-incompatible",
85105 "message": "upgrade required",
86− "expectedProtocolVersion": 1,
106+ "expectedProtocolVersion": 2,
87107 }
88108 )
89109 with self.assertRaises(connection.ConnectionRejected) as raised:
integrations/hermes-t3-gateway/tests/test_protocol.py+5−4
@@ -14,7 +14,7 @@ SPEC.loader.exec_module(protocol) · 13 unmodified lines
1414
1515
1616 class ProtocolTests(unittest.TestCase):
17− def test_hello_matches_v1_contract(self):
17+ def test_hello_matches_v2_contract(self):
1818 hello = protocol.connection_hello(
1919 hermes_version="0.19.0",
2020 authentication={"type": "enrollment-token", "token": "once"},
@@ -22,15 +22,16 @@ class ProtocolTests(unittest.TestCase): · 1 unmodified line
2222 )
2323 self.assertEqual(hello["type"], "connection.hello")
2424 self.assertEqual(hello["requestId"], "request-1")
25− self.assertEqual(hello["protocolVersion"], 1)
25+ self.assertEqual(hello["protocolVersion"], 2)
2626 self.assertFalse(hello["capabilities"]["attachments"])
2727 self.assertTrue(hello["capabilities"]["streaming"])
2828
2929 def test_server_frame_validation_is_closed(self):
3030 with self.assertRaisesRegex(ValueError, "unsupported"):
31− protocol.validate_server_frame({"type": "made.up", "protocolVersion": 1})
31+ protocol.validate_server_frame({"type": "made.up", "protocolVersion": 2})
3232 with self.assertRaisesRegex(ValueError, "version"):
33− protocol.validate_server_frame({"type": "ping", "protocolVersion": 2})
33+ # Protocol v1 peers must upgrade before sending runtime frames.
34+ protocol.validate_server_frame({"type": "ping", "protocolVersion": 1})
3435
3536 def test_tool_types_map_to_canonical_items(self):
3637 self.assertEqual(
packages/contracts/src/hermesGateway.test.ts+64−37
@@ -67,12 +67,12 @@ describe("Hermes gateway management contracts", () => { · 66 unmodified lines
6767 status: "connected",
6868 connectorUrl: "wss://siva.davis7.space/hermes",
6969 lastConnectedAt: "2026-07-23T12:00:00.000Z",
70− pluginVersion: "0.1.0",
70+ pluginVersion: "0.2.0",
7171 hermesVersion: "1.2.3",
7272 activeSessionCount: 2,
73− protocolVersion: 1,
73+ protocolVersion: 2,
7474 capabilities: {
75− protocolVersion: 1,
75+ protocolVersion: 2,
7676 streaming: true,
7777 activity: true,
7878 approvals: true,
@@ -86,10 +86,10 @@ describe("Hermes gateway management contracts", () => { · 7 unmodified lines
8686 const upgradeRequired = decodeInstanceStatus({
8787 ...connected,
8888 status: "upgrade-required",
89− protocolVersion: 2,
89+ protocolVersion: 3,
9090 capabilities: null,
9191 });
92− expect(upgradeRequired.protocolVersion).toBe(2);
92+ expect(upgradeRequired.protocolVersion).toBe(3);
9393 expect(upgradeRequired.capabilities).toBeNull();
9494 });
9595 });
@@ -99,11 +99,11 @@ describe("Hermes gateway handshake", () => { · 3 unmodified lines
9999 const hello = decodeHello({
100100 type: "connection.hello",
101101 requestId: "hello-1",
102− protocolVersion: 1,
103− pluginVersion: "0.1.0",
102+ protocolVersion: 2,
103+ pluginVersion: "0.2.0",
104104 hermesVersion: "1.2.3",
105105 capabilities: {
106− protocolVersion: 1,
106+ protocolVersion: 2,
107107 streaming: true,
108108 activity: true,
109109 approvals: true,
@@ -123,11 +123,11 @@ describe("Hermes gateway handshake", () => { · 13 unmodified lines
123123 const hello = decodeHello({
124124 type: "connection.hello",
125125 requestId: "hello-2",
126− protocolVersion: 1,
127− pluginVersion: "0.1.0",
126+ protocolVersion: 2,
127+ pluginVersion: "0.2.0",
128128 hermesVersion: "1.2.3",
129129 capabilities: {
130− protocolVersion: 1,
130+ protocolVersion: 2,
131131 streaming: true,
132132 activity: true,
133133 approvals: true,
@@ -148,11 +148,11 @@ describe("Hermes gateway handshake", () => { · 14 unmodified lines
148148 const hello = decodeHello({
149149 type: "connection.hello",
150150 requestId: "hello-future",
151− protocolVersion: 2,
152− pluginVersion: "0.2.0",
151+ protocolVersion: 3,
152+ pluginVersion: "0.3.0",
153153 hermesVersion: "2.0.0",
154154 capabilities: {
155− protocolVersion: 2,
155+ protocolVersion: 3,
156156 streaming: true,
157157 activity: true,
158158 approvals: true,
@@ -165,14 +165,14 @@ describe("Hermes gateway handshake", () => { · 6 unmodified lines
165165 },
166166 });
167167
168− expect(hello.protocolVersion).toBe(2);
169− expect(hello.capabilities.protocolVersion).toBe(2);
168+ expect(hello.protocolVersion).toBe(3);
169+ expect(hello.capabilities.protocolVersion).toBe(3);
170170 });
171171
172172 it("reserves attachments for a future protocol version", () => {
173173 expect(() =>
174174 decodeCapabilities({
175− protocolVersion: 1,
175+ protocolVersion: 2,
176176 streaming: true,
177177 activity: true,
178178 approvals: true,
@@ -183,12 +183,12 @@ describe("Hermes gateway handshake", () => { · 4 unmodified lines
183183 });
184184 });
185185
186−describe("T3 to Hermes v1 messages", () => {
186+describe("T3 to Hermes v2 messages", () => {
187187 it("decodes session creation and opaque resume cursors", () => {
188188 expect(
189189 decodeT3Message({
190190 type: "session.ensure",
191− protocolVersion: 1,
191+ protocolVersion: 2,
192192 requestId: "ensure-1",
193193 threadId: "thread-1",
194194 resumeSessionId: "opaque/hermes/session/value",
@@ -197,7 +197,7 @@ describe("T3 to Hermes v1 messages", () => { · 2 unmodified lines
197197
198198 expect(
199199 decodeResumeCursor({
200− protocolVersion: 1,
200+ protocolVersion: 2,
201201 sessionId: "opaque/hermes/session/value",
202202 }).sessionId,
203203 ).toBe("opaque/hermes/session/value");
@@ -205,7 +205,7 @@ describe("T3 to Hermes v1 messages", () => { · 1 unmodified line
205205
206206 it("decodes start and steering as distinct turn operations", () => {
207207 const context = {
208− protocolVersion: 1,
208+ protocolVersion: 2,
209209 requestId: "turn-command-1",
210210 threadId: "thread-1",
211211 sessionId: "session-1",
@@ -219,7 +219,7 @@ describe("T3 to Hermes v1 messages", () => { · 7 unmodified lines
219219
220220 it("decodes interrupt, approval, structured input, stop, and ping", () => {
221221 const turnContext = {
222− protocolVersion: 1,
222+ protocolVersion: 2,
223223 threadId: "thread-1",
224224 sessionId: "session-1",
225225 turnId: "turn-1",
@@ -251,7 +251,7 @@ describe("T3 to Hermes v1 messages", () => { · 25 unmodified lines
251251 expect(
252252 decodeT3Message({
253253 type: "session.stop",
254− protocolVersion: 1,
254+ protocolVersion: 2,
255255 requestId: "stop-1",
256256 threadId: "thread-1",
257257 sessionId: "session-1",
@@ -260,7 +260,7 @@ describe("T3 to Hermes v1 messages", () => { · 2 unmodified lines
260260 expect(
261261 decodeT3Message({
262262 type: "ping",
263− protocolVersion: 1,
263+ protocolVersion: 2,
264264 requestId: "ping-1",
265265 sentAt: "2026-07-23T12:00:00.000Z",
266266 }).type,
@@ -271,7 +271,8 @@ describe("T3 to Hermes v1 messages", () => { · 4 unmodified lines
271271 expect(() =>
272272 decodeT3Message({
273273 type: "ping",
274− protocolVersion: 2,
274+ // Protocol v1 peers must upgrade before sending post-handshake frames.
275+ protocolVersion: 1,
275276 requestId: "ping-1",
276277 sentAt: "2026-07-23T12:00:00.000Z",
277278 }),
@@ -279,25 +280,38 @@ describe("T3 to Hermes v1 messages", () => { · 1 unmodified line
279280 });
280281 });
281282
282−describe("Hermes to T3 v1 events", () => {
283+describe("Hermes to T3 v2 events", () => {
283284 const turnContext = {
284− protocolVersion: 1,
285+ protocolVersion: 2,
285286 threadId: "thread-1",
286287 sessionId: "session-1",
287288 turnId: "turn-1",
288289 };
289290
290291 it("decodes session readiness, turn start, streaming text, and completion", () => {
291− expect(
292− decodePluginMessage({
293− type: "session.ready",
294− protocolVersion: 1,
295− requestId: "ensure-1",
296− threadId: "thread-1",
297− sessionId: "session-1",
298− resumed: false,
299− }).type,
300− ).toBe("session.ready");
292+ const legacyReady = decodePluginMessage({
293+ type: "session.ready",
294+ protocolVersion: 2,
295+ requestId: "ensure-1",
296+ threadId: "thread-1",
297+ sessionId: "session-1",
298+ resumed: false,
299+ });
300+ expect(legacyReady.type).toBe("session.ready");
301+ const activeReady = decodePluginMessage({
302+ type: "session.ready",
303+ protocolVersion: 2,
304+ requestId: "ensure-2",
305+ threadId: "thread-1",
306+ sessionId: "session-1",
307+ resumed: true,
308+ activeTurnId: "turn-1",
309+ });
310+ expect(activeReady.type).toBe("session.ready");
311+ if (activeReady.type !== "session.ready") {
312+ throw new Error("expected session.ready");
313+ }
314+ expect(activeReady.activeTurnId).toBe("turn-1");
301315 expect(
302316 decodePluginMessage({
303317 type: "turn.started",
@@ -313,6 +327,19 @@ describe("Hermes to T3 v1 events", () => { · 9 unmodified lines
313327 ...turnContext,
314328 }).type,
315329 ).toBe("content.delta");
330+ const snapshot = decodePluginMessage({
331+ type: "content.snapshot",
332+ streamKind: "assistant_text",
333+ text: "",
334+ itemId: "message-1",
335+ contentIndex: 0,
336+ ...turnContext,
337+ });
338+ expect(snapshot.type).toBe("content.snapshot");
339+ if (snapshot.type !== "content.snapshot") {
340+ throw new Error("expected content.snapshot");
341+ }
342+ expect(snapshot.text).toBe("");
316343 expect(
317344 decodePluginMessage({
318345 type: "turn.completed",
packages/contracts/src/hermesGateway.ts+17−4
@@ -21,7 +21,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat · 20 unmodified lines
2121 import { ProviderInstanceId } from "./providerInstance.ts";
2222 import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
2323
24−export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const;
24+export const HERMES_GATEWAY_PROTOCOL_VERSION = 2 as const;
2525
2626 export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
2727 export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
@@ -87,7 +87,7 @@ export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; · 59 unmodified lines
8787 *
8888 * This deliberately permits capability shapes from a newer protocol so T3 can
8989 * return a structured `version-incompatible` rejection instead of failing the
90− * WebSocket frame decoder. Accepted v1 connections must subsequently validate
90+ * WebSocket frame decoder. Accepted v2 connections must subsequently validate
9191 * this advertisement with `HermesGatewayCapabilities`.
9292 */
9393 export const HermesGatewayHelloCapabilities = Schema.Struct({
@@ -112,7 +112,7 @@ export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.T · 18 unmodified lines
112112 /**
113113 * Public instance state used by settings and provider-picker surfaces.
114114 *
115− * `protocolVersion` is not restricted to v1 here so the UI can report the
115+ * `protocolVersion` is not restricted to v2 here so the UI can report the
116116 * unsupported version observed from a plugin that needs an upgrade.
117117 */
118118 export const HermesGatewayInstanceStatus = Schema.Struct({
@@ -241,7 +241,7 @@ export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Typ · 122 unmodified lines
241241 /**
242242 * `protocolVersion` accepts any positive integer at the initial boundary so
243243 * T3 can reject incompatible plugins with a structured upgrade response.
244− * Once accepted, all remaining v1 frames use the literal v1 schema.
244+ * Once accepted, all remaining v2 frames use the literal v2 schema.
245245 */
246246 export const HermesGatewayConnectionHello = Schema.Struct({
247247 type: Schema.Literal("connection.hello"),
@@ -380,6 +380,7 @@ export const HermesGatewaySessionReady = Schema.Struct({ · 132 unmodified lines
380380 threadId: ThreadId,
381381 sessionId: HermesGatewaySessionId,
382382 resumed: Schema.Boolean,
383+ activeTurnId: Schema.optional(TurnId),
383384 });
384385 export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type;
385386
@@ -412,6 +413,17 @@ export const HermesGatewayContentDelta = Schema.Struct({ · 26 unmodified lines
412413 });
413414 export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type;
414415
416+export const HermesGatewayContentSnapshot = Schema.Struct({
417+ type: Schema.Literal("content.snapshot"),
418+ protocolVersion: HermesGatewayProtocolVersion,
419+ ...HermesGatewayTurnContext.fields,
420+ itemId: Schema.optional(HermesGatewayItemId),
421+ streamKind: HermesGatewayContentStreamKind,
422+ text: Schema.String,
423+ contentIndex: Schema.optional(NonNegativeInt),
424+});
425+export type HermesGatewayContentSnapshot = typeof HermesGatewayContentSnapshot.Type;
426+
415427 export const HermesGatewayItemStatus = Schema.Literals([
416428 "inProgress",
417429 "completed",
@@ -569,6 +581,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ · 151 unmodified lines
569581 HermesGatewaySessionReady,
570582 HermesGatewayTurnStarted,
571583 HermesGatewayContentDelta,
584+ HermesGatewayContentSnapshot,
572585 HermesGatewayItemStarted,
573586 HermesGatewayItemUpdated,
574587 HermesGatewayItemCompleted,
packages/contracts/src/orchestration.ts+12−0
@@ -813,6 +813,16 @@ const ThreadMessageAssistantDeltaCommand = Schema.Struct({ · 812 unmodified lines
813813 createdAt: IsoDateTime,
814814 });
815815
816+const ThreadMessageAssistantReplaceCommand = Schema.Struct({
817+ type: Schema.Literal("thread.message.assistant.replace"),
818+ commandId: CommandId,
819+ threadId: ThreadId,
820+ messageId: MessageId,
821+ text: Schema.String,
822+ turnId: Schema.optional(TurnId),
823+ createdAt: IsoDateTime,
824+});
825+
816826 const ThreadMessageAssistantCompleteCommand = Schema.Struct({
817827 type: Schema.Literal("thread.message.assistant.complete"),
818828 commandId: CommandId,
@@ -863,6 +873,7 @@ const ThreadRevertCompleteCommand = Schema.Struct({ · 44 unmodified lines
863873 const InternalOrchestrationCommand = Schema.Union([
864874 ThreadSessionSetCommand,
865875 ThreadMessageAssistantDeltaCommand,
876+ ThreadMessageAssistantReplaceCommand,
866877 ThreadMessageAssistantCompleteCommand,
867878 ThreadProposedPlanUpsertCommand,
868879 ThreadTurnDiffCompleteCommand,
@@ -1028,6 +1039,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 159 unmodified lines
10281039 attachments: Schema.optional(Schema.Array(ChatAttachment)),
10291040 turnId: Schema.NullOr(TurnId),
10301041 streaming: Schema.Boolean,
1042+ textOperation: Schema.optional(Schema.Literal("replace")),
10311043 createdAt: IsoDateTime,
10321044 updatedAt: IsoDateTime,
10331045 });
packages/contracts/src/providerRuntime.test.ts+28−0
@@ -69,6 +69,34 @@ describe("ProviderRuntimeEvent", () => { · 68 unmodified lines
6969 expect(parsed.payload.planMarkdown).toBe("# Ship it");
7070 });
7171
72+ it("decodes authoritative content snapshots, including empty text", () => {
73+ const parsed = decodeRuntimeEvent({
74+ type: "content.snapshot",
75+ eventId: "event-content-snapshot-1",
76+ provider: "hermes",
77+ providerInstanceId: "hermes-research",
78+ sessionId: "runtime-session-1",
79+ createdAt: "2026-02-28T00:00:00.000Z",
80+ threadId: "thread-1",
81+ turnId: "turn-1",
82+ itemId: "message-1",
83+ payload: {
84+ streamKind: "assistant_text",
85+ text: "",
86+ contentIndex: 0,
87+ summaryIndex: 1,
88+ },
89+ });
90+
91+ expect(parsed.type).toBe("content.snapshot");
92+ if (parsed.type !== "content.snapshot") {
93+ throw new Error("expected content.snapshot");
94+ }
95+ expect(parsed.payload.text).toBe("");
96+ expect(parsed.payload.contentIndex).toBe(0);
97+ expect(parsed.payload.summaryIndex).toBe(1);
98+ });
99+
72100 it("decodes user-input.requested with structured questions", () => {
73101 const parsed = decodeRuntimeEvent({
74102 type: "user-input.requested",
packages/contracts/src/providerRuntime.ts+18−0
@@ -170,6 +170,7 @@ const ProviderRuntimeEventType = Schema.Literals([ · 169 unmodified lines
170170 "item.updated",
171171 "item.completed",
172172 "content.delta",
173+ "content.snapshot",
173174 "request.opened",
174175 "request.resolved",
175176 "user-input.requested",
@@ -220,6 +221,7 @@ const ItemStartedType = Schema.Literal("item.started"); · 44 unmodified lines
220221 const ItemUpdatedType = Schema.Literal("item.updated");
221222 const ItemCompletedType = Schema.Literal("item.completed");
222223 const ContentDeltaType = Schema.Literal("content.delta");
224+const ContentSnapshotType = Schema.Literal("content.snapshot");
223225 const RequestOpenedType = Schema.Literal("request.opened");
224226 const RequestResolvedType = Schema.Literal("request.resolved");
225227 const UserInputRequestedType = Schema.Literal("user-input.requested");
@@ -418,6 +420,14 @@ const ContentDeltaPayload = Schema.Struct({ · 192 unmodified lines
418420 });
419421 export type ContentDeltaPayload = typeof ContentDeltaPayload.Type;
420422
423+export const ContentSnapshotPayload = Schema.Struct({
424+ streamKind: RuntimeContentStreamKind,
425+ text: Schema.String,
426+ contentIndex: Schema.optional(Schema.Int),
427+ summaryIndex: Schema.optional(Schema.Int),
428+});
429+export type ContentSnapshotPayload = typeof ContentSnapshotPayload.Type;
430+
421431 const RequestOpenedPayload = Schema.Struct({
422432 requestType: CanonicalRequestType,
423433 detail: Schema.optional(TrimmedNonEmptyStringSchema),
@@ -791,6 +801,13 @@ const ProviderRuntimeContentDeltaEvent = Schema.Struct({ · 367 unmodified lines
791801 });
792802 export type ProviderRuntimeContentDeltaEvent = typeof ProviderRuntimeContentDeltaEvent.Type;
793803
804+const ProviderRuntimeContentSnapshotEvent = Schema.Struct({
805+ ...ProviderRuntimeEventBase.fields,
806+ type: ContentSnapshotType,
807+ payload: ContentSnapshotPayload,
808+});
809+export type ProviderRuntimeContentSnapshotEvent = typeof ProviderRuntimeContentSnapshotEvent.Type;
810+
794811 const ProviderRuntimeRequestOpenedEvent = Schema.Struct({
795812 ...ProviderRuntimeEventBase.fields,
796813 type: RequestOpenedType,
@@ -989,6 +1006,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ · 192 unmodified lines
9891006 ProviderRuntimeItemUpdatedEvent,
9901007 ProviderRuntimeItemCompletedEvent,
9911008 ProviderRuntimeContentDeltaEvent,
1009+ ProviderRuntimeContentSnapshotEvent,
9921010 ProviderRuntimeRequestOpenedEvent,
9931011 ProviderRuntimeRequestResolvedEvent,
9941012 ProviderRuntimeUserInputRequestedEvent,