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