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
1652 }),1652 }),
1653 );1653 );
1654 1654
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
1655 it.effect(1766 it.effect(
1656 "resolves turn-count conflicts when checkpoint completion rewrites provisional turns",1767 "resolves turn-count conflicts when checkpoint completion rewrites provisional turns",
1657 () =>1768 () =>
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3−0
@@ -887,6 +887,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 886 unmodified lines
887 const nextText = Option.match(existingMessage, {887 const nextText = Option.match(existingMessage, {
888 onNone: () => event.payload.text,888 onNone: () => event.payload.text,
889 onSome: (message) => {889 onSome: (message) => {
890 if (event.payload.textOperation === "replace") {
891 return event.payload.text;
892 }
890 if (event.payload.streaming) {893 if (event.payload.streaming) {
891 return `${message.text}${event.payload.text}`;894 return `${message.text}${event.payload.text}`;
892 }895 }
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+43−0
@@ -477,6 +477,49 @@ describe("ProviderCommandReactor", () => { · 476 unmodified lines
477 expect(thread?.session?.runtimeMode).toBe("approval-required");477 expect(thread?.session?.runtimeMode).toBe("approval-required");
478 });478 });
479 479
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
480 effectIt.effect("projects starting before a slow provider session finishes", () =>523 effectIt.effect("projects starting before a slow provider session finishes", () =>
481 Effect.gen(function* () {524 Effect.gen(function* () {
482 const releaseStart = yield* Deferred.make<void>();525 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
527 providerName: session.provider,527 providerName: session.provider,
528 providerInstanceId: session.providerInstanceId,528 providerInstanceId: session.providerInstanceId,
529 runtimeMode: desiredRuntimeMode,529 runtimeMode: desiredRuntimeMode,
530 // Provider turn ids are not orchestration turn ids.530 activeTurnId: session.activeTurnId ?? null,
531 activeTurnId: null,
532 lastError: session.lastError ?? null,531 lastError: session.lastError ?? null,
533 updatedAt: session.updatedAt,532 updatedAt: session.updatedAt,
534 },533 },
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+437−1
@@ -27,6 +27,7 @@ import * as Effect from "effect/Effect"; · 26 unmodified lines
27import * as Exit from "effect/Exit";27import * as Exit from "effect/Exit";
28import * as Layer from "effect/Layer";28import * as Layer from "effect/Layer";
29import * as ManagedRuntime from "effect/ManagedRuntime";29import * as ManagedRuntime from "effect/ManagedRuntime";
30import * as Option from "effect/Option";
30import * as PubSub from "effect/PubSub";31import * as PubSub from "effect/PubSub";
31import * as Scope from "effect/Scope";32import * as Scope from "effect/Scope";
32import * as Stream from "effect/Stream";33import * as Stream from "effect/Stream";
@@ -35,7 +36,9 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; · 2 unmodified lines
35 36
36import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";37import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
37import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";38import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
39import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
38import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";40import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
41import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
39import {42import {
40 ProviderService,43 ProviderService,
41 type ProviderServiceShape,44 type ProviderServiceShape,
@@ -192,7 +195,10 @@ async function waitForThread( · 150 unmodified lines
192 195
193describe("ProviderRuntimeIngestion", () => {196describe("ProviderRuntimeIngestion", () => {
194 let runtime: ManagedRuntime.ManagedRuntime<197 let runtime: ManagedRuntime.ManagedRuntime<
195 OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery,198 | OrchestrationEngineService
199 | ProviderRuntimeIngestionService
200 | ProjectionSnapshotQuery
201 | ProjectionTurnRepository,
196 unknown202 unknown
197 > | null = null;203 > | null = null;
198 let scope: Scope.Closeable | null = null;204 let scope: Scope.Closeable | null = null;
@@ -234,9 +240,13 @@ describe("ProviderRuntimeIngestion", () => { · 35 unmodified lines
234 Layer.provide(RepositoryIdentityResolver.layer),240 Layer.provide(RepositoryIdentityResolver.layer),
235 Layer.provide(SqlitePersistenceMemory),241 Layer.provide(SqlitePersistenceMemory),
236 );242 );
243 const projectionTurnLayer = ProjectionTurnRepositoryLive.pipe(
244 Layer.provide(SqlitePersistenceMemory),
245 );
237 const layer = ProviderRuntimeIngestionLive.pipe(246 const layer = ProviderRuntimeIngestionLive.pipe(
238 Layer.provideMerge(orchestrationLayer),247 Layer.provideMerge(orchestrationLayer),
239 Layer.provideMerge(projectionSnapshotLayer),248 Layer.provideMerge(projectionSnapshotLayer),
249 Layer.provideMerge(projectionTurnLayer),
240 Layer.provideMerge(SqlitePersistenceMemory),250 Layer.provideMerge(SqlitePersistenceMemory),
241 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),251 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
242 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),252 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
@@ -247,6 +257,7 @@ describe("ProviderRuntimeIngestion", () => { · 4 unmodified lines
247 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));257 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
248 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));258 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
249 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));259 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));
260 const turnRepository = await runtime.runPromise(Effect.service(ProjectionTurnRepository));
250 scope = await Effect.runPromise(Scope.make("sequential"));261 scope = await Effect.runPromise(Scope.make("sequential"));
251 await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope)));262 await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope)));
252 const drain = () => Effect.runPromise(ingestion.drain);263 const drain = () => Effect.runPromise(ingestion.drain);
@@ -315,6 +326,7 @@ describe("ProviderRuntimeIngestion", () => { · 62 unmodified lines
315 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),326 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
316 emit: provider.emit,327 emit: provider.emit,
317 setProviderSession: provider.setSession,328 setProviderSession: provider.setSession,
329 turnRepository,
318 drain,330 drain,
319 };331 };
320 }332 }
@@ -946,6 +958,209 @@ describe("ProviderRuntimeIngestion", () => { · 625 unmodified lines
946 expect(message?.streaming).toBe(false);958 expect(message?.streaming).toBe(false);
947 });959 });
948 960
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
949 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {1164 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
950 const harness = await createHarness();1165 const harness = await createHarness();
951 const now = "2026-01-01T00:00:00.000Z";1166 const now = "2026-01-01T00:00:00.000Z";
@@ -1573,6 +1788,85 @@ describe("ProviderRuntimeIngestion", () => { · 621 unmodified lines
1573 expect(threadAfterSteer.latestTurn?.state).toBe("running");1788 expect(threadAfterSteer.latestTurn?.state).toBe("running");
1574 });1789 });
1575 1790
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
1576 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {1870 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {
1577 const harness = await createHarness();1871 const harness = await createHarness();
1578 const sourceThreadId = asThreadId("thread-plan");1872 const sourceThreadId = asThreadId("thread-plan");
@@ -2543,6 +2837,65 @@ describe("ProviderRuntimeIngestion", () => { · 964 unmodified lines
2543 expect(message?.streaming).toBe(false);2837 expect(message?.streaming).toBe(false);
2544 });2838 });
2545 2839
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
2546 it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => {2899 it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => {
2547 const harness = await createHarness();2900 const harness = await createHarness();
2548 const now = "2026-01-01T00:00:00.000Z";2901 const now = "2026-01-01T00:00:00.000Z";
@@ -2849,6 +3202,89 @@ describe("ProviderRuntimeIngestion", () => { · 300 unmodified lines
2849 ).toBe(true);3202 ).toBe(true);
2850 });3203 });
2851 3204
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
2852 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {3288 it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => {
2853 const harness = await createHarness();3289 const harness = await createHarness();
2854 const now = "2026-01-01T00:00:00.000Z";3290 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
2 ApprovalRequestId,2 ApprovalRequestId,
3 type AssistantDeliveryMode,3 type AssistantDeliveryMode,
4 CommandId,4 CommandId,
5 EventId,
5 MessageId,6 MessageId,
6 type OrchestrationEvent,7 type OrchestrationEvent,
7 type OrchestrationMessage,8 type OrchestrationMessage,
@@ -306,6 +307,44 @@ function requestKindFromCanonicalRequestType( · 298 unmodified lines
306 }307 }
307}308}
308 309
310function 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
318function 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
309export function runtimeEventToActivities(348export function runtimeEventToActivities(
310 event: ProviderRuntimeEvent,349 event: ProviderRuntimeEvent,
311 taskTitle?: string,350 taskTitle?: string,
@@ -614,6 +653,9 @@ export function runtimeEventToActivities( · 302 unmodified lines
614 }653 }
615 654
616 case "item.updated": {655 case "item.updated": {
656 if (event.payload.itemType === "unknown") {
657 return [genericProviderActivity(event, maybeSequence)];
658 }
617 if (!isToolLifecycleItemType(event.payload.itemType)) {659 if (!isToolLifecycleItemType(event.payload.itemType)) {
618 return [];660 return [];
619 }661 }
@@ -637,6 +679,9 @@ export function runtimeEventToActivities( · 17 unmodified lines
637 }679 }
638 680
639 case "item.completed": {681 case "item.completed": {
682 if (event.payload.itemType === "unknown") {
683 return [genericProviderActivity(event, maybeSequence)];
684 }
640 if (!isToolLifecycleItemType(event.payload.itemType)) {685 if (!isToolLifecycleItemType(event.payload.itemType)) {
641 return [];686 return [];
642 }687 }
@@ -659,6 +704,9 @@ export function runtimeEventToActivities( · 16 unmodified lines
659 }704 }
660 705
661 case "item.started": {706 case "item.started": {
707 if (event.payload.itemType === "unknown") {
708 return [genericProviderActivity(event, maybeSequence)];
709 }
662 if (!isToolLifecycleItemType(event.payload.itemType)) {710 if (!isToolLifecycleItemType(event.payload.itemType)) {
663 return [];711 return [];
664 }712 }
@@ -899,6 +947,9 @@ const make = Effect.gen(function* () { · 234 unmodified lines
899 ),947 ),
900 );948 );
901 949
950 const replaceBufferedAssistantText = (messageId: MessageId, text: string) =>
951 Cache.set(bufferedAssistantTextByMessageId, messageId, text);
952
902 const takeBufferedAssistantText = (messageId: MessageId) =>953 const takeBufferedAssistantText = (messageId: MessageId) =>
903 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(954 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
904 Effect.flatMap((existingText) =>955 Effect.flatMap((existingText) =>
@@ -1463,6 +1514,10 @@ const make = Effect.gen(function* () { · 558 unmodified lines
1463 event.type === "content.delta" && event.payload.streamKind === "assistant_text"1514 event.type === "content.delta" && event.payload.streamKind === "assistant_text"
1464 ? event.payload.delta1515 ? event.payload.delta
1465 : undefined;1516 : undefined;
1517 const assistantSnapshot =
1518 event.type === "content.snapshot" && event.payload.streamKind === "assistant_text"
1519 ? event.payload.text
1520 : undefined;
1466 const proposedPlanDelta =1521 const proposedPlanDelta =
1467 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;1522 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;
1468 1523
@@ -1507,6 +1562,53 @@ const make = Effect.gen(function* () { · 38 unmodified lines
1507 }1562 }
1508 }1563 }
1509 1564
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
1510 const pauseForUserTurnId =1612 const pauseForUserTurnId =
1511 event.type === "request.opened" || event.type === "user-input.requested"1613 event.type === "request.opened" || event.type === "user-input.requested"
1512 ? toTurnId(event.turnId)1614 ? toTurnId(event.turnId)
apps/server/src/orchestration/decider.assistantMessageReplacement.test.ts+73−0
@@ -0,0 +1,73 @@
1import {
2 CommandId,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
4 EventId,
5 MessageId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 TurnId,
10} from "@t3tools/contracts";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import * as Effect from "effect/Effect";
13import { expect, it } from "@effect/vitest";
14
15import { decideOrchestrationCommand } from "./decider.ts";
16import { createEmptyReadModel, projectEvent } from "./projector.ts";
17
18it.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
1018 };1018 };
1019 }1019 }
1020 1020
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
1021 case "thread.message.assistant.complete": {1049 case "thread.message.assistant.complete": {
1022 yield* requireThread({1050 yield* requireThread({
1023 readModel,1051 readModel,
apps/server/src/orchestration/projector.test.ts+77−0
@@ -488,6 +488,83 @@ describe("orchestration projector", () => { · 487 unmodified lines
488 expect(message?.updatedAt).toBe(completeAt);488 expect(message?.updatedAt).toBe(completeAt);
489 });489 });
490 490
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
491 it("prunes reverted turn messages from in-memory thread snapshot", async () => {568 it("prunes reverted turn messages from in-memory thread snapshot", async () => {
492 const createdAt = "2026-02-23T10:00:00.000Z";569 const createdAt = "2026-02-23T10:00:00.000Z";
493 const model = createEmptyReadModel(createdAt);570 const model = createEmptyReadModel(createdAt);
apps/server/src/orchestration/projector.ts+7−4
@@ -471,11 +471,14 @@ export function projectEvent( · 470 unmodified lines
471 entry.id === message.id471 entry.id === message.id
472 ? {472 ? {
473 ...entry,473 ...entry,
474 text: message.streaming474 text:
475 ? `${entry.text}${message.text}`475 payload.textOperation === "replace"
476 : message.text.length > 0
477 ? message.text476 ? message.text
478 : entry.text,477 : message.streaming
478 ? `${entry.text}${message.text}`
479 : message.text.length > 0
480 ? message.text
481 : entry.text,
479 streaming: message.streaming,482 streaming: message.streaming,
480 updatedAt: message.updatedAt,483 updatedAt: message.updatedAt,
481 turnId: message.turnId,484 turnId: message.turnId,
apps/server/src/provider/Layers/HermesAdapter.test.ts+211−9
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 HermesGatewaySessionId,6 HermesGatewaySessionId,
7 ProviderInstanceId,7 ProviderInstanceId,
8 ThreadId,8 ThreadId,
9 TurnId,
9 type HermesGatewayT3ToPluginMessage,10 type HermesGatewayT3ToPluginMessage,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
@@ -23,10 +24,9 @@ import { · 11 unmodified lines
23 makeHermesAdapter,24 makeHermesAdapter,
24 sanitizeHermesItemData,25 sanitizeHermesItemData,
25 sanitizeHermesRequestArgs,26 sanitizeHermesRequestArgs,
26 shouldProjectHermesTurnStarted,
27} from "./HermesAdapter.ts";27} from "./HermesAdapter.ts";
28 28
29it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => {29it("sanitizes persisted gateway payloads", () => {
30 assert.deepEqual(30 assert.deepEqual(
31 sanitizeHermesItemData("command_execution", {31 sanitizeHermesItemData("command_execution", {
32 command: "git status",32 command: "git status",
@@ -43,12 +43,6 @@ it("sanitizes persisted gateway payloads and suppresses steering acknowledgement · 10 unmodified lines
43 }),43 }),
44 { command: "git push" },44 { command: "git push" },
45 );45 );
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));
52});46});
53 47
54it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () =>48it.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
101 Effect.provideService(HermesGatewayBroker, broker),95 Effect.provideService(HermesGatewayBroker, broker),
102 );96 );
103 yield* Effect.yieldNow;97 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 });
104 const session = yield* adapter.startSession({111 const session = yield* adapter.startSession({
105 threadId,112 threadId,
106 providerInstanceId: instanceId,113 providerInstanceId: instanceId,
@@ -135,13 +142,17 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 28 unmodified lines
135 });142 });
136 const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber));143 const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber));
137 assert.equal(startEvent?.type, "turn.started");144 assert.equal(startEvent?.type, "turn.started");
145 const firstEventAfterOrphanExit = Option.getOrUndefined(
146 yield* Fiber.join(firstEventAfterOrphanExitFiber),
147 );
148 assert.equal(firstEventAfterOrphanExit?.type, "session.started");
138 149
139 yield* adapter.sendTurn({ threadId, input: "follow up" });150 yield* adapter.sendTurn({ threadId, input: "follow up" });
140 const turnSteer = sent.find((message) => message.type === "turn.steer");151 const turnSteer = sent.find((message) => message.type === "turn.steer");
141 if (!turnSteer || turnSteer.type !== "turn.steer") {152 if (!turnSteer || turnSteer.type !== "turn.steer") {
142 return yield* Effect.die(new Error("turn.steer was not sent"));153 return yield* Effect.die(new Error("turn.steer was not sent"));
143 }154 }
144 const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(155 const steerEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
145 Effect.forkChild({ startImmediately: true }),156 Effect.forkChild({ startImmediately: true }),
146 );157 );
147 yield* PubSub.publish(brokerEvents, {158 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
155 turnId: turnSteer.turnId,166 turnId: turnSteer.turnId,
156 },167 },
157 });168 });
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 );
158 yield* PubSub.publish(brokerEvents, {175 yield* PubSub.publish(brokerEvents, {
159 instanceId,176 instanceId,
160 message: {177 message: {
@@ -169,5 +186,190 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 8 unmodified lines
169 });186 });
170 const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber));187 const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber));
171 assert.equal(nextEvent?.type, "content.delta");188 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
280it.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 }
172 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),374 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
173);375);
apps/server/src/provider/Layers/HermesAdapter.ts+34−31
@@ -107,11 +107,6 @@ export const sanitizeHermesRequestArgs = ( · 106 unmodified lines
107 return Object.keys(sanitized).length > 0 ? sanitized : undefined;107 return Object.keys(sanitized).length > 0 ? sanitized : undefined;
108};108};
109 109
110export const shouldProjectHermesTurnStarted = (
111 pendingSteerRequestIds: Set<HermesGatewayRequestId>,
112 incomingRequestId: HermesGatewayRequestId,
113) => !pendingSteerRequestIds.delete(incomingRequestId);
114
115export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: {110export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: {
116 readonly instanceId: ProviderInstanceId;111 readonly instanceId: ProviderInstanceId;
117}) {112}) {
@@ -119,7 +114,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 1 unmodified line
119 const broker = yield* HermesGatewayBroker;114 const broker = yield* HermesGatewayBroker;
120 const events = yield* PubSub.unbounded<ProviderRuntimeEvent>();115 const events = yield* PubSub.unbounded<ProviderRuntimeEvent>();
121 const sessions = new Map<ThreadId, SessionContext>();116 const sessions = new Map<ThreadId, SessionContext>();
122 const pendingSteerRequestIds = new Set<HermesGatewayRequestId>();
123 117
124 const randomId = crypto.randomUUIDv4.pipe(118 const randomId = crypto.randomUUIDv4.pipe(
125 Effect.mapError(119 Effect.mapError(
@@ -188,10 +182,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 62 unmodified lines
188 case "session.ready":182 case "session.ready":
189 return undefined;183 return undefined;
190 case "turn.started": {184 case "turn.started": {
191 const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted(
192 pendingSteerRequestIds,
193 message.requestId,
194 );
195 if (context) {185 if (context) {
196 updateSession(context, {186 updateSession(context, {
197 status: "running",187 status: "running",
@@ -201,7 +191,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 3 unmodified lines
201 context.turns.push({ id: TurnId.make(message.turnId), items: [] });191 context.turns.push({ id: TurnId.make(message.turnId), items: [] });
202 }192 }
203 }193 }
204 if (isSteeringAcknowledgement) return undefined;
205 return {194 return {
206 ...base,195 ...base,
207 type: "turn.started",196 type: "turn.started",
@@ -218,6 +207,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 10 unmodified lines
218 ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}),207 ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}),
219 },208 },
220 };209 };
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 };
221 case "item.started":220 case "item.started":
222 case "item.updated":221 case "item.updated":
223 case "item.completed": {222 case "item.completed": {
@@ -267,7 +266,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 43 unmodified lines
267 payload: { answers: message.answers },266 payload: { answers: message.answers },
268 };267 };
269 case "turn.completed":268 case "turn.completed":
270 if (context) {269 if (context?.session.activeTurnId === message.turnId) {
271 updateSession(context, { status: message.state === "failed" ? "error" : "ready" });270 updateSession(context, { status: message.state === "failed" ? "error" : "ready" });
272 const { activeTurnId: _activeTurnId, ...session } = context.session;271 const { activeTurnId: _activeTurnId, ...session } = context.session;
273 context.session = session;272 context.session = session;
@@ -282,7 +281,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 8 unmodified lines
282 },281 },
283 };282 };
284 case "turn.aborted":283 case "turn.aborted":
285 if (context) {284 if (context?.session.activeTurnId === message.turnId) {
286 const { activeTurnId: _activeTurnId, ...session } = context.session;285 const { activeTurnId: _activeTurnId, ...session } = context.session;
287 context.session = { ...session, status: "ready", updatedAt: nowIso() };286 context.session = { ...session, status: "ready", updatedAt: nowIso() };
288 }287 }
@@ -292,7 +291,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 3 unmodified lines
292 payload: { reason: message.reason },291 payload: { reason: message.reason },
293 };292 };
294 case "session.exited":293 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 };
296 return {303 return {
297 ...base,304 ...base,
298 type: "session.exited",305 type: "session.exited",
@@ -365,13 +372,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 66 unmodified lines
365 });372 });
366 }373 }
367 const createdAt = nowIso();374 const createdAt = nowIso();
375 const activeTurnId = response.activeTurnId;
368 const session = {376 const session = {
369 provider: PROVIDER,377 provider: PROVIDER,
370 providerInstanceId: input.instanceId,378 providerInstanceId: input.instanceId,
371 status: "ready",379 status: activeTurnId === undefined ? "ready" : "running",
372 runtimeMode: sessionInput.runtimeMode,380 runtimeMode: sessionInput.runtimeMode,
373 ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}),381 ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}),
374 ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}),382 ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}),
383 ...(activeTurnId !== undefined ? { activeTurnId } : {}),
375 threadId: sessionInput.threadId,384 threadId: sessionInput.threadId,
376 resumeCursor: {385 resumeCursor: {
377 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,386 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
@@ -383,7 +392,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 5 unmodified lines
383 sessions.set(sessionInput.threadId, {392 sessions.set(sessionInput.threadId, {
384 session,393 session,
385 hermesSessionId: response.sessionId,394 hermesSessionId: response.sessionId,
386 turns: [],395 turns: activeTurnId === undefined ? [] : [{ id: activeTurnId, items: [] }],
387 });396 });
388 yield* emit({397 yield* emit({
389 ...(yield* eventBase({ threadId: sessionInput.threadId })),398 ...(yield* eventBase({ threadId: sessionInput.threadId })),
@@ -422,22 +431,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 32 unmodified lines
422 const activeTurnId = context.session.activeTurnId;431 const activeTurnId = context.session.activeTurnId;
423 const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`);432 const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`);
424 const outboundRequestId = yield* requestId;433 const outboundRequestId = yield* requestId;
425 if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId);434 const response = yield* broker.request(input.instanceId, {
426 const response = yield* broker435 type: activeTurnId ? "turn.steer" : "turn.start",
427 .request(input.instanceId, {436 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
428 type: activeTurnId ? "turn.steer" : "turn.start",437 requestId: outboundRequestId,
429 protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,438 threadId: turnInput.threadId,
430 requestId: outboundRequestId,439 sessionId: context.hermesSessionId,
431 threadId: turnInput.threadId,440 turnId,
432 sessionId: context.hermesSessionId,441 text,
433 turnId,442 });
434 text,
435 })
436 .pipe(
437 Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))),
438 );
439 if (response.type !== "turn.started") {443 if (response.type !== "turn.started") {
440 pendingSteerRequestIds.delete(outboundRequestId);
441 return yield* new ProviderAdapterRequestError({444 return yield* new ProviderAdapterRequestError({
442 provider: PROVIDER,445 provider: PROVIDER,
443 method: activeTurnId ? "turn.steer" : "turn.start",446 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
87 type: "connection.hello",87 type: "connection.hello",
88 requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),88 requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),
89 protocolVersion,89 protocolVersion,
90 pluginVersion: "0.1.0",90 pluginVersion: "0.2.0",
91 hermesVersion: "1.0.0",91 hermesVersion: "1.0.0",
92 capabilities: { ...capabilities, protocolVersion },92 capabilities: { ...capabilities, protocolVersion },
93 authentication,93 authentication,
@@ -157,7 +157,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 63 unmodified lines
157 instanceId,157 instanceId,
158 credential: HermesGatewayCredential.make("not-the-real-credential"),158 credential: HermesGatewayCredential.make("not-the-real-credential"),
159 },159 },
160 2,160 3,
161 ),161 ),
162 secondTransport,162 secondTransport,
163 ),163 ),
@@ -185,7 +185,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 21 unmodified lines
185 instanceId,185 instanceId,
186 credential: first.accepted.credential!,186 credential: first.accepted.credential!,
187 },187 },
188 2,188 3,
189 ),189 ),
190 secondTransport,190 secondTransport,
191 ),191 ),
@@ -201,7 +201,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 9 unmodified lines
201 });201 });
202 const incompatibleEnrollment = yield* Effect.flip(202 const incompatibleEnrollment = yield* Effect.flip(
203 broker.registerConnection(203 broker.registerConnection(
204 hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2),204 hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 3),
205 secondTransport,205 secondTransport,
206 ),206 ),
207 );207 );
apps/server/src/provider/Layers/ProviderService.test.ts+95−0
@@ -1280,6 +1280,101 @@ routing.layer("ProviderServiceLive routing", (it) => { · 1279 unmodified lines
1280 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");1280 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
1281 }1281 }
1282 }1282 }
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 }
1283 }),1378 }),
1284 );1379 );
1285 1380
apps/server/src/provider/Layers/ProviderService.ts+93−1
@@ -162,6 +162,19 @@ function readPersistedCwd( · 161 unmodified lines
162 return trimmed.length > 0 ? trimmed : undefined;162 return trimmed.length > 0 ? trimmed : undefined;
163}163}
164 164
165function 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
165const dieOnMissingBindingInstanceId = (178const dieOnMissingBindingInstanceId = (
166 operation: string,179 operation: string,
167 payload: {180 payload: {
@@ -281,6 +294,70 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 113 unmodified lines
281 });294 });
282 });295 });
283 296
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
284 const processRuntimeEvent = (361 const processRuntimeEvent = (
285 source: {362 source: {
286 readonly instanceId: ProviderInstanceId;363 readonly instanceId: ProviderInstanceId;
@@ -293,7 +370,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 6 unmodified lines
293 increment(providerRuntimeEventsTotal, {370 increment(providerRuntimeEventsTotal, {
294 provider: canonicalEvent.provider,371 provider: canonicalEvent.provider,
295 eventType: canonicalEvent.type,372 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 ),
297 ),389 ),
298 );390 );
299 391
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
4surfaces audited at Hermes Agent commit4surfaces audited at Hermes Agent commit
5`3910ab28c0892fcf846fc61318d2fd15689eddf1`.5`3910ab28c0892fcf846fc61318d2fd15689eddf1`.
6 6
7## Mapped in V17This inventory describes gateway wire protocol v2. Protocol v2 adds active-turn
8recovery in `session.ready` and authoritative `content.snapshot` replacement;
9older server/plugin pairs are rejected during the handshake.
10
11## Mapped in the initial scope
8 12
9| Hermes surface | T3 gateway event |13| Hermes surface | T3 gateway event |
10| ----------------------------------------- | ---------------------------------------------- |14| ----------------------------------------- | ---------------------------------------------- |
11| Cumulative `send` / `edit_message` output | `content.delta` |15| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` |
12| Final stream edit | `item.completed`, `turn.completed` |16| Final stream edit | `item.completed`, `turn.completed` |
13| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` |17| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` |
14| Live adapter status text | Generic `unknown` activity item |18| Live adapter status text | Generic `unknown` activity item |
@@ -20,14 +24,16 @@ surfaces audited at Hermes Agent commit · 5 unmodified lines
20## Known limitations24## Known limitations
21 25
22- The platform adapter receives cumulative rendered text, not the underlying26- 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,
24 plan, and command-output stream categories are not publicly exposed here.28 plan, and command-output stream categories are not publicly exposed here.
25- The delta contract has no replacement operation. Rare edits that revise29- Prefix-extending cumulative edits emit `content.delta`; edits that revise or
26 already-emitted text become a meaningful generic activity instead of30 clear already-emitted text emit an authoritative `content.snapshot`.
27 corrupting the T3 transcript.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.
28- Hermes' documented tool hook surface exposes a `task_id`, tool name,34- Hermes' documented tool hook surface exposes a `task_id`, tool name,
29 arguments, string result, and duration. The audited runtime additionally35 arguments, string result, and duration. The audited runtime additionally
30 supplies `session_id` and `tool_call_id`; V1 uses them when present and falls36 supplies `session_id` and `tool_call_id`; the adapter uses them when present and falls
31 back to the documented IDs for older versions. It projects only canonical,37 back to the documented IDs for older versions. It projects only canonical,
32 whitelisted fields (command/cwd, file path, search query, image path, or MCP38 whitelisted fields (command/cwd, file path, search query, image path, or MCP
33 server/operation); arbitrary arguments and raw results never cross the wire.39 server/operation); arbitrary arguments and raw results never cross the wire.
@@ -45,4 +51,4 @@ surfaces audited at Hermes Agent commit · 11 unmodified lines
45 Hermes success response begins with `⏩ Steer queued`. Unknown future response51 Hermes success response begins with `⏩ Steer queued`. Unknown future response
46 shapes fail closed with `protocol.error` rather than completing the turn.52 shapes fail closed with `protocol.error` rather than completing the turn.
47- Attachments are not accepted. They are the first planned post-stability53- 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
4process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes4process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes
5does not need to listen on a public port.5does not need to listen on a public port.
6 6
7The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated
8together; mismatched versions fail the connection handshake closed.
9
7Each T3 thread maps deterministically to one Hermes gateway session. A new T310Each T3 thread maps deterministically to one Hermes gateway session. A new T3
8thread creates a new session identity, later messages use the same identity,11thread creates a new session identity, later messages use the same identity,
9active-turn messages use Hermes' native `/steer` path, and stopping a turn does12active-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
43enrollment. `hermes t3 status` reports the local enrollment without revealing46enrollment. `hermes t3 status` reports the local enrollment without revealing
44the credential.47the credential.
45 48
46## V1 scope49## Initial scope
47 50
48- Text input and live assistant streaming51- Text input and live assistant streaming
52- Authoritative text replacement when Hermes revises cumulative streamed output
49- Multiple concurrent Hermes sessions in one process53- Multiple concurrent Hermes sessions in one process
50- Active-turn steering and interrupt54- Active-turn steering and interrupt
51- Dangerous-command approvals55- Dangerous-command approvals
@@ -54,6 +58,10 @@ the credential. · 2 unmodified lines
54- Reconnect with bounded backoff58- Reconnect with bounded backoff
55- Version-incompatible and revoked credentials fail closed59- Version-incompatible and revoked credentials fail closed
56 60
61T3 threads are session-only in the initial scope. The adapter suppresses Hermes' exact
62first-chat `/sethome` notice without assigning a home channel or redirecting
63cron and cross-platform delivery.
64
57Attachments intentionally advertise `false`. Adding bounded image/file input is65Attachments intentionally advertise `false`. Adding bounded image/file input is
58the first post-stability feature and should not reuse arbitrary raw payloads.66the first post-stability feature and should not reuse arbitrary raw payloads.
59 67
integrations/hermes-t3-gateway/adapter.py+41−11
@@ -35,6 +35,13 @@ from .protocol import ( · 34 unmodified lines
35 35
36logger = logging.getLogger(__name__)36logger = logging.getLogger(__name__)
37 37
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
38 45
39def _hermes_version() -> str:46def _hermes_version() -> str:
40 try:47 try:
@@ -163,6 +170,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 122 unmodified lines
163 if turn is None:170 if turn is None:
164 return SendResult(success=False, error="no active T3 turn")171 return SendResult(success=False, error="no active T3 turn")
165 try:172 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)
166 await self._emit_assistant_content(turn, content)177 await self._emit_assistant_content(turn, content)
167 if bool((metadata or {}).get("notify")):178 if bool((metadata or {}).get("notify")):
168 await self._complete_turn(turn)179 await self._complete_turn(turn)
@@ -187,6 +198,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 18 unmodified lines
187 if turn is None:198 if turn is None:
188 return SendResult(success=False, error="no active T3 turn")199 return SendResult(success=False, error="no active T3 turn")
189 try:200 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)
190 await self._emit_assistant_content(turn, content)205 await self._emit_assistant_content(turn, content)
191 if finalize:206 if finalize:
192 await self._complete_turn(turn)207 await self._complete_turn(turn)
@@ -343,6 +358,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 150 unmodified lines
343 self._sessions[thread_id] = session_id358 self._sessions[thread_id] = session_id
344 self._active_session_threads.add(thread_id)359 self._active_session_threads.add(thread_id)
345 self._thread_by_session[session_id] = thread_id360 self._thread_by_session[session_id] = thread_id
361 active_turn = self._active_turns.get(thread_id)
346 await self._send_frame(362 await self._send_frame(
347 frame(363 frame(
348 "session.ready",364 "session.ready",
@@ -350,6 +366,11 @@ class T3PlatformAdapter(BasePlatformAdapter): · 1 unmodified line
350 threadId=thread_id,366 threadId=thread_id,
351 sessionId=session_id,367 sessionId=session_id,
352 resumed=bool(resume_id and resume_id == session_id),368 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 ),
353 )374 )
354 )375 )
355 await self._send_status()376 await self._send_status()
@@ -647,7 +668,6 @@ class T3PlatformAdapter(BasePlatformAdapter): · 291 unmodified lines
647 async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None:668 async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None:
648 visible = str(content or "").replace(" ▉", "").replace("▉", "")669 visible = str(content or "").replace(" ▉", "").replace("▉", "")
649 if not turn.assistant_started:670 if not turn.assistant_started:
650 turn.assistant_started = True
651 await self._send_frame(671 await self._send_frame(
652 frame(672 frame(
653 "item.started",673 "item.started",
@@ -660,9 +680,9 @@ class T3PlatformAdapter(BasePlatformAdapter): · 6 unmodified lines
660 title="Hermes response",680 title="Hermes response",
661 )681 )
662 )682 )
683 turn.assistant_started = True
663 if visible.startswith(turn.visible_text):684 if visible.startswith(turn.visible_text):
664 delta = visible[len(turn.visible_text) :]685 delta = visible[len(turn.visible_text) :]
665 turn.visible_text = visible
666 if delta:686 if delta:
667 await self._send_frame(687 await self._send_frame(
668 frame(688 frame(
@@ -676,13 +696,21 @@ class T3PlatformAdapter(BasePlatformAdapter): · 7 unmodified lines
676 contentIndex=0,696 contentIndex=0,
677 )697 )
678 )698 )
699 turn.visible_text = visible
679 elif visible != turn.visible_text:700 elif visible != turn.visible_text:
680 # Hermes' public platform edit hook exposes cumulative rendered text,701 await self._send_frame(
681 # but the v1 T3 delta contract has no replacement operation. Preserve702 frame(
682 # the valid prefix and report the rare rewrite as generic activity.703 "content.snapshot",
683 await self._emit_generic_activity(704 threadId=turn.thread_id,
684 turn, "Hermes revised already-streamed text; replacement is deferred."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 )
685 )712 )
713 turn.visible_text = visible
686 714
687 async def _complete_turn(self, turn: _TurnState) -> None:715 async def _complete_turn(self, turn: _TurnState) -> None:
688 if self._active_turns.get(turn.thread_id) is not turn:716 if self._active_turns.get(turn.thread_id) is not turn:
@@ -737,9 +765,9 @@ class T3PlatformAdapter(BasePlatformAdapter): · 48 unmodified lines
737 normalized_detail = str(detail)[:2_000]765 normalized_detail = str(detail)[:2_000]
738 if turn.generic_activity_detail == normalized_detail:766 if turn.generic_activity_detail == normalized_detail:
739 return767 return
740 turn.generic_activity_detail = normalized_detail768 activity_id = turn.generic_activity_id
741 if turn.generic_activity_id is None:769 if activity_id is None:
742 turn.generic_activity_id = item_id()770 activity_id = item_id()
743 event_type = "item.started"771 event_type = "item.started"
744 else:772 else:
745 event_type = "item.updated"773 event_type = "item.updated"
@@ -749,13 +777,15 @@ class T3PlatformAdapter(BasePlatformAdapter): · 3 unmodified lines
749 threadId=turn.thread_id,777 threadId=turn.thread_id,
750 sessionId=turn.session_id,778 sessionId=turn.session_id,
751 turnId=turn.turn_id,779 turnId=turn.turn_id,
752 itemId=turn.generic_activity_id,780 itemId=activity_id,
753 itemType="unknown",781 itemType="unknown",
754 status="inProgress",782 status="inProgress",
755 title="Hermes activity",783 title="Hermes activity",
756 detail=normalized_detail,784 detail=normalized_detail,
757 )785 )
758 )786 )
787 turn.generic_activity_id = activity_id
788 turn.generic_activity_detail = normalized_detail
759 789
760 def emit_tool_started(790 def emit_tool_started(
761 self,791 self,
integrations/hermes-t3-gateway/connection.py+2−2
@@ -10,7 +10,7 @@ from contextlib import suppress · 9 unmodified lines
10from typing import Any10from typing import Any
11from urllib.parse import urlsplit, urlunsplit11from urllib.parse import urlsplit, urlunsplit
12 12
13from .protocol import WEBSOCKET_PATH, connection_hello13from .protocol import PROTOCOL_VERSION, WEBSOCKET_PATH, connection_hello
14 14
15logger = logging.getLogger(__name__)15logger = logging.getLogger(__name__)
16 16
@@ -93,7 +93,7 @@ async def authenticate_socket( · 76 unmodified lines
93 raise RuntimeError(93 raise RuntimeError(
94 f"expected connection.accepted, received {message.get('type')!r}"94 f"expected connection.accepted, received {message.get('type')!r}"
95 )95 )
96 if message.get("protocolVersion") != 1:96 if message.get("protocolVersion") != PROTOCOL_VERSION:
97 raise RuntimeError("T3 accepted the connection with an incompatible version")97 raise RuntimeError("T3 accepted the connection with an incompatible version")
98 return message98 return message
99 99
integrations/hermes-t3-gateway/plugin.yaml+1−1
@@ -1,7 +1,7 @@
1name: hermes-t3-gateway1name: hermes-t3-gateway
2label: T3 Code2label: T3 Code
3kind: platform3kind: platform
4version: 0.1.04version: 0.2.0
5description: >5description: >
6 Experimental outbound gateway that exposes one Hermes Agent process as a6 Experimental outbound gateway that exposes one Hermes Agent process as a
7 named T3 Code provider instance over an authenticated WebSocket.7 named T3 Code provider instance over an authenticated WebSocket.
integrations/hermes-t3-gateway/protocol.py+2−2
@@ -6,8 +6,8 @@ import uuid · 5 unmodified lines
6from datetime import datetime, timezone6from datetime import datetime, timezone
7from typing import Any7from typing import Any
8 8
9PROTOCOL_VERSION = 19PROTOCOL_VERSION = 2
10PLUGIN_VERSION = "0.1.0"10PLUGIN_VERSION = "0.2.0"
11WEBSOCKET_PATH = "/api/hermes-gateway/ws"11WEBSOCKET_PATH = "/api/hermes-gateway/ws"
12 12
13CAPABILITIES = {13CAPABILITIES = {
integrations/hermes-t3-gateway/tests/test_adapter.py+330−13
@@ -176,11 +176,34 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 175 unmodified lines
176 self.connection = FakeConnection()176 self.connection = FakeConnection()
177 self.adapter._connection = self.connection177 self.adapter._connection = self.connection
178 178
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
179 async def test_thread_ensure_start_stream_and_complete(self):202 async def test_thread_ensure_start_stream_and_complete(self):
180 await self.adapter._handle_server_frame(203 await self.adapter._handle_server_frame(
181 {204 {
182 "type": "session.ensure",205 "type": "session.ensure",
183 "protocolVersion": 1,206 "protocolVersion": 2,
184 "requestId": "ensure-1",207 "requestId": "ensure-1",
185 "threadId": "thread-1",208 "threadId": "thread-1",
186 }209 }
@@ -193,7 +216,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 6 unmodified lines
193 await self.adapter._handle_server_frame(216 await self.adapter._handle_server_frame(
194 {217 {
195 "type": "turn.start",218 "type": "turn.start",
196 "protocolVersion": 1,219 "protocolVersion": 2,
197 "requestId": "start-1",220 "requestId": "start-1",
198 "threadId": "thread-1",221 "threadId": "thread-1",
199 "sessionId": ready["sessionId"],222 "sessionId": ready["sessionId"],
@@ -216,11 +239,305 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 16 unmodified lines
216 ]239 ]
217 self.assertEqual(deltas, ["Hello", " world"])240 self.assertEqual(deltas, ["Hello", " world"])
218 241
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
219 async def test_steer_uses_official_hermes_command(self):536 async def test_steer_uses_official_hermes_command(self):
220 await self.adapter._handle_server_frame(537 await self.adapter._handle_server_frame(
221 {538 {
222 "type": "session.ensure",539 "type": "session.ensure",
223 "protocolVersion": 1,540 "protocolVersion": 2,
224 "requestId": "ensure-2",541 "requestId": "ensure-2",
225 "threadId": "thread-2",542 "threadId": "thread-2",
226 }543 }
@@ -229,7 +546,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
229 await self.adapter._handle_server_frame(546 await self.adapter._handle_server_frame(
230 {547 {
231 "type": "turn.start",548 "type": "turn.start",
232 "protocolVersion": 1,549 "protocolVersion": 2,
233 "requestId": "start-2",550 "requestId": "start-2",
234 "threadId": "thread-2",551 "threadId": "thread-2",
235 "sessionId": session_id,552 "sessionId": session_id,
@@ -248,7 +565,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines
248 await self.adapter._handle_server_frame(565 await self.adapter._handle_server_frame(
249 {566 {
250 "type": "turn.steer",567 "type": "turn.steer",
251 "protocolVersion": 1,568 "protocolVersion": 2,
252 "requestId": "steer-2",569 "requestId": "steer-2",
253 "threadId": "thread-2",570 "threadId": "thread-2",
254 "sessionId": session_id,571 "sessionId": session_id,
@@ -283,7 +600,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 28 unmodified lines
283 await self.adapter._handle_server_frame(600 await self.adapter._handle_server_frame(
284 {601 {
285 "type": "session.ensure",602 "type": "session.ensure",
286 "protocolVersion": 1,603 "protocolVersion": 2,
287 "requestId": "ensure-rejected-steer",604 "requestId": "ensure-rejected-steer",
288 "threadId": "thread-rejected-steer",605 "threadId": "thread-rejected-steer",
289 }606 }
@@ -292,7 +609,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
292 await self.adapter._handle_server_frame(609 await self.adapter._handle_server_frame(
293 {610 {
294 "type": "turn.start",611 "type": "turn.start",
295 "protocolVersion": 1,612 "protocolVersion": 2,
296 "requestId": "start-rejected-steer",613 "requestId": "start-rejected-steer",
297 "threadId": "thread-rejected-steer",614 "threadId": "thread-rejected-steer",
298 "sessionId": session_id,615 "sessionId": session_id,
@@ -309,7 +626,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
309 await self.adapter._handle_server_frame(626 await self.adapter._handle_server_frame(
310 {627 {
311 "type": "turn.steer",628 "type": "turn.steer",
312 "protocolVersion": 1,629 "protocolVersion": 2,
313 "requestId": "steer-rejected",630 "requestId": "steer-rejected",
314 "threadId": "thread-rejected-steer",631 "threadId": "thread-rejected-steer",
315 "sessionId": session_id,632 "sessionId": session_id,
@@ -339,7 +656,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 23 unmodified lines
339 await self.adapter._handle_server_frame(656 await self.adapter._handle_server_frame(
340 {657 {
341 "type": "session.ensure",658 "type": "session.ensure",
342 "protocolVersion": 1,659 "protocolVersion": 2,
343 "requestId": "ensure-failed-steer",660 "requestId": "ensure-failed-steer",
344 "threadId": "thread-failed-steer",661 "threadId": "thread-failed-steer",
345 }662 }
@@ -348,7 +665,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines
348 await self.adapter._handle_server_frame(665 await self.adapter._handle_server_frame(
349 {666 {
350 "type": "turn.start",667 "type": "turn.start",
351 "protocolVersion": 1,668 "protocolVersion": 2,
352 "requestId": "start-failed-steer",669 "requestId": "start-failed-steer",
353 "threadId": "thread-failed-steer",670 "threadId": "thread-failed-steer",
354 "sessionId": session_id,671 "sessionId": session_id,
@@ -365,7 +682,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
365 await self.adapter._handle_server_frame(682 await self.adapter._handle_server_frame(
366 {683 {
367 "type": "turn.steer",684 "type": "turn.steer",
368 "protocolVersion": 1,685 "protocolVersion": 2,
369 "requestId": "steer-failed",686 "requestId": "steer-failed",
370 "threadId": "thread-failed-steer",687 "threadId": "thread-failed-steer",
371 "sessionId": session_id,688 "sessionId": session_id,
@@ -386,7 +703,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines
386 await self.adapter._handle_server_frame(703 await self.adapter._handle_server_frame(
387 {704 {
388 "type": "session.ensure",705 "type": "session.ensure",
389 "protocolVersion": 1,706 "protocolVersion": 2,
390 "requestId": "ensure-3",707 "requestId": "ensure-3",
391 "threadId": "thread-3",708 "threadId": "thread-3",
392 }709 }
@@ -396,7 +713,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 3 unmodified lines
396 await self.adapter._handle_server_frame(713 await self.adapter._handle_server_frame(
397 {714 {
398 "type": "session.stop",715 "type": "session.stop",
399 "protocolVersion": 1,716 "protocolVersion": 2,
400 "requestId": "stop-3",717 "requestId": "stop-3",
401 "threadId": "thread-3",718 "threadId": "thread-3",
402 "sessionId": session_id,719 "sessionId": session_id,
integrations/hermes-t3-gateway/tests/test_connection.py+22−2
@@ -60,7 +60,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 59 unmodified lines
60 socket = FakeSocket(60 socket = FakeSocket(
61 {61 {
62 "type": "connection.accepted",62 "type": "connection.accepted",
63 "protocolVersion": 1,63 "protocolVersion": 2,
64 "instanceId": "provider-instance",64 "instanceId": "provider-instance",
65 "nickname": "Research",65 "nickname": "Research",
66 "credential": "persistent-secret",66 "credential": "persistent-secret",
@@ -77,13 +77,33 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines
77 {"type": "enrollment-token", "token": "once"},77 {"type": "enrollment-token", "token": "once"},
78 )78 )
79 79
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
80 async def test_rejected_handshake_fails_closed(self):100 async def test_rejected_handshake_fails_closed(self):
81 socket = FakeSocket(101 socket = FakeSocket(
82 {102 {
83 "type": "connection.rejected",103 "type": "connection.rejected",
84 "code": "version-incompatible",104 "code": "version-incompatible",
85 "message": "upgrade required",105 "message": "upgrade required",
86 "expectedProtocolVersion": 1,106 "expectedProtocolVersion": 2,
87 }107 }
88 )108 )
89 with self.assertRaises(connection.ConnectionRejected) as raised:109 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
14 14
15 15
16class ProtocolTests(unittest.TestCase):16class ProtocolTests(unittest.TestCase):
17 def test_hello_matches_v1_contract(self):17 def test_hello_matches_v2_contract(self):
18 hello = protocol.connection_hello(18 hello = protocol.connection_hello(
19 hermes_version="0.19.0",19 hermes_version="0.19.0",
20 authentication={"type": "enrollment-token", "token": "once"},20 authentication={"type": "enrollment-token", "token": "once"},
@@ -22,15 +22,16 @@ class ProtocolTests(unittest.TestCase): · 1 unmodified line
22 )22 )
23 self.assertEqual(hello["type"], "connection.hello")23 self.assertEqual(hello["type"], "connection.hello")
24 self.assertEqual(hello["requestId"], "request-1")24 self.assertEqual(hello["requestId"], "request-1")
25 self.assertEqual(hello["protocolVersion"], 1)25 self.assertEqual(hello["protocolVersion"], 2)
26 self.assertFalse(hello["capabilities"]["attachments"])26 self.assertFalse(hello["capabilities"]["attachments"])
27 self.assertTrue(hello["capabilities"]["streaming"])27 self.assertTrue(hello["capabilities"]["streaming"])
28 28
29 def test_server_frame_validation_is_closed(self):29 def test_server_frame_validation_is_closed(self):
30 with self.assertRaisesRegex(ValueError, "unsupported"):30 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})
32 with self.assertRaisesRegex(ValueError, "version"):32 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})
34 35
35 def test_tool_types_map_to_canonical_items(self):36 def test_tool_types_map_to_canonical_items(self):
36 self.assertEqual(37 self.assertEqual(
packages/contracts/src/hermesGateway.test.ts+64−37
@@ -67,12 +67,12 @@ describe("Hermes gateway management contracts", () => { · 66 unmodified lines
67 status: "connected",67 status: "connected",
68 connectorUrl: "wss://siva.davis7.space/hermes",68 connectorUrl: "wss://siva.davis7.space/hermes",
69 lastConnectedAt: "2026-07-23T12:00:00.000Z",69 lastConnectedAt: "2026-07-23T12:00:00.000Z",
70 pluginVersion: "0.1.0",70 pluginVersion: "0.2.0",
71 hermesVersion: "1.2.3",71 hermesVersion: "1.2.3",
72 activeSessionCount: 2,72 activeSessionCount: 2,
73 protocolVersion: 1,73 protocolVersion: 2,
74 capabilities: {74 capabilities: {
75 protocolVersion: 1,75 protocolVersion: 2,
76 streaming: true,76 streaming: true,
77 activity: true,77 activity: true,
78 approvals: true,78 approvals: true,
@@ -86,10 +86,10 @@ describe("Hermes gateway management contracts", () => { · 7 unmodified lines
86 const upgradeRequired = decodeInstanceStatus({86 const upgradeRequired = decodeInstanceStatus({
87 ...connected,87 ...connected,
88 status: "upgrade-required",88 status: "upgrade-required",
89 protocolVersion: 2,89 protocolVersion: 3,
90 capabilities: null,90 capabilities: null,
91 });91 });
92 expect(upgradeRequired.protocolVersion).toBe(2);92 expect(upgradeRequired.protocolVersion).toBe(3);
93 expect(upgradeRequired.capabilities).toBeNull();93 expect(upgradeRequired.capabilities).toBeNull();
94 });94 });
95});95});
@@ -99,11 +99,11 @@ describe("Hermes gateway handshake", () => { · 3 unmodified lines
99 const hello = decodeHello({99 const hello = decodeHello({
100 type: "connection.hello",100 type: "connection.hello",
101 requestId: "hello-1",101 requestId: "hello-1",
102 protocolVersion: 1,102 protocolVersion: 2,
103 pluginVersion: "0.1.0",103 pluginVersion: "0.2.0",
104 hermesVersion: "1.2.3",104 hermesVersion: "1.2.3",
105 capabilities: {105 capabilities: {
106 protocolVersion: 1,106 protocolVersion: 2,
107 streaming: true,107 streaming: true,
108 activity: true,108 activity: true,
109 approvals: true,109 approvals: true,
@@ -123,11 +123,11 @@ describe("Hermes gateway handshake", () => { · 13 unmodified lines
123 const hello = decodeHello({123 const hello = decodeHello({
124 type: "connection.hello",124 type: "connection.hello",
125 requestId: "hello-2",125 requestId: "hello-2",
126 protocolVersion: 1,126 protocolVersion: 2,
127 pluginVersion: "0.1.0",127 pluginVersion: "0.2.0",
128 hermesVersion: "1.2.3",128 hermesVersion: "1.2.3",
129 capabilities: {129 capabilities: {
130 protocolVersion: 1,130 protocolVersion: 2,
131 streaming: true,131 streaming: true,
132 activity: true,132 activity: true,
133 approvals: true,133 approvals: true,
@@ -148,11 +148,11 @@ describe("Hermes gateway handshake", () => { · 14 unmodified lines
148 const hello = decodeHello({148 const hello = decodeHello({
149 type: "connection.hello",149 type: "connection.hello",
150 requestId: "hello-future",150 requestId: "hello-future",
151 protocolVersion: 2,151 protocolVersion: 3,
152 pluginVersion: "0.2.0",152 pluginVersion: "0.3.0",
153 hermesVersion: "2.0.0",153 hermesVersion: "2.0.0",
154 capabilities: {154 capabilities: {
155 protocolVersion: 2,155 protocolVersion: 3,
156 streaming: true,156 streaming: true,
157 activity: true,157 activity: true,
158 approvals: true,158 approvals: true,
@@ -165,14 +165,14 @@ describe("Hermes gateway handshake", () => { · 6 unmodified lines
165 },165 },
166 });166 });
167 167
168 expect(hello.protocolVersion).toBe(2);168 expect(hello.protocolVersion).toBe(3);
169 expect(hello.capabilities.protocolVersion).toBe(2);169 expect(hello.capabilities.protocolVersion).toBe(3);
170 });170 });
171 171
172 it("reserves attachments for a future protocol version", () => {172 it("reserves attachments for a future protocol version", () => {
173 expect(() =>173 expect(() =>
174 decodeCapabilities({174 decodeCapabilities({
175 protocolVersion: 1,175 protocolVersion: 2,
176 streaming: true,176 streaming: true,
177 activity: true,177 activity: true,
178 approvals: true,178 approvals: true,
@@ -183,12 +183,12 @@ describe("Hermes gateway handshake", () => { · 4 unmodified lines
183 });183 });
184});184});
185 185
186describe("T3 to Hermes v1 messages", () => {186describe("T3 to Hermes v2 messages", () => {
187 it("decodes session creation and opaque resume cursors", () => {187 it("decodes session creation and opaque resume cursors", () => {
188 expect(188 expect(
189 decodeT3Message({189 decodeT3Message({
190 type: "session.ensure",190 type: "session.ensure",
191 protocolVersion: 1,191 protocolVersion: 2,
192 requestId: "ensure-1",192 requestId: "ensure-1",
193 threadId: "thread-1",193 threadId: "thread-1",
194 resumeSessionId: "opaque/hermes/session/value",194 resumeSessionId: "opaque/hermes/session/value",
@@ -197,7 +197,7 @@ describe("T3 to Hermes v1 messages", () => { · 2 unmodified lines
197 197
198 expect(198 expect(
199 decodeResumeCursor({199 decodeResumeCursor({
200 protocolVersion: 1,200 protocolVersion: 2,
201 sessionId: "opaque/hermes/session/value",201 sessionId: "opaque/hermes/session/value",
202 }).sessionId,202 }).sessionId,
203 ).toBe("opaque/hermes/session/value");203 ).toBe("opaque/hermes/session/value");
@@ -205,7 +205,7 @@ describe("T3 to Hermes v1 messages", () => { · 1 unmodified line
205 205
206 it("decodes start and steering as distinct turn operations", () => {206 it("decodes start and steering as distinct turn operations", () => {
207 const context = {207 const context = {
208 protocolVersion: 1,208 protocolVersion: 2,
209 requestId: "turn-command-1",209 requestId: "turn-command-1",
210 threadId: "thread-1",210 threadId: "thread-1",
211 sessionId: "session-1",211 sessionId: "session-1",
@@ -219,7 +219,7 @@ describe("T3 to Hermes v1 messages", () => { · 7 unmodified lines
219 219
220 it("decodes interrupt, approval, structured input, stop, and ping", () => {220 it("decodes interrupt, approval, structured input, stop, and ping", () => {
221 const turnContext = {221 const turnContext = {
222 protocolVersion: 1,222 protocolVersion: 2,
223 threadId: "thread-1",223 threadId: "thread-1",
224 sessionId: "session-1",224 sessionId: "session-1",
225 turnId: "turn-1",225 turnId: "turn-1",
@@ -251,7 +251,7 @@ describe("T3 to Hermes v1 messages", () => { · 25 unmodified lines
251 expect(251 expect(
252 decodeT3Message({252 decodeT3Message({
253 type: "session.stop",253 type: "session.stop",
254 protocolVersion: 1,254 protocolVersion: 2,
255 requestId: "stop-1",255 requestId: "stop-1",
256 threadId: "thread-1",256 threadId: "thread-1",
257 sessionId: "session-1",257 sessionId: "session-1",
@@ -260,7 +260,7 @@ describe("T3 to Hermes v1 messages", () => { · 2 unmodified lines
260 expect(260 expect(
261 decodeT3Message({261 decodeT3Message({
262 type: "ping",262 type: "ping",
263 protocolVersion: 1,263 protocolVersion: 2,
264 requestId: "ping-1",264 requestId: "ping-1",
265 sentAt: "2026-07-23T12:00:00.000Z",265 sentAt: "2026-07-23T12:00:00.000Z",
266 }).type,266 }).type,
@@ -271,7 +271,8 @@ describe("T3 to Hermes v1 messages", () => { · 4 unmodified lines
271 expect(() =>271 expect(() =>
272 decodeT3Message({272 decodeT3Message({
273 type: "ping",273 type: "ping",
274 protocolVersion: 2,274 // Protocol v1 peers must upgrade before sending post-handshake frames.
275 protocolVersion: 1,
275 requestId: "ping-1",276 requestId: "ping-1",
276 sentAt: "2026-07-23T12:00:00.000Z",277 sentAt: "2026-07-23T12:00:00.000Z",
277 }),278 }),
@@ -279,25 +280,38 @@ describe("T3 to Hermes v1 messages", () => { · 1 unmodified line
279 });280 });
280});281});
281 282
282describe("Hermes to T3 v1 events", () => {283describe("Hermes to T3 v2 events", () => {
283 const turnContext = {284 const turnContext = {
284 protocolVersion: 1,285 protocolVersion: 2,
285 threadId: "thread-1",286 threadId: "thread-1",
286 sessionId: "session-1",287 sessionId: "session-1",
287 turnId: "turn-1",288 turnId: "turn-1",
288 };289 };
289 290
290 it("decodes session readiness, turn start, streaming text, and completion", () => {291 it("decodes session readiness, turn start, streaming text, and completion", () => {
291 expect(292 const legacyReady = decodePluginMessage({
292 decodePluginMessage({293 type: "session.ready",
293 type: "session.ready",294 protocolVersion: 2,
294 protocolVersion: 1,295 requestId: "ensure-1",
295 requestId: "ensure-1",296 threadId: "thread-1",
296 threadId: "thread-1",297 sessionId: "session-1",
297 sessionId: "session-1",298 resumed: false,
298 resumed: false,299 });
299 }).type,300 expect(legacyReady.type).toBe("session.ready");
300 ).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");
301 expect(315 expect(
302 decodePluginMessage({316 decodePluginMessage({
303 type: "turn.started",317 type: "turn.started",
@@ -313,6 +327,19 @@ describe("Hermes to T3 v1 events", () => { · 9 unmodified lines
313 ...turnContext,327 ...turnContext,
314 }).type,328 }).type,
315 ).toBe("content.delta");329 ).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("");
316 expect(343 expect(
317 decodePluginMessage({344 decodePluginMessage({
318 type: "turn.completed",345 type: "turn.completed",
packages/contracts/src/hermesGateway.ts+17−4
@@ -21,7 +21,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat · 20 unmodified lines
21import { ProviderInstanceId } from "./providerInstance.ts";21import { ProviderInstanceId } from "./providerInstance.ts";
22import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";22import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
23 23
24export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const;24export const HERMES_GATEWAY_PROTOCOL_VERSION = 2 as const;
25 25
26export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);26export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
27export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;27export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
@@ -87,7 +87,7 @@ export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; · 59 unmodified lines
87 *87 *
88 * This deliberately permits capability shapes from a newer protocol so T3 can88 * This deliberately permits capability shapes from a newer protocol so T3 can
89 * return a structured `version-incompatible` rejection instead of failing the89 * return a structured `version-incompatible` rejection instead of failing the
90 * WebSocket frame decoder. Accepted v1 connections must subsequently validate90 * WebSocket frame decoder. Accepted v2 connections must subsequently validate
91 * this advertisement with `HermesGatewayCapabilities`.91 * this advertisement with `HermesGatewayCapabilities`.
92 */92 */
93export const HermesGatewayHelloCapabilities = Schema.Struct({93export const HermesGatewayHelloCapabilities = Schema.Struct({
@@ -112,7 +112,7 @@ export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.T · 18 unmodified lines
112/**112/**
113 * Public instance state used by settings and provider-picker surfaces.113 * Public instance state used by settings and provider-picker surfaces.
114 *114 *
115 * `protocolVersion` is not restricted to v1 here so the UI can report the115 * `protocolVersion` is not restricted to v2 here so the UI can report the
116 * unsupported version observed from a plugin that needs an upgrade.116 * unsupported version observed from a plugin that needs an upgrade.
117 */117 */
118export const HermesGatewayInstanceStatus = Schema.Struct({118export const HermesGatewayInstanceStatus = Schema.Struct({
@@ -241,7 +241,7 @@ export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Typ · 122 unmodified lines
241/**241/**
242 * `protocolVersion` accepts any positive integer at the initial boundary so242 * `protocolVersion` accepts any positive integer at the initial boundary so
243 * T3 can reject incompatible plugins with a structured upgrade response.243 * 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.
245 */245 */
246export const HermesGatewayConnectionHello = Schema.Struct({246export const HermesGatewayConnectionHello = Schema.Struct({
247 type: Schema.Literal("connection.hello"),247 type: Schema.Literal("connection.hello"),
@@ -380,6 +380,7 @@ export const HermesGatewaySessionReady = Schema.Struct({ · 132 unmodified lines
380 threadId: ThreadId,380 threadId: ThreadId,
381 sessionId: HermesGatewaySessionId,381 sessionId: HermesGatewaySessionId,
382 resumed: Schema.Boolean,382 resumed: Schema.Boolean,
383 activeTurnId: Schema.optional(TurnId),
383});384});
384export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type;385export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type;
385 386
@@ -412,6 +413,17 @@ export const HermesGatewayContentDelta = Schema.Struct({ · 26 unmodified lines
412});413});
413export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type;414export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type;
414 415
416export 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});
425export type HermesGatewayContentSnapshot = typeof HermesGatewayContentSnapshot.Type;
426
415export const HermesGatewayItemStatus = Schema.Literals([427export const HermesGatewayItemStatus = Schema.Literals([
416 "inProgress",428 "inProgress",
417 "completed",429 "completed",
@@ -569,6 +581,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ · 151 unmodified lines
569 HermesGatewaySessionReady,581 HermesGatewaySessionReady,
570 HermesGatewayTurnStarted,582 HermesGatewayTurnStarted,
571 HermesGatewayContentDelta,583 HermesGatewayContentDelta,
584 HermesGatewayContentSnapshot,
572 HermesGatewayItemStarted,585 HermesGatewayItemStarted,
573 HermesGatewayItemUpdated,586 HermesGatewayItemUpdated,
574 HermesGatewayItemCompleted,587 HermesGatewayItemCompleted,
packages/contracts/src/orchestration.ts+12−0
@@ -813,6 +813,16 @@ const ThreadMessageAssistantDeltaCommand = Schema.Struct({ · 812 unmodified lines
813 createdAt: IsoDateTime,813 createdAt: IsoDateTime,
814});814});
815 815
816const 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
816const ThreadMessageAssistantCompleteCommand = Schema.Struct({826const ThreadMessageAssistantCompleteCommand = Schema.Struct({
817 type: Schema.Literal("thread.message.assistant.complete"),827 type: Schema.Literal("thread.message.assistant.complete"),
818 commandId: CommandId,828 commandId: CommandId,
@@ -863,6 +873,7 @@ const ThreadRevertCompleteCommand = Schema.Struct({ · 44 unmodified lines
863const InternalOrchestrationCommand = Schema.Union([873const InternalOrchestrationCommand = Schema.Union([
864 ThreadSessionSetCommand,874 ThreadSessionSetCommand,
865 ThreadMessageAssistantDeltaCommand,875 ThreadMessageAssistantDeltaCommand,
876 ThreadMessageAssistantReplaceCommand,
866 ThreadMessageAssistantCompleteCommand,877 ThreadMessageAssistantCompleteCommand,
867 ThreadProposedPlanUpsertCommand,878 ThreadProposedPlanUpsertCommand,
868 ThreadTurnDiffCompleteCommand,879 ThreadTurnDiffCompleteCommand,
@@ -1028,6 +1039,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 159 unmodified lines
1028 attachments: Schema.optional(Schema.Array(ChatAttachment)),1039 attachments: Schema.optional(Schema.Array(ChatAttachment)),
1029 turnId: Schema.NullOr(TurnId),1040 turnId: Schema.NullOr(TurnId),
1030 streaming: Schema.Boolean,1041 streaming: Schema.Boolean,
1042 textOperation: Schema.optional(Schema.Literal("replace")),
1031 createdAt: IsoDateTime,1043 createdAt: IsoDateTime,
1032 updatedAt: IsoDateTime,1044 updatedAt: IsoDateTime,
1033});1045});
packages/contracts/src/providerRuntime.test.ts+28−0
@@ -69,6 +69,34 @@ describe("ProviderRuntimeEvent", () => { · 68 unmodified lines
69 expect(parsed.payload.planMarkdown).toBe("# Ship it");69 expect(parsed.payload.planMarkdown).toBe("# Ship it");
70 });70 });
71 71
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
72 it("decodes user-input.requested with structured questions", () => {100 it("decodes user-input.requested with structured questions", () => {
73 const parsed = decodeRuntimeEvent({101 const parsed = decodeRuntimeEvent({
74 type: "user-input.requested",102 type: "user-input.requested",
packages/contracts/src/providerRuntime.ts+18−0
@@ -170,6 +170,7 @@ const ProviderRuntimeEventType = Schema.Literals([ · 169 unmodified lines
170 "item.updated",170 "item.updated",
171 "item.completed",171 "item.completed",
172 "content.delta",172 "content.delta",
173 "content.snapshot",
173 "request.opened",174 "request.opened",
174 "request.resolved",175 "request.resolved",
175 "user-input.requested",176 "user-input.requested",
@@ -220,6 +221,7 @@ const ItemStartedType = Schema.Literal("item.started"); · 44 unmodified lines
220const ItemUpdatedType = Schema.Literal("item.updated");221const ItemUpdatedType = Schema.Literal("item.updated");
221const ItemCompletedType = Schema.Literal("item.completed");222const ItemCompletedType = Schema.Literal("item.completed");
222const ContentDeltaType = Schema.Literal("content.delta");223const ContentDeltaType = Schema.Literal("content.delta");
224const ContentSnapshotType = Schema.Literal("content.snapshot");
223const RequestOpenedType = Schema.Literal("request.opened");225const RequestOpenedType = Schema.Literal("request.opened");
224const RequestResolvedType = Schema.Literal("request.resolved");226const RequestResolvedType = Schema.Literal("request.resolved");
225const UserInputRequestedType = Schema.Literal("user-input.requested");227const UserInputRequestedType = Schema.Literal("user-input.requested");
@@ -418,6 +420,14 @@ const ContentDeltaPayload = Schema.Struct({ · 192 unmodified lines
418});420});
419export type ContentDeltaPayload = typeof ContentDeltaPayload.Type;421export type ContentDeltaPayload = typeof ContentDeltaPayload.Type;
420 422
423export const ContentSnapshotPayload = Schema.Struct({
424 streamKind: RuntimeContentStreamKind,
425 text: Schema.String,
426 contentIndex: Schema.optional(Schema.Int),
427 summaryIndex: Schema.optional(Schema.Int),
428});
429export type ContentSnapshotPayload = typeof ContentSnapshotPayload.Type;
430
421const RequestOpenedPayload = Schema.Struct({431const RequestOpenedPayload = Schema.Struct({
422 requestType: CanonicalRequestType,432 requestType: CanonicalRequestType,
423 detail: Schema.optional(TrimmedNonEmptyStringSchema),433 detail: Schema.optional(TrimmedNonEmptyStringSchema),
@@ -791,6 +801,13 @@ const ProviderRuntimeContentDeltaEvent = Schema.Struct({ · 367 unmodified lines
791});801});
792export type ProviderRuntimeContentDeltaEvent = typeof ProviderRuntimeContentDeltaEvent.Type;802export type ProviderRuntimeContentDeltaEvent = typeof ProviderRuntimeContentDeltaEvent.Type;
793 803
804const ProviderRuntimeContentSnapshotEvent = Schema.Struct({
805 ...ProviderRuntimeEventBase.fields,
806 type: ContentSnapshotType,
807 payload: ContentSnapshotPayload,
808});
809export type ProviderRuntimeContentSnapshotEvent = typeof ProviderRuntimeContentSnapshotEvent.Type;
810
794const ProviderRuntimeRequestOpenedEvent = Schema.Struct({811const ProviderRuntimeRequestOpenedEvent = Schema.Struct({
795 ...ProviderRuntimeEventBase.fields,812 ...ProviderRuntimeEventBase.fields,
796 type: RequestOpenedType,813 type: RequestOpenedType,
@@ -989,6 +1006,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ · 192 unmodified lines
989 ProviderRuntimeItemUpdatedEvent,1006 ProviderRuntimeItemUpdatedEvent,
990 ProviderRuntimeItemCompletedEvent,1007 ProviderRuntimeItemCompletedEvent,
991 ProviderRuntimeContentDeltaEvent,1008 ProviderRuntimeContentDeltaEvent,
1009 ProviderRuntimeContentSnapshotEvent,
992 ProviderRuntimeRequestOpenedEvent,1010 ProviderRuntimeRequestOpenedEvent,
993 ProviderRuntimeRequestResolvedEvent,1011 ProviderRuntimeRequestResolvedEvent,
994 ProviderRuntimeUserInputRequestedEvent,1012 ProviderRuntimeUserInputRequestedEvent,