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 | it.effect( | 1766 | it.effect( |
| 1656 | "resolves turn-count conflicts when checkpoint completion rewrites provisional turns", | 1767 | "resolves turn-count conflicts when checkpoint completion rewrites provisional turns", |
| 1657 | () => | 1768 | () => |
| @@ -887,6 +887,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 886 unmodified lines | |||
| 887 | const nextText = Option.match(existingMessage, { | 887 | const nextText = Option.match(existingMessage, { |
| 888 | onNone: () => event.payload.text, | 888 | onNone: () => event.payload.text, |
| 889 | onSome: (message) => { | 889 | onSome: (message) => { |
| 890 | if (event.payload.textOperation === "replace") { | ||
| 891 | return event.payload.text; | ||
| 892 | } | ||
| 890 | if (event.payload.streaming) { | 893 | if (event.payload.streaming) { |
| 891 | return `${message.text}${event.payload.text}`; | 894 | return `${message.text}${event.payload.text}`; |
| 892 | } | 895 | } |
| @@ -477,6 +477,49 @@ describe("ProviderCommandReactor", () => { · 476 unmodified lines | |||
| 477 | expect(thread?.session?.runtimeMode).toBe("approval-required"); | 477 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 478 | }); | 478 | }); |
| 479 | 479 | ||
| 480 | effectIt.effect("adopts a recovered active provider turn while binding a pending start", () => | ||
| 481 | Effect.gen(function* () { | ||
| 482 | const recoveredTurnId = asTurnId("turn-recovered-during-start"); | ||
| 483 | const harness = yield* Effect.promise(() => | ||
| 484 | createHarness({ | ||
| 485 | startSessionEffect: (session) => | ||
| 486 | Effect.succeed({ | ||
| 487 | ...session, | ||
| 488 | status: "running", | ||
| 489 | activeTurnId: recoveredTurnId, | ||
| 490 | }), | ||
| 491 | }), | ||
| 492 | ); | ||
| 493 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 494 | |||
| 495 | yield* harness.engine.dispatch({ | ||
| 496 | type: "thread.turn.start", | ||
| 497 | commandId: CommandId.make("cmd-turn-start-recovered-provider-turn"), | ||
| 498 | threadId: ThreadId.make("thread-1"), | ||
| 499 | message: { | ||
| 500 | messageId: asMessageId("user-message-recovered-provider-turn"), | ||
| 501 | role: "user", | ||
| 502 | text: "steer the recovered turn", | ||
| 503 | attachments: [], | ||
| 504 | }, | ||
| 505 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 506 | runtimeMode: "approval-required", | ||
| 507 | createdAt: now, | ||
| 508 | }); | ||
| 509 | |||
| 510 | yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); | ||
| 511 | const readModel = yield* Effect.promise(() => harness.readModel()); | ||
| 512 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | ||
| 513 | |||
| 514 | expect(thread?.session?.status).toBe("running"); | ||
| 515 | expect(thread?.session?.activeTurnId).toBe(recoveredTurnId); | ||
| 516 | expect(thread?.latestTurn).toMatchObject({ | ||
| 517 | turnId: recoveredTurnId, | ||
| 518 | state: "running", | ||
| 519 | }); | ||
| 520 | }), | ||
| 521 | ); | ||
| 522 | |||
| 480 | effectIt.effect("projects starting before a slow provider session finishes", () => | 523 | effectIt.effect("projects starting before a slow provider session finishes", () => |
| 481 | Effect.gen(function* () { | 524 | Effect.gen(function* () { |
| 482 | const releaseStart = yield* Deferred.make<void>(); | 525 | const releaseStart = yield* Deferred.make<void>(); |
| @@ -527,8 +527,7 @@ const make = Effect.gen(function* () { · 526 unmodified lines | |||
| 527 | providerName: session.provider, | 527 | providerName: session.provider, |
| 528 | providerInstanceId: session.providerInstanceId, | 528 | providerInstanceId: session.providerInstanceId, |
| 529 | runtimeMode: desiredRuntimeMode, | 529 | runtimeMode: desiredRuntimeMode, |
| 530 | // Provider turn ids are not orchestration turn ids. | 530 | activeTurnId: session.activeTurnId ?? null, |
| 531 | activeTurnId: null, | ||
| 532 | lastError: session.lastError ?? null, | 531 | lastError: session.lastError ?? null, |
| 533 | updatedAt: session.updatedAt, | 532 | updatedAt: session.updatedAt, |
| 534 | }, | 533 | }, |
| @@ -27,6 +27,7 @@ import * as Effect from "effect/Effect"; · 26 unmodified lines | |||
| 27 | import * as Exit from "effect/Exit"; | 27 | import * as Exit from "effect/Exit"; |
| 28 | import * as Layer from "effect/Layer"; | 28 | import * as Layer from "effect/Layer"; |
| 29 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 29 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 30 | import * as Option from "effect/Option"; | ||
| 30 | import * as PubSub from "effect/PubSub"; | 31 | import * as PubSub from "effect/PubSub"; |
| 31 | import * as Scope from "effect/Scope"; | 32 | import * as Scope from "effect/Scope"; |
| 32 | import * as Stream from "effect/Stream"; | 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 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 37 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 37 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 38 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 39 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; | ||
| 38 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 40 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 41 | import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; | ||
| 39 | import { | 42 | import { |
| 40 | ProviderService, | 43 | ProviderService, |
| 41 | type ProviderServiceShape, | 44 | type ProviderServiceShape, |
| @@ -192,7 +195,10 @@ async function waitForThread( · 150 unmodified lines | |||
| 192 | 195 | ||
| 193 | describe("ProviderRuntimeIngestion", () => { | 196 | describe("ProviderRuntimeIngestion", () => { |
| 194 | let runtime: ManagedRuntime.ManagedRuntime< | 197 | let runtime: ManagedRuntime.ManagedRuntime< |
| 195 | OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery, | 198 | | OrchestrationEngineService |
| 199 | | ProviderRuntimeIngestionService | ||
| 200 | | ProjectionSnapshotQuery | ||
| 201 | | ProjectionTurnRepository, | ||
| 196 | unknown | 202 | unknown |
| 197 | > | null = null; | 203 | > | null = null; |
| 198 | let scope: Scope.Closeable | null = null; | 204 | let scope: Scope.Closeable | null = null; |
| @@ -234,9 +240,13 @@ describe("ProviderRuntimeIngestion", () => { · 35 unmodified lines | |||
| 234 | Layer.provide(RepositoryIdentityResolver.layer), | 240 | Layer.provide(RepositoryIdentityResolver.layer), |
| 235 | Layer.provide(SqlitePersistenceMemory), | 241 | Layer.provide(SqlitePersistenceMemory), |
| 236 | ); | 242 | ); |
| 243 | const projectionTurnLayer = ProjectionTurnRepositoryLive.pipe( | ||
| 244 | Layer.provide(SqlitePersistenceMemory), | ||
| 245 | ); | ||
| 237 | const layer = ProviderRuntimeIngestionLive.pipe( | 246 | const layer = ProviderRuntimeIngestionLive.pipe( |
| 238 | Layer.provideMerge(orchestrationLayer), | 247 | Layer.provideMerge(orchestrationLayer), |
| 239 | Layer.provideMerge(projectionSnapshotLayer), | 248 | Layer.provideMerge(projectionSnapshotLayer), |
| 249 | Layer.provideMerge(projectionTurnLayer), | ||
| 240 | Layer.provideMerge(SqlitePersistenceMemory), | 250 | Layer.provideMerge(SqlitePersistenceMemory), |
| 241 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 251 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 242 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), | 252 | Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), |
| @@ -247,6 +257,7 @@ describe("ProviderRuntimeIngestion", () => { · 4 unmodified lines | |||
| 247 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | 257 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 248 | const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); | 258 | const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); |
| 249 | const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); | 259 | const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); |
| 260 | const turnRepository = await runtime.runPromise(Effect.service(ProjectionTurnRepository)); | ||
| 250 | scope = await Effect.runPromise(Scope.make("sequential")); | 261 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 251 | await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); | 262 | await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); |
| 252 | const drain = () => Effect.runPromise(ingestion.drain); | 263 | const drain = () => Effect.runPromise(ingestion.drain); |
| @@ -315,6 +326,7 @@ describe("ProviderRuntimeIngestion", () => { · 62 unmodified lines | |||
| 315 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), | 326 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), |
| 316 | emit: provider.emit, | 327 | emit: provider.emit, |
| 317 | setProviderSession: provider.setSession, | 328 | setProviderSession: provider.setSession, |
| 329 | turnRepository, | ||
| 318 | drain, | 330 | drain, |
| 319 | }; | 331 | }; |
| 320 | } | 332 | } |
| @@ -946,6 +958,209 @@ describe("ProviderRuntimeIngestion", () => { · 625 unmodified lines | |||
| 946 | expect(message?.streaming).toBe(false); | 958 | expect(message?.streaming).toBe(false); |
| 947 | }); | 959 | }); |
| 948 | 960 | ||
| 961 | it("replaces streaming assistant text from authoritative snapshots without changing identity", async () => { | ||
| 962 | const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); | ||
| 963 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 964 | const itemId = asItemId("item-streaming-snapshot"); | ||
| 965 | const turnId = asTurnId("turn-streaming-snapshot"); | ||
| 966 | |||
| 967 | harness.emit({ | ||
| 968 | type: "content.delta", | ||
| 969 | eventId: asEventId("evt-streaming-snapshot-delta"), | ||
| 970 | provider: ProviderDriverKind.make("hermes"), | ||
| 971 | createdAt: now, | ||
| 972 | threadId: asThreadId("thread-1"), | ||
| 973 | turnId, | ||
| 974 | itemId, | ||
| 975 | payload: { | ||
| 976 | streamKind: "assistant_text", | ||
| 977 | delta: "draft", | ||
| 978 | }, | ||
| 979 | }); | ||
| 980 | harness.emit({ | ||
| 981 | type: "content.snapshot", | ||
| 982 | eventId: asEventId("evt-streaming-snapshot-replace"), | ||
| 983 | provider: ProviderDriverKind.make("hermes"), | ||
| 984 | createdAt: now, | ||
| 985 | threadId: asThreadId("thread-1"), | ||
| 986 | turnId, | ||
| 987 | itemId, | ||
| 988 | payload: { | ||
| 989 | streamKind: "assistant_text", | ||
| 990 | text: "authoritative", | ||
| 991 | }, | ||
| 992 | }); | ||
| 993 | harness.emit({ | ||
| 994 | type: "content.delta", | ||
| 995 | eventId: asEventId("evt-streaming-snapshot-tail"), | ||
| 996 | provider: ProviderDriverKind.make("hermes"), | ||
| 997 | createdAt: now, | ||
| 998 | threadId: asThreadId("thread-1"), | ||
| 999 | turnId, | ||
| 1000 | itemId, | ||
| 1001 | payload: { | ||
| 1002 | streamKind: "assistant_text", | ||
| 1003 | delta: " answer", | ||
| 1004 | }, | ||
| 1005 | }); | ||
| 1006 | harness.emit({ | ||
| 1007 | type: "item.completed", | ||
| 1008 | eventId: asEventId("evt-streaming-snapshot-completed"), | ||
| 1009 | provider: ProviderDriverKind.make("hermes"), | ||
| 1010 | createdAt: now, | ||
| 1011 | threadId: asThreadId("thread-1"), | ||
| 1012 | turnId, | ||
| 1013 | itemId, | ||
| 1014 | payload: { | ||
| 1015 | itemType: "assistant_message", | ||
| 1016 | status: "completed", | ||
| 1017 | }, | ||
| 1018 | }); | ||
| 1019 | |||
| 1020 | const thread = await waitForThread(harness.readModel, (entry) => | ||
| 1021 | entry.messages.some( | ||
| 1022 | (message: ProviderRuntimeTestMessage) => | ||
| 1023 | message.id === `assistant:${itemId}` && | ||
| 1024 | message.text === "authoritative answer" && | ||
| 1025 | !message.streaming, | ||
| 1026 | ), | ||
| 1027 | ); | ||
| 1028 | expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( | ||
| 1029 | 1, | ||
| 1030 | ); | ||
| 1031 | }); | ||
| 1032 | |||
| 1033 | it("applies repeated empty snapshots idempotently to a streaming assistant message", async () => { | ||
| 1034 | const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); | ||
| 1035 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 1036 | const itemId = asItemId("item-empty-snapshot"); | ||
| 1037 | const turnId = asTurnId("turn-empty-snapshot"); | ||
| 1038 | |||
| 1039 | harness.emit({ | ||
| 1040 | type: "content.delta", | ||
| 1041 | eventId: asEventId("evt-empty-snapshot-delta"), | ||
| 1042 | provider: ProviderDriverKind.make("hermes"), | ||
| 1043 | createdAt: now, | ||
| 1044 | threadId: asThreadId("thread-1"), | ||
| 1045 | turnId, | ||
| 1046 | itemId, | ||
| 1047 | payload: { | ||
| 1048 | streamKind: "assistant_text", | ||
| 1049 | delta: "remove me", | ||
| 1050 | }, | ||
| 1051 | }); | ||
| 1052 | for (const eventId of ["evt-empty-snapshot-one", "evt-empty-snapshot-two"]) { | ||
| 1053 | harness.emit({ | ||
| 1054 | type: "content.snapshot", | ||
| 1055 | eventId: asEventId(eventId), | ||
| 1056 | provider: ProviderDriverKind.make("hermes"), | ||
| 1057 | createdAt: now, | ||
| 1058 | threadId: asThreadId("thread-1"), | ||
| 1059 | turnId, | ||
| 1060 | itemId, | ||
| 1061 | payload: { | ||
| 1062 | streamKind: "assistant_text", | ||
| 1063 | text: "", | ||
| 1064 | }, | ||
| 1065 | }); | ||
| 1066 | } | ||
| 1067 | harness.emit({ | ||
| 1068 | type: "item.completed", | ||
| 1069 | eventId: asEventId("evt-empty-snapshot-completed"), | ||
| 1070 | provider: ProviderDriverKind.make("hermes"), | ||
| 1071 | createdAt: now, | ||
| 1072 | threadId: asThreadId("thread-1"), | ||
| 1073 | turnId, | ||
| 1074 | itemId, | ||
| 1075 | payload: { | ||
| 1076 | itemType: "assistant_message", | ||
| 1077 | status: "completed", | ||
| 1078 | }, | ||
| 1079 | }); | ||
| 1080 | |||
| 1081 | const thread = await waitForThread(harness.readModel, (entry) => | ||
| 1082 | entry.messages.some( | ||
| 1083 | (message: ProviderRuntimeTestMessage) => | ||
| 1084 | message.id === `assistant:${itemId}` && message.text === "" && !message.streaming, | ||
| 1085 | ), | ||
| 1086 | ); | ||
| 1087 | expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( | ||
| 1088 | 1, | ||
| 1089 | ); | ||
| 1090 | }); | ||
| 1091 | |||
| 1092 | it("authoritatively replaces buffered assistant text before completion", async () => { | ||
| 1093 | const harness = await createHarness(); | ||
| 1094 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 1095 | const itemId = asItemId("item-buffered-snapshot"); | ||
| 1096 | const turnId = asTurnId("turn-buffered-snapshot"); | ||
| 1097 | |||
| 1098 | harness.emit({ | ||
| 1099 | type: "content.delta", | ||
| 1100 | eventId: asEventId("evt-buffered-snapshot-delta"), | ||
| 1101 | provider: ProviderDriverKind.make("hermes"), | ||
| 1102 | createdAt: now, | ||
| 1103 | threadId: asThreadId("thread-1"), | ||
| 1104 | turnId, | ||
| 1105 | itemId, | ||
| 1106 | payload: { | ||
| 1107 | streamKind: "assistant_text", | ||
| 1108 | delta: "draft", | ||
| 1109 | }, | ||
| 1110 | }); | ||
| 1111 | harness.emit({ | ||
| 1112 | type: "content.snapshot", | ||
| 1113 | eventId: asEventId("evt-buffered-snapshot-replace"), | ||
| 1114 | provider: ProviderDriverKind.make("hermes"), | ||
| 1115 | createdAt: now, | ||
| 1116 | threadId: asThreadId("thread-1"), | ||
| 1117 | turnId, | ||
| 1118 | itemId, | ||
| 1119 | payload: { | ||
| 1120 | streamKind: "assistant_text", | ||
| 1121 | text: "final", | ||
| 1122 | }, | ||
| 1123 | }); | ||
| 1124 | harness.emit({ | ||
| 1125 | type: "content.delta", | ||
| 1126 | eventId: asEventId("evt-buffered-snapshot-tail"), | ||
| 1127 | provider: ProviderDriverKind.make("hermes"), | ||
| 1128 | createdAt: now, | ||
| 1129 | threadId: asThreadId("thread-1"), | ||
| 1130 | turnId, | ||
| 1131 | itemId, | ||
| 1132 | payload: { | ||
| 1133 | streamKind: "assistant_text", | ||
| 1134 | delta: " answer", | ||
| 1135 | }, | ||
| 1136 | }); | ||
| 1137 | harness.emit({ | ||
| 1138 | type: "item.completed", | ||
| 1139 | eventId: asEventId("evt-buffered-snapshot-completed"), | ||
| 1140 | provider: ProviderDriverKind.make("hermes"), | ||
| 1141 | createdAt: now, | ||
| 1142 | threadId: asThreadId("thread-1"), | ||
| 1143 | turnId, | ||
| 1144 | itemId, | ||
| 1145 | payload: { | ||
| 1146 | itemType: "assistant_message", | ||
| 1147 | status: "completed", | ||
| 1148 | }, | ||
| 1149 | }); | ||
| 1150 | |||
| 1151 | const thread = await waitForThread(harness.readModel, (entry) => | ||
| 1152 | entry.messages.some( | ||
| 1153 | (message: ProviderRuntimeTestMessage) => | ||
| 1154 | message.id === `assistant:${itemId}` && | ||
| 1155 | message.text === "final answer" && | ||
| 1156 | !message.streaming, | ||
| 1157 | ), | ||
| 1158 | ); | ||
| 1159 | expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( | ||
| 1160 | 1, | ||
| 1161 | ); | ||
| 1162 | }); | ||
| 1163 | |||
| 949 | it("uses assistant item completion detail when no assistant deltas were streamed", async () => { | 1164 | it("uses assistant item completion detail when no assistant deltas were streamed", async () => { |
| 950 | const harness = await createHarness(); | 1165 | const harness = await createHarness(); |
| 951 | const now = "2026-01-01T00:00:00.000Z"; | 1166 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -1573,6 +1788,85 @@ describe("ProviderRuntimeIngestion", () => { · 621 unmodified lines | |||
| 1573 | expect(threadAfterSteer.latestTurn?.state).toBe("running"); | 1788 | expect(threadAfterSteer.latestTurn?.state).toBe("running"); |
| 1574 | }); | 1789 | }); |
| 1575 | 1790 | ||
| 1791 | effectIt.effect( | ||
| 1792 | "consumes a follow-up pending start when Hermes acknowledges the active turn", | ||
| 1793 | () => | ||
| 1794 | Effect.gen(function* () { | ||
| 1795 | const harness = yield* Effect.promise(() => createHarness()); | ||
| 1796 | const threadId = asThreadId("thread-1"); | ||
| 1797 | const activeTurnId = asTurnId("turn-hermes-active"); | ||
| 1798 | const followUpMessageId = asMessageId("msg-hermes-follow-up"); | ||
| 1799 | const createdAt = "2026-01-01T00:00:00.000Z"; | ||
| 1800 | |||
| 1801 | harness.setProviderSession({ | ||
| 1802 | provider: ProviderDriverKind.make("hermes"), | ||
| 1803 | status: "running", | ||
| 1804 | runtimeMode: "approval-required", | ||
| 1805 | threadId, | ||
| 1806 | createdAt, | ||
| 1807 | updatedAt: createdAt, | ||
| 1808 | activeTurnId, | ||
| 1809 | }); | ||
| 1810 | harness.emit({ | ||
| 1811 | type: "turn.started", | ||
| 1812 | eventId: asEventId("evt-hermes-active-turn-started"), | ||
| 1813 | provider: ProviderDriverKind.make("hermes"), | ||
| 1814 | createdAt, | ||
| 1815 | threadId, | ||
| 1816 | turnId: activeTurnId, | ||
| 1817 | }); | ||
| 1818 | yield* Effect.promise(() => | ||
| 1819 | waitForThread( | ||
| 1820 | harness.readModel, | ||
| 1821 | (thread) => | ||
| 1822 | thread.session?.status === "running" && thread.session.activeTurnId === activeTurnId, | ||
| 1823 | 2_000, | ||
| 1824 | threadId, | ||
| 1825 | ), | ||
| 1826 | ); | ||
| 1827 | |||
| 1828 | yield* harness.engine.dispatch({ | ||
| 1829 | type: "thread.turn.start", | ||
| 1830 | commandId: CommandId.make("cmd-hermes-follow-up"), | ||
| 1831 | threadId, | ||
| 1832 | message: { | ||
| 1833 | messageId: followUpMessageId, | ||
| 1834 | role: "user", | ||
| 1835 | text: "steer the active Hermes turn", | ||
| 1836 | attachments: [], | ||
| 1837 | }, | ||
| 1838 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 1839 | runtimeMode: "approval-required", | ||
| 1840 | createdAt: "2026-01-01T00:00:01.000Z", | ||
| 1841 | }); | ||
| 1842 | |||
| 1843 | const pendingBeforeAck = yield* harness.turnRepository.getPendingTurnStartByThreadId({ | ||
| 1844 | threadId, | ||
| 1845 | }); | ||
| 1846 | expect(Option.getOrUndefined(pendingBeforeAck)?.messageId).toBe(followUpMessageId); | ||
| 1847 | |||
| 1848 | harness.emit({ | ||
| 1849 | type: "turn.started", | ||
| 1850 | eventId: asEventId("evt-hermes-follow-up-ack"), | ||
| 1851 | provider: ProviderDriverKind.make("hermes"), | ||
| 1852 | createdAt: "2026-01-01T00:00:02.000Z", | ||
| 1853 | threadId, | ||
| 1854 | turnId: activeTurnId, | ||
| 1855 | }); | ||
| 1856 | yield* Effect.promise(() => harness.drain()); | ||
| 1857 | |||
| 1858 | const pendingAfterAck = yield* harness.turnRepository.getPendingTurnStartByThreadId({ | ||
| 1859 | threadId, | ||
| 1860 | }); | ||
| 1861 | const activeTurn = yield* harness.turnRepository.getByTurnId({ | ||
| 1862 | threadId, | ||
| 1863 | turnId: activeTurnId, | ||
| 1864 | }); | ||
| 1865 | expect(Option.isNone(pendingAfterAck)).toBe(true); | ||
| 1866 | expect(Option.getOrUndefined(activeTurn)?.pendingMessageId).toBe(followUpMessageId); | ||
| 1867 | }), | ||
| 1868 | ); | ||
| 1869 | |||
| 1576 | it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { | 1870 | it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { |
| 1577 | const harness = await createHarness(); | 1871 | const harness = await createHarness(); |
| 1578 | const sourceThreadId = asThreadId("thread-plan"); | 1872 | const sourceThreadId = asThreadId("thread-plan"); |
| @@ -2543,6 +2837,65 @@ describe("ProviderRuntimeIngestion", () => { · 964 unmodified lines | |||
| 2543 | expect(message?.streaming).toBe(false); | 2837 | expect(message?.streaming).toBe(false); |
| 2544 | }); | 2838 | }); |
| 2545 | 2839 | ||
| 2840 | it("replaces a spilled buffered projection when an authoritative snapshot arrives", async () => { | ||
| 2841 | const harness = await createHarness(); | ||
| 2842 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 2843 | const itemId = asItemId("item-buffer-spill-snapshot"); | ||
| 2844 | const turnId = asTurnId("turn-buffer-spill-snapshot"); | ||
| 2845 | |||
| 2846 | harness.emit({ | ||
| 2847 | type: "content.delta", | ||
| 2848 | eventId: asEventId("evt-buffer-spill-before-snapshot"), | ||
| 2849 | provider: ProviderDriverKind.make("hermes"), | ||
| 2850 | createdAt: now, | ||
| 2851 | threadId: asThreadId("thread-1"), | ||
| 2852 | turnId, | ||
| 2853 | itemId, | ||
| 2854 | payload: { | ||
| 2855 | streamKind: "assistant_text", | ||
| 2856 | delta: "x".repeat(40_000), | ||
| 2857 | }, | ||
| 2858 | }); | ||
| 2859 | harness.emit({ | ||
| 2860 | type: "content.snapshot", | ||
| 2861 | eventId: asEventId("evt-buffer-spill-snapshot"), | ||
| 2862 | provider: ProviderDriverKind.make("hermes"), | ||
| 2863 | createdAt: now, | ||
| 2864 | threadId: asThreadId("thread-1"), | ||
| 2865 | turnId, | ||
| 2866 | itemId, | ||
| 2867 | payload: { | ||
| 2868 | streamKind: "assistant_text", | ||
| 2869 | text: "authoritative after spill", | ||
| 2870 | }, | ||
| 2871 | }); | ||
| 2872 | harness.emit({ | ||
| 2873 | type: "item.completed", | ||
| 2874 | eventId: asEventId("evt-buffer-spill-snapshot-completed"), | ||
| 2875 | provider: ProviderDriverKind.make("hermes"), | ||
| 2876 | createdAt: now, | ||
| 2877 | threadId: asThreadId("thread-1"), | ||
| 2878 | turnId, | ||
| 2879 | itemId, | ||
| 2880 | payload: { | ||
| 2881 | itemType: "assistant_message", | ||
| 2882 | status: "completed", | ||
| 2883 | }, | ||
| 2884 | }); | ||
| 2885 | |||
| 2886 | const thread = await waitForThread(harness.readModel, (entry) => | ||
| 2887 | entry.messages.some( | ||
| 2888 | (message: ProviderRuntimeTestMessage) => | ||
| 2889 | message.id === `assistant:${itemId}` && | ||
| 2890 | message.text === "authoritative after spill" && | ||
| 2891 | !message.streaming, | ||
| 2892 | ), | ||
| 2893 | ); | ||
| 2894 | expect(thread.messages.filter((message) => message.id === `assistant:${itemId}`)).toHaveLength( | ||
| 2895 | 1, | ||
| 2896 | ); | ||
| 2897 | }); | ||
| 2898 | |||
| 2546 | it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => { | 2899 | it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => { |
| 2547 | const harness = await createHarness(); | 2900 | const harness = await createHarness(); |
| 2548 | const now = "2026-01-01T00:00:00.000Z"; | 2901 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -2849,6 +3202,89 @@ describe("ProviderRuntimeIngestion", () => { · 300 unmodified lines | |||
| 2849 | ).toBe(true); | 3202 | ).toBe(true); |
| 2850 | }); | 3203 | }); |
| 2851 | 3204 | ||
| 3205 | it("upserts unknown provider item lifecycle into one meaningful activity", async () => { | ||
| 3206 | const harness = await createHarness(); | ||
| 3207 | const itemId = asItemId("item-generic-activity"); | ||
| 3208 | const turnId = asTurnId("turn-generic-activity"); | ||
| 3209 | |||
| 3210 | harness.emit({ | ||
| 3211 | type: "item.started", | ||
| 3212 | eventId: asEventId("evt-generic-activity-started"), | ||
| 3213 | provider: ProviderDriverKind.make("hermes"), | ||
| 3214 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 3215 | threadId: asThreadId("thread-1"), | ||
| 3216 | turnId, | ||
| 3217 | itemId, | ||
| 3218 | payload: { | ||
| 3219 | itemType: "unknown", | ||
| 3220 | status: "inProgress", | ||
| 3221 | title: "Reading skill hermes-agent", | ||
| 3222 | detail: "Opening the skill instructions.", | ||
| 3223 | data: { unsafeRawPayload: "not projected" }, | ||
| 3224 | }, | ||
| 3225 | }); | ||
| 3226 | harness.emit({ | ||
| 3227 | type: "item.updated", | ||
| 3228 | eventId: asEventId("evt-generic-activity-updated"), | ||
| 3229 | provider: ProviderDriverKind.make("hermes"), | ||
| 3230 | createdAt: "2026-01-01T00:00:01.000Z", | ||
| 3231 | threadId: asThreadId("thread-1"), | ||
| 3232 | turnId, | ||
| 3233 | itemId, | ||
| 3234 | payload: { | ||
| 3235 | itemType: "unknown", | ||
| 3236 | status: "inProgress", | ||
| 3237 | title: "Reading skill hermes-agent", | ||
| 3238 | detail: "Parsing the workflow.", | ||
| 3239 | }, | ||
| 3240 | }); | ||
| 3241 | harness.emit({ | ||
| 3242 | type: "item.completed", | ||
| 3243 | eventId: asEventId("evt-generic-activity-completed"), | ||
| 3244 | provider: ProviderDriverKind.make("hermes"), | ||
| 3245 | createdAt: "2026-01-01T00:00:02.000Z", | ||
| 3246 | threadId: asThreadId("thread-1"), | ||
| 3247 | turnId, | ||
| 3248 | itemId, | ||
| 3249 | payload: { | ||
| 3250 | itemType: "unknown", | ||
| 3251 | status: "completed", | ||
| 3252 | title: "Read skill hermes-agent", | ||
| 3253 | detail: "Skill instructions loaded.", | ||
| 3254 | }, | ||
| 3255 | }); | ||
| 3256 | |||
| 3257 | const activityId = `provider:hermes:item:${itemId}`; | ||
| 3258 | const thread = await waitForThread(harness.readModel, (entry) => | ||
| 3259 | entry.activities.some( | ||
| 3260 | (activity: ProviderRuntimeTestActivity) => | ||
| 3261 | activity.id === activityId && | ||
| 3262 | activity.payload && | ||
| 3263 | typeof activity.payload === "object" && | ||
| 3264 | (activity.payload as Record<string, unknown>).status === "completed", | ||
| 3265 | ), | ||
| 3266 | ); | ||
| 3267 | const activities = thread.activities.filter((activity) => activity.id === activityId); | ||
| 3268 | const activity = activities[0]; | ||
| 3269 | const payload = | ||
| 3270 | activity?.payload && typeof activity.payload === "object" | ||
| 3271 | ? (activity.payload as Record<string, unknown>) | ||
| 3272 | : undefined; | ||
| 3273 | |||
| 3274 | expect(activities).toHaveLength(1); | ||
| 3275 | expect(activity?.kind).toBe("provider.activity"); | ||
| 3276 | expect(activity?.tone).toBe("info"); | ||
| 3277 | expect(activity?.summary).toBe("Read skill hermes-agent"); | ||
| 3278 | expect(payload).toMatchObject({ | ||
| 3279 | itemType: "unknown", | ||
| 3280 | status: "completed", | ||
| 3281 | providerItemId: itemId, | ||
| 3282 | title: "Read skill hermes-agent", | ||
| 3283 | detail: "Skill instructions loaded.", | ||
| 3284 | }); | ||
| 3285 | expect(payload?.data).toBeUndefined(); | ||
| 3286 | }); | ||
| 3287 | |||
| 2852 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { | 3288 | it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { |
| 2853 | const harness = await createHarness(); | 3289 | const harness = await createHarness(); |
| 2854 | const now = "2026-01-01T00:00:00.000Z"; | 3290 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | |||
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | type AssistantDeliveryMode, | 3 | type AssistantDeliveryMode, |
| 4 | CommandId, | 4 | CommandId, |
| 5 | EventId, | ||
| 5 | MessageId, | 6 | MessageId, |
| 6 | type OrchestrationEvent, | 7 | type OrchestrationEvent, |
| 7 | type OrchestrationMessage, | 8 | type OrchestrationMessage, |
| @@ -306,6 +307,44 @@ function requestKindFromCanonicalRequestType( · 298 unmodified lines | |||
| 306 | } | 307 | } |
| 307 | } | 308 | } |
| 308 | 309 | ||
| 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 | export function runtimeEventToActivities( | 348 | export function runtimeEventToActivities( |
| 310 | event: ProviderRuntimeEvent, | 349 | event: ProviderRuntimeEvent, |
| 311 | taskTitle?: string, | 350 | taskTitle?: string, |
| @@ -614,6 +653,9 @@ export function runtimeEventToActivities( · 302 unmodified lines | |||
| 614 | } | 653 | } |
| 615 | 654 | ||
| 616 | case "item.updated": { | 655 | case "item.updated": { |
| 656 | if (event.payload.itemType === "unknown") { | ||
| 657 | return [genericProviderActivity(event, maybeSequence)]; | ||
| 658 | } | ||
| 617 | if (!isToolLifecycleItemType(event.payload.itemType)) { | 659 | if (!isToolLifecycleItemType(event.payload.itemType)) { |
| 618 | return []; | 660 | return []; |
| 619 | } | 661 | } |
| @@ -637,6 +679,9 @@ export function runtimeEventToActivities( · 17 unmodified lines | |||
| 637 | } | 679 | } |
| 638 | 680 | ||
| 639 | case "item.completed": { | 681 | case "item.completed": { |
| 682 | if (event.payload.itemType === "unknown") { | ||
| 683 | return [genericProviderActivity(event, maybeSequence)]; | ||
| 684 | } | ||
| 640 | if (!isToolLifecycleItemType(event.payload.itemType)) { | 685 | if (!isToolLifecycleItemType(event.payload.itemType)) { |
| 641 | return []; | 686 | return []; |
| 642 | } | 687 | } |
| @@ -659,6 +704,9 @@ export function runtimeEventToActivities( · 16 unmodified lines | |||
| 659 | } | 704 | } |
| 660 | 705 | ||
| 661 | case "item.started": { | 706 | case "item.started": { |
| 707 | if (event.payload.itemType === "unknown") { | ||
| 708 | return [genericProviderActivity(event, maybeSequence)]; | ||
| 709 | } | ||
| 662 | if (!isToolLifecycleItemType(event.payload.itemType)) { | 710 | if (!isToolLifecycleItemType(event.payload.itemType)) { |
| 663 | return []; | 711 | return []; |
| 664 | } | 712 | } |
| @@ -899,6 +947,9 @@ const make = Effect.gen(function* () { · 234 unmodified lines | |||
| 899 | ), | 947 | ), |
| 900 | ); | 948 | ); |
| 901 | 949 | ||
| 950 | const replaceBufferedAssistantText = (messageId: MessageId, text: string) => | ||
| 951 | Cache.set(bufferedAssistantTextByMessageId, messageId, text); | ||
| 952 | |||
| 902 | const takeBufferedAssistantText = (messageId: MessageId) => | 953 | const takeBufferedAssistantText = (messageId: MessageId) => |
| 903 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( | 954 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| 904 | Effect.flatMap((existingText) => | 955 | Effect.flatMap((existingText) => |
| @@ -1463,6 +1514,10 @@ const make = Effect.gen(function* () { · 558 unmodified lines | |||
| 1463 | event.type === "content.delta" && event.payload.streamKind === "assistant_text" | 1514 | event.type === "content.delta" && event.payload.streamKind === "assistant_text" |
| 1464 | ? event.payload.delta | 1515 | ? event.payload.delta |
| 1465 | : undefined; | 1516 | : undefined; |
| 1517 | const assistantSnapshot = | ||
| 1518 | event.type === "content.snapshot" && event.payload.streamKind === "assistant_text" | ||
| 1519 | ? event.payload.text | ||
| 1520 | : undefined; | ||
| 1466 | const proposedPlanDelta = | 1521 | const proposedPlanDelta = |
| 1467 | event.type === "turn.proposed.delta" ? event.payload.delta : undefined; | 1522 | event.type === "turn.proposed.delta" ? event.payload.delta : undefined; |
| 1468 | 1523 | ||
| @@ -1507,6 +1562,53 @@ const make = Effect.gen(function* () { · 38 unmodified lines | |||
| 1507 | } | 1562 | } |
| 1508 | } | 1563 | } |
| 1509 | 1564 | ||
| 1565 | if (assistantSnapshot !== undefined) { | ||
| 1566 | const turnId = toTurnId(event.turnId); | ||
| 1567 | const assistantMessageId = yield* getOrCreateAssistantMessageId({ | ||
| 1568 | threadId: thread.id, | ||
| 1569 | event, | ||
| 1570 | ...(turnId ? { turnId } : {}), | ||
| 1571 | }); | ||
| 1572 | if (turnId) { | ||
| 1573 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); | ||
| 1574 | } | ||
| 1575 | |||
| 1576 | const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( | ||
| 1577 | serverSettingsService.getSettings, | ||
| 1578 | (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), | ||
| 1579 | ); | ||
| 1580 | if (assistantDeliveryMode === "buffered") { | ||
| 1581 | const detailedThread = yield* getLoadedThreadDetail(); | ||
| 1582 | const hasProjectedMessage = | ||
| 1583 | findMessageById(detailedThread?.messages ?? [], assistantMessageId) !== undefined; | ||
| 1584 | if (hasProjectedMessage) { | ||
| 1585 | yield* orchestrationEngine.dispatch({ | ||
| 1586 | type: "thread.message.assistant.replace", | ||
| 1587 | commandId: yield* providerCommandId(event, "assistant-snapshot-buffer-spill"), | ||
| 1588 | threadId: thread.id, | ||
| 1589 | messageId: assistantMessageId, | ||
| 1590 | text: assistantSnapshot, | ||
| 1591 | ...(turnId ? { turnId } : {}), | ||
| 1592 | createdAt: now, | ||
| 1593 | }); | ||
| 1594 | yield* clearBufferedAssistantText(assistantMessageId); | ||
| 1595 | } else { | ||
| 1596 | yield* replaceBufferedAssistantText(assistantMessageId, assistantSnapshot); | ||
| 1597 | } | ||
| 1598 | } else { | ||
| 1599 | yield* orchestrationEngine.dispatch({ | ||
| 1600 | type: "thread.message.assistant.replace", | ||
| 1601 | commandId: yield* providerCommandId(event, "assistant-snapshot"), | ||
| 1602 | threadId: thread.id, | ||
| 1603 | messageId: assistantMessageId, | ||
| 1604 | text: assistantSnapshot, | ||
| 1605 | ...(turnId ? { turnId } : {}), | ||
| 1606 | createdAt: now, | ||
| 1607 | }); | ||
| 1608 | yield* clearBufferedAssistantText(assistantMessageId); | ||
| 1609 | } | ||
| 1610 | } | ||
| 1611 | |||
| 1510 | const pauseForUserTurnId = | 1612 | const pauseForUserTurnId = |
| 1511 | event.type === "request.opened" || event.type === "user-input.requested" | 1613 | event.type === "request.opened" || event.type === "user-input.requested" |
| 1512 | ? toTurnId(event.turnId) | 1614 | ? toTurnId(event.turnId) |
| @@ -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 | case "thread.message.assistant.complete": { | 1049 | case "thread.message.assistant.complete": { |
| 1022 | yield* requireThread({ | 1050 | yield* requireThread({ |
| 1023 | readModel, | 1051 | readModel, |
| @@ -488,6 +488,83 @@ describe("orchestration projector", () => { · 487 unmodified lines | |||
| 488 | expect(message?.updatedAt).toBe(completeAt); | 488 | expect(message?.updatedAt).toBe(completeAt); |
| 489 | }); | 489 | }); |
| 490 | 490 | ||
| 491 | it("replaces an assistant snapshot between legacy appended deltas", async () => { | ||
| 492 | const createdAt = "2026-02-23T09:00:00.000Z"; | ||
| 493 | const model = createEmptyReadModel(createdAt); | ||
| 494 | const afterCreate = await Effect.runPromise( | ||
| 495 | projectEvent( | ||
| 496 | model, | ||
| 497 | makeEvent({ | ||
| 498 | sequence: 1, | ||
| 499 | type: "thread.created", | ||
| 500 | aggregateKind: "thread", | ||
| 501 | aggregateId: "thread-1", | ||
| 502 | occurredAt: createdAt, | ||
| 503 | commandId: "cmd-create", | ||
| 504 | payload: { | ||
| 505 | threadId: "thread-1", | ||
| 506 | projectId: "project-1", | ||
| 507 | title: "demo", | ||
| 508 | modelSelection: { | ||
| 509 | provider: ProviderDriverKind.make("hermes"), | ||
| 510 | model: "hermes", | ||
| 511 | }, | ||
| 512 | runtimeMode: "full-access", | ||
| 513 | branch: null, | ||
| 514 | worktreePath: null, | ||
| 515 | createdAt, | ||
| 516 | updatedAt: createdAt, | ||
| 517 | }, | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ); | ||
| 521 | |||
| 522 | const messageEvent = (input: { | ||
| 523 | sequence: number; | ||
| 524 | text: string; | ||
| 525 | streaming: boolean; | ||
| 526 | textOperation?: "replace"; | ||
| 527 | }) => | ||
| 528 | makeEvent({ | ||
| 529 | sequence: input.sequence, | ||
| 530 | type: "thread.message-sent", | ||
| 531 | aggregateKind: "thread", | ||
| 532 | aggregateId: "thread-1", | ||
| 533 | occurredAt: createdAt, | ||
| 534 | commandId: `cmd-message-${input.sequence}`, | ||
| 535 | payload: { | ||
| 536 | threadId: "thread-1", | ||
| 537 | messageId: "assistant:msg-replace", | ||
| 538 | role: "assistant", | ||
| 539 | text: input.text, | ||
| 540 | turnId: "turn-1", | ||
| 541 | streaming: input.streaming, | ||
| 542 | ...(input.textOperation !== undefined ? { textOperation: input.textOperation } : {}), | ||
| 543 | createdAt, | ||
| 544 | updatedAt: createdAt, | ||
| 545 | }, | ||
| 546 | }); | ||
| 547 | |||
| 548 | let projected = afterCreate; | ||
| 549 | for (const event of [ | ||
| 550 | messageEvent({ sequence: 2, text: "Hello wor", streaming: true }), | ||
| 551 | messageEvent({ | ||
| 552 | sequence: 3, | ||
| 553 | text: "Hello there", | ||
| 554 | streaming: true, | ||
| 555 | textOperation: "replace", | ||
| 556 | }), | ||
| 557 | messageEvent({ sequence: 4, text: "!", streaming: true }), | ||
| 558 | messageEvent({ sequence: 5, text: "", streaming: false }), | ||
| 559 | ]) { | ||
| 560 | projected = await Effect.runPromise(projectEvent(projected, event)); | ||
| 561 | } | ||
| 562 | |||
| 563 | const message = projected.threads[0]?.messages[0]; | ||
| 564 | expect(message?.text).toBe("Hello there!"); | ||
| 565 | expect(message?.streaming).toBe(false); | ||
| 566 | }); | ||
| 567 | |||
| 491 | it("prunes reverted turn messages from in-memory thread snapshot", async () => { | 568 | it("prunes reverted turn messages from in-memory thread snapshot", async () => { |
| 492 | const createdAt = "2026-02-23T10:00:00.000Z"; | 569 | const createdAt = "2026-02-23T10:00:00.000Z"; |
| 493 | const model = createEmptyReadModel(createdAt); | 570 | const model = createEmptyReadModel(createdAt); |
| @@ -471,11 +471,14 @@ export function projectEvent( · 470 unmodified lines | |||
| 471 | entry.id === message.id | 471 | entry.id === message.id |
| 472 | ? { | 472 | ? { |
| 473 | ...entry, | 473 | ...entry, |
| 474 | text: message.streaming | 474 | text: |
| 475 | ? `${entry.text}${message.text}` | 475 | payload.textOperation === "replace" |
| 476 | : message.text.length > 0 | ||
| 477 | ? message.text | 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 | streaming: message.streaming, | 482 | streaming: message.streaming, |
| 480 | updatedAt: message.updatedAt, | 483 | updatedAt: message.updatedAt, |
| 481 | turnId: message.turnId, | 484 | turnId: message.turnId, |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | HermesGatewaySessionId, | 6 | HermesGatewaySessionId, |
| 7 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 8 | ThreadId, | 8 | ThreadId, |
| 9 | TurnId, | ||
| 9 | type HermesGatewayT3ToPluginMessage, | 10 | type HermesGatewayT3ToPluginMessage, |
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| @@ -23,10 +24,9 @@ import { · 11 unmodified lines | |||
| 23 | makeHermesAdapter, | 24 | makeHermesAdapter, |
| 24 | sanitizeHermesItemData, | 25 | sanitizeHermesItemData, |
| 25 | sanitizeHermesRequestArgs, | 26 | sanitizeHermesRequestArgs, |
| 26 | shouldProjectHermesTurnStarted, | ||
| 27 | } from "./HermesAdapter.ts"; | 27 | } from "./HermesAdapter.ts"; |
| 28 | 28 | ||
| 29 | it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => { | 29 | it("sanitizes persisted gateway payloads", () => { |
| 30 | assert.deepEqual( | 30 | assert.deepEqual( |
| 31 | sanitizeHermesItemData("command_execution", { | 31 | sanitizeHermesItemData("command_execution", { |
| 32 | command: "git status", | 32 | command: "git status", |
| @@ -43,12 +43,6 @@ it("sanitizes persisted gateway payloads and suppresses steering acknowledgement · 10 unmodified lines | |||
| 43 | }), | 43 | }), |
| 44 | { command: "git push" }, | 44 | { command: "git push" }, |
| 45 | ); | 45 | ); |
| 46 | const steerRequestId = HermesGatewayRequestId.make("steer-request"); | ||
| 47 | const startRequestId = HermesGatewayRequestId.make("start-request"); | ||
| 48 | const pendingSteerRequestIds = new Set([steerRequestId]); | ||
| 49 | assert.isFalse(shouldProjectHermesTurnStarted(pendingSteerRequestIds, steerRequestId)); | ||
| 50 | assert.isTrue(shouldProjectHermesTurnStarted(pendingSteerRequestIds, startRequestId)); | ||
| 51 | assert.isFalse(pendingSteerRequestIds.has(steerRequestId)); | ||
| 52 | }); | 46 | }); |
| 53 | 47 | ||
| 54 | it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () => | 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 | Effect.provideService(HermesGatewayBroker, broker), | 95 | Effect.provideService(HermesGatewayBroker, broker), |
| 102 | ); | 96 | ); |
| 103 | yield* Effect.yieldNow; | 97 | yield* Effect.yieldNow; |
| 98 | const firstEventAfterOrphanExitFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | ||
| 99 | Effect.forkChild({ startImmediately: true }), | ||
| 100 | ); | ||
| 101 | yield* PubSub.publish(brokerEvents, { | ||
| 102 | instanceId, | ||
| 103 | message: { | ||
| 104 | type: "session.exited", | ||
| 105 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | ||
| 106 | threadId, | ||
| 107 | sessionId: HermesGatewaySessionId.make("session-before-context"), | ||
| 108 | recoverable: false, | ||
| 109 | }, | ||
| 110 | }); | ||
| 104 | const session = yield* adapter.startSession({ | 111 | const session = yield* adapter.startSession({ |
| 105 | threadId, | 112 | threadId, |
| 106 | providerInstanceId: instanceId, | 113 | providerInstanceId: instanceId, |
| @@ -135,13 +142,17 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 28 unmodified lines | |||
| 135 | }); | 142 | }); |
| 136 | const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber)); | 143 | const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber)); |
| 137 | assert.equal(startEvent?.type, "turn.started"); | 144 | assert.equal(startEvent?.type, "turn.started"); |
| 145 | const firstEventAfterOrphanExit = Option.getOrUndefined( | ||
| 146 | yield* Fiber.join(firstEventAfterOrphanExitFiber), | ||
| 147 | ); | ||
| 148 | assert.equal(firstEventAfterOrphanExit?.type, "session.started"); | ||
| 138 | 149 | ||
| 139 | yield* adapter.sendTurn({ threadId, input: "follow up" }); | 150 | yield* adapter.sendTurn({ threadId, input: "follow up" }); |
| 140 | const turnSteer = sent.find((message) => message.type === "turn.steer"); | 151 | const turnSteer = sent.find((message) => message.type === "turn.steer"); |
| 141 | if (!turnSteer || turnSteer.type !== "turn.steer") { | 152 | if (!turnSteer || turnSteer.type !== "turn.steer") { |
| 142 | return yield* Effect.die(new Error("turn.steer was not sent")); | 153 | return yield* Effect.die(new Error("turn.steer was not sent")); |
| 143 | } | 154 | } |
| 144 | const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | 155 | const steerEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( |
| 145 | Effect.forkChild({ startImmediately: true }), | 156 | Effect.forkChild({ startImmediately: true }), |
| 146 | ); | 157 | ); |
| 147 | yield* PubSub.publish(brokerEvents, { | 158 | yield* PubSub.publish(brokerEvents, { |
| @@ -155,6 +166,12 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 7 unmodified lines | |||
| 155 | turnId: turnSteer.turnId, | 166 | turnId: turnSteer.turnId, |
| 156 | }, | 167 | }, |
| 157 | }); | 168 | }); |
| 169 | const steerEvent = Option.getOrUndefined(yield* Fiber.join(steerEventFiber)); | ||
| 170 | assert.equal(steerEvent?.type, "turn.started"); | ||
| 171 | |||
| 172 | const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | ||
| 173 | Effect.forkChild({ startImmediately: true }), | ||
| 174 | ); | ||
| 158 | yield* PubSub.publish(brokerEvents, { | 175 | yield* PubSub.publish(brokerEvents, { |
| 159 | instanceId, | 176 | instanceId, |
| 160 | message: { | 177 | message: { |
| @@ -169,5 +186,190 @@ it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering · 8 unmodified lines | |||
| 169 | }); | 186 | }); |
| 170 | const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber)); | 187 | const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber)); |
| 171 | assert.equal(nextEvent?.type, "content.delta"); | 188 | assert.equal(nextEvent?.type, "content.delta"); |
| 189 | |||
| 190 | const newerTurnId = TurnId.make("newer-turn"); | ||
| 191 | const newerTurnFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | ||
| 192 | Effect.forkChild({ startImmediately: true }), | ||
| 193 | ); | ||
| 194 | yield* PubSub.publish(brokerEvents, { | ||
| 195 | instanceId, | ||
| 196 | message: { | ||
| 197 | type: "turn.started", | ||
| 198 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | ||
| 199 | requestId: HermesGatewayRequestId.make("newer-turn-request"), | ||
| 200 | threadId, | ||
| 201 | sessionId, | ||
| 202 | turnId: newerTurnId, | ||
| 203 | }, | ||
| 204 | }); | ||
| 205 | yield* Fiber.join(newerTurnFiber); | ||
| 206 | |||
| 207 | const afterStaleExitFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | ||
| 208 | Effect.forkChild({ startImmediately: true }), | ||
| 209 | ); | ||
| 210 | yield* PubSub.publish(brokerEvents, { | ||
| 211 | instanceId, | ||
| 212 | message: { | ||
| 213 | type: "session.exited", | ||
| 214 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | ||
| 215 | threadId, | ||
| 216 | sessionId: HermesGatewaySessionId.make("session-stale"), | ||
| 217 | recoverable: false, | ||
| 218 | }, | ||
| 219 | }); | ||
| 220 | yield* PubSub.publish(brokerEvents, { | ||
| 221 | instanceId, | ||
| 222 | message: { | ||
| 223 | type: "content.delta", | ||
| 224 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | ||
| 225 | threadId, | ||
| 226 | sessionId, | ||
| 227 | turnId: newerTurnId, | ||
| 228 | streamKind: "assistant_text", | ||
| 229 | delta: "still active", | ||
| 230 | }, | ||
| 231 | }); | ||
| 232 | const afterStaleExit = Option.getOrUndefined(yield* Fiber.join(afterStaleExitFiber)); | ||
| 233 | assert.equal(afterStaleExit?.type, "content.delta"); | ||
| 234 | const afterStaleExitSession = (yield* adapter.listSessions())[0]; | ||
| 235 | assert.equal(afterStaleExitSession?.status, "running"); | ||
| 236 | assert.equal(afterStaleExitSession?.activeTurnId, newerTurnId); | ||
| 237 | |||
| 238 | const staleCompletionFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | ||
| 239 | Effect.forkChild({ startImmediately: true }), | ||
| 240 | ); | ||
| 241 | yield* PubSub.publish(brokerEvents, { | ||
| 242 | instanceId, | ||
| 243 | message: { | ||
| 244 | type: "turn.completed", | ||
| 245 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | ||
| 246 | threadId, | ||
| 247 | sessionId, | ||
| 248 | turnId: turnStart.turnId, | ||
| 249 | state: "completed", | ||
| 250 | }, | ||
| 251 | }); | ||
| 252 | yield* Fiber.join(staleCompletionFiber); | ||
| 253 | |||
| 254 | const afterStaleCompletion = (yield* adapter.listSessions())[0]; | ||
| 255 | assert.equal(afterStaleCompletion?.status, "running"); | ||
| 256 | assert.equal(afterStaleCompletion?.activeTurnId, newerTurnId); | ||
| 257 | |||
| 258 | const activeCompletionFiber = yield* Stream.runHead(adapter.streamEvents).pipe( | ||
| 259 | Effect.forkChild({ startImmediately: true }), | ||
| 260 | ); | ||
| 261 | yield* PubSub.publish(brokerEvents, { | ||
| 262 | instanceId, | ||
| 263 | message: { | ||
| 264 | type: "turn.completed", | ||
| 265 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | ||
| 266 | threadId, | ||
| 267 | sessionId, | ||
| 268 | turnId: newerTurnId, | ||
| 269 | state: "completed", | ||
| 270 | }, | ||
| 271 | }); | ||
| 272 | yield* Fiber.join(activeCompletionFiber); | ||
| 273 | |||
| 274 | const afterActiveCompletion = (yield* adapter.listSessions())[0]; | ||
| 275 | assert.equal(afterActiveCompletion?.status, "ready"); | ||
| 276 | assert.equal(afterActiveCompletion?.activeTurnId, undefined); | ||
| 277 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 278 | ); | ||
| 279 | |||
| 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 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 374 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 173 | ); | 375 | ); |
| @@ -107,11 +107,6 @@ export const sanitizeHermesRequestArgs = ( · 106 unmodified lines | |||
| 107 | return Object.keys(sanitized).length > 0 ? sanitized : undefined; | 107 | return Object.keys(sanitized).length > 0 ? sanitized : undefined; |
| 108 | }; | 108 | }; |
| 109 | 109 | ||
| 110 | export const shouldProjectHermesTurnStarted = ( | ||
| 111 | pendingSteerRequestIds: Set<HermesGatewayRequestId>, | ||
| 112 | incomingRequestId: HermesGatewayRequestId, | ||
| 113 | ) => !pendingSteerRequestIds.delete(incomingRequestId); | ||
| 114 | |||
| 115 | export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: { | 110 | export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: { |
| 116 | readonly instanceId: ProviderInstanceId; | 111 | readonly instanceId: ProviderInstanceId; |
| 117 | }) { | 112 | }) { |
| @@ -119,7 +114,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 1 unmodified line | |||
| 119 | const broker = yield* HermesGatewayBroker; | 114 | const broker = yield* HermesGatewayBroker; |
| 120 | const events = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | 115 | const events = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 121 | const sessions = new Map<ThreadId, SessionContext>(); | 116 | const sessions = new Map<ThreadId, SessionContext>(); |
| 122 | const pendingSteerRequestIds = new Set<HermesGatewayRequestId>(); | ||
| 123 | 117 | ||
| 124 | const randomId = crypto.randomUUIDv4.pipe( | 118 | const randomId = crypto.randomUUIDv4.pipe( |
| 125 | Effect.mapError( | 119 | Effect.mapError( |
| @@ -188,10 +182,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 62 unmodified lines | |||
| 188 | case "session.ready": | 182 | case "session.ready": |
| 189 | return undefined; | 183 | return undefined; |
| 190 | case "turn.started": { | 184 | case "turn.started": { |
| 191 | const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted( | ||
| 192 | pendingSteerRequestIds, | ||
| 193 | message.requestId, | ||
| 194 | ); | ||
| 195 | if (context) { | 185 | if (context) { |
| 196 | updateSession(context, { | 186 | updateSession(context, { |
| 197 | status: "running", | 187 | status: "running", |
| @@ -201,7 +191,6 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 3 unmodified lines | |||
| 201 | context.turns.push({ id: TurnId.make(message.turnId), items: [] }); | 191 | context.turns.push({ id: TurnId.make(message.turnId), items: [] }); |
| 202 | } | 192 | } |
| 203 | } | 193 | } |
| 204 | if (isSteeringAcknowledgement) return undefined; | ||
| 205 | return { | 194 | return { |
| 206 | ...base, | 195 | ...base, |
| 207 | type: "turn.started", | 196 | type: "turn.started", |
| @@ -218,6 +207,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 10 unmodified lines | |||
| 218 | ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), | 207 | ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), |
| 219 | }, | 208 | }, |
| 220 | }; | 209 | }; |
| 210 | case "content.snapshot": | ||
| 211 | return { | ||
| 212 | ...base, | ||
| 213 | type: "content.snapshot", | ||
| 214 | payload: { | ||
| 215 | streamKind: message.streamKind, | ||
| 216 | text: message.text, | ||
| 217 | ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}), | ||
| 218 | }, | ||
| 219 | }; | ||
| 221 | case "item.started": | 220 | case "item.started": |
| 222 | case "item.updated": | 221 | case "item.updated": |
| 223 | case "item.completed": { | 222 | case "item.completed": { |
| @@ -267,7 +266,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 43 unmodified lines | |||
| 267 | payload: { answers: message.answers }, | 266 | payload: { answers: message.answers }, |
| 268 | }; | 267 | }; |
| 269 | case "turn.completed": | 268 | case "turn.completed": |
| 270 | if (context) { | 269 | if (context?.session.activeTurnId === message.turnId) { |
| 271 | updateSession(context, { status: message.state === "failed" ? "error" : "ready" }); | 270 | updateSession(context, { status: message.state === "failed" ? "error" : "ready" }); |
| 272 | const { activeTurnId: _activeTurnId, ...session } = context.session; | 271 | const { activeTurnId: _activeTurnId, ...session } = context.session; |
| 273 | context.session = session; | 272 | context.session = session; |
| @@ -282,7 +281,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 8 unmodified lines | |||
| 282 | }, | 281 | }, |
| 283 | }; | 282 | }; |
| 284 | case "turn.aborted": | 283 | case "turn.aborted": |
| 285 | if (context) { | 284 | if (context?.session.activeTurnId === message.turnId) { |
| 286 | const { activeTurnId: _activeTurnId, ...session } = context.session; | 285 | const { activeTurnId: _activeTurnId, ...session } = context.session; |
| 287 | context.session = { ...session, status: "ready", updatedAt: nowIso() }; | 286 | context.session = { ...session, status: "ready", updatedAt: nowIso() }; |
| 288 | } | 287 | } |
| @@ -292,7 +291,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 3 unmodified lines | |||
| 292 | payload: { reason: message.reason }, | 291 | payload: { reason: message.reason }, |
| 293 | }; | 292 | }; |
| 294 | case "session.exited": | 293 | case "session.exited": |
| 295 | if (context) updateSession(context, { status: message.recoverable ? "error" : "closed" }); | 294 | if (!context || context.hermesSessionId !== message.sessionId) { |
| 295 | return undefined; | ||
| 296 | } | ||
| 297 | const { activeTurnId: _activeTurnId, ...session } = context.session; | ||
| 298 | context.session = { | ||
| 299 | ...session, | ||
| 300 | status: message.recoverable ? "error" : "closed", | ||
| 301 | updatedAt: nowIso(), | ||
| 302 | }; | ||
| 296 | return { | 303 | return { |
| 297 | ...base, | 304 | ...base, |
| 298 | type: "session.exited", | 305 | type: "session.exited", |
| @@ -365,13 +372,15 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 66 unmodified lines | |||
| 365 | }); | 372 | }); |
| 366 | } | 373 | } |
| 367 | const createdAt = nowIso(); | 374 | const createdAt = nowIso(); |
| 375 | const activeTurnId = response.activeTurnId; | ||
| 368 | const session = { | 376 | const session = { |
| 369 | provider: PROVIDER, | 377 | provider: PROVIDER, |
| 370 | providerInstanceId: input.instanceId, | 378 | providerInstanceId: input.instanceId, |
| 371 | status: "ready", | 379 | status: activeTurnId === undefined ? "ready" : "running", |
| 372 | runtimeMode: sessionInput.runtimeMode, | 380 | runtimeMode: sessionInput.runtimeMode, |
| 373 | ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}), | 381 | ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}), |
| 374 | ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}), | 382 | ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}), |
| 383 | ...(activeTurnId !== undefined ? { activeTurnId } : {}), | ||
| 375 | threadId: sessionInput.threadId, | 384 | threadId: sessionInput.threadId, |
| 376 | resumeCursor: { | 385 | resumeCursor: { |
| 377 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | 386 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, |
| @@ -383,7 +392,7 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 5 unmodified lines | |||
| 383 | sessions.set(sessionInput.threadId, { | 392 | sessions.set(sessionInput.threadId, { |
| 384 | session, | 393 | session, |
| 385 | hermesSessionId: response.sessionId, | 394 | hermesSessionId: response.sessionId, |
| 386 | turns: [], | 395 | turns: activeTurnId === undefined ? [] : [{ id: activeTurnId, items: [] }], |
| 387 | }); | 396 | }); |
| 388 | yield* emit({ | 397 | yield* emit({ |
| 389 | ...(yield* eventBase({ threadId: sessionInput.threadId })), | 398 | ...(yield* eventBase({ threadId: sessionInput.threadId })), |
| @@ -422,22 +431,16 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input · 32 unmodified lines | |||
| 422 | const activeTurnId = context.session.activeTurnId; | 431 | const activeTurnId = context.session.activeTurnId; |
| 423 | const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`); | 432 | const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`); |
| 424 | const outboundRequestId = yield* requestId; | 433 | const outboundRequestId = yield* requestId; |
| 425 | if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId); | 434 | const response = yield* broker.request(input.instanceId, { |
| 426 | const response = yield* broker | 435 | type: activeTurnId ? "turn.steer" : "turn.start", |
| 427 | .request(input.instanceId, { | 436 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, |
| 428 | type: activeTurnId ? "turn.steer" : "turn.start", | 437 | requestId: outboundRequestId, |
| 429 | protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, | 438 | threadId: turnInput.threadId, |
| 430 | requestId: outboundRequestId, | 439 | sessionId: context.hermesSessionId, |
| 431 | threadId: turnInput.threadId, | 440 | turnId, |
| 432 | sessionId: context.hermesSessionId, | 441 | text, |
| 433 | turnId, | 442 | }); |
| 434 | text, | ||
| 435 | }) | ||
| 436 | .pipe( | ||
| 437 | Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))), | ||
| 438 | ); | ||
| 439 | if (response.type !== "turn.started") { | 443 | if (response.type !== "turn.started") { |
| 440 | pendingSteerRequestIds.delete(outboundRequestId); | ||
| 441 | return yield* new ProviderAdapterRequestError({ | 444 | return yield* new ProviderAdapterRequestError({ |
| 442 | provider: PROVIDER, | 445 | provider: PROVIDER, |
| 443 | method: activeTurnId ? "turn.steer" : "turn.start", | 446 | method: activeTurnId ? "turn.steer" : "turn.start", |
| @@ -87,7 +87,7 @@ const hello = ( · 86 unmodified lines | |||
| 87 | type: "connection.hello", | 87 | type: "connection.hello", |
| 88 | requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`), | 88 | requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`), |
| 89 | protocolVersion, | 89 | protocolVersion, |
| 90 | pluginVersion: "0.1.0", | 90 | pluginVersion: "0.2.0", |
| 91 | hermesVersion: "1.0.0", | 91 | hermesVersion: "1.0.0", |
| 92 | capabilities: { ...capabilities, protocolVersion }, | 92 | capabilities: { ...capabilities, protocolVersion }, |
| 93 | authentication, | 93 | authentication, |
| @@ -157,7 +157,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 63 unmodified lines | |||
| 157 | instanceId, | 157 | instanceId, |
| 158 | credential: HermesGatewayCredential.make("not-the-real-credential"), | 158 | credential: HermesGatewayCredential.make("not-the-real-credential"), |
| 159 | }, | 159 | }, |
| 160 | 2, | 160 | 3, |
| 161 | ), | 161 | ), |
| 162 | secondTransport, | 162 | secondTransport, |
| 163 | ), | 163 | ), |
| @@ -185,7 +185,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 21 unmodified lines | |||
| 185 | instanceId, | 185 | instanceId, |
| 186 | credential: first.accepted.credential!, | 186 | credential: first.accepted.credential!, |
| 187 | }, | 187 | }, |
| 188 | 2, | 188 | 3, |
| 189 | ), | 189 | ), |
| 190 | secondTransport, | 190 | secondTransport, |
| 191 | ), | 191 | ), |
| @@ -201,7 +201,7 @@ it.effect("authenticates before applying incompatible connection state", () => · 9 unmodified lines | |||
| 201 | }); | 201 | }); |
| 202 | const incompatibleEnrollment = yield* Effect.flip( | 202 | const incompatibleEnrollment = yield* Effect.flip( |
| 203 | broker.registerConnection( | 203 | broker.registerConnection( |
| 204 | hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2), | 204 | hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 3), |
| 205 | secondTransport, | 205 | secondTransport, |
| 206 | ), | 206 | ), |
| 207 | ); | 207 | ); |
| @@ -1280,6 +1280,101 @@ routing.layer("ProviderServiceLive routing", (it) => { · 1279 unmodified lines | |||
| 1280 | assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); | 1280 | assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); |
| 1281 | } | 1281 | } |
| 1282 | } | 1282 | } |
| 1283 | |||
| 1284 | yield* advanceTestClock(50); | ||
| 1285 | routing.codex.emit({ | ||
| 1286 | type: "turn.completed", | ||
| 1287 | eventId: asEventId("evt-runtime-status-stale-completion"), | ||
| 1288 | provider: ProviderDriverKind.make("codex"), | ||
| 1289 | createdAt: "2026-01-01T00:00:01.000Z", | ||
| 1290 | threadId: session.threadId, | ||
| 1291 | turnId: asTurnId("turn-from-an-older-request"), | ||
| 1292 | payload: { | ||
| 1293 | state: "completed", | ||
| 1294 | }, | ||
| 1295 | }); | ||
| 1296 | yield* advanceTestClock(50); | ||
| 1297 | |||
| 1298 | const afterStaleCompletion = yield* runtimeRepository.getByThreadId({ | ||
| 1299 | threadId: session.threadId, | ||
| 1300 | }); | ||
| 1301 | assert.equal(Option.isSome(afterStaleCompletion), true); | ||
| 1302 | if (Option.isSome(afterStaleCompletion)) { | ||
| 1303 | assert.equal(afterStaleCompletion.value.status, "running"); | ||
| 1304 | const payload = afterStaleCompletion.value.runtimePayload; | ||
| 1305 | assert.equal(payload !== null && typeof payload === "object", true); | ||
| 1306 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { | ||
| 1307 | assert.equal( | ||
| 1308 | "activeTurnId" in payload ? payload.activeTurnId : undefined, | ||
| 1309 | `turn-${String(session.threadId)}`, | ||
| 1310 | ); | ||
| 1311 | assert.equal( | ||
| 1312 | "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined, | ||
| 1313 | "turn.completed", | ||
| 1314 | ); | ||
| 1315 | } | ||
| 1316 | } | ||
| 1317 | |||
| 1318 | routing.codex.emit({ | ||
| 1319 | type: "turn.completed", | ||
| 1320 | eventId: asEventId("evt-runtime-status-active-completion"), | ||
| 1321 | provider: ProviderDriverKind.make("codex"), | ||
| 1322 | createdAt: "2026-01-01T00:00:02.000Z", | ||
| 1323 | threadId: session.threadId, | ||
| 1324 | turnId: asTurnId(`turn-${String(session.threadId)}`), | ||
| 1325 | payload: { | ||
| 1326 | state: "completed", | ||
| 1327 | }, | ||
| 1328 | }); | ||
| 1329 | yield* advanceTestClock(50); | ||
| 1330 | |||
| 1331 | const afterActiveCompletion = yield* runtimeRepository.getByThreadId({ | ||
| 1332 | threadId: session.threadId, | ||
| 1333 | }); | ||
| 1334 | assert.equal(Option.isSome(afterActiveCompletion), true); | ||
| 1335 | if (Option.isSome(afterActiveCompletion)) { | ||
| 1336 | assert.equal(afterActiveCompletion.value.status, "running"); | ||
| 1337 | const payload = afterActiveCompletion.value.runtimePayload; | ||
| 1338 | assert.equal(payload !== null && typeof payload === "object", true); | ||
| 1339 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { | ||
| 1340 | assert.equal("activeTurnId" in payload ? payload.activeTurnId : undefined, null); | ||
| 1341 | assert.equal("cwd" in payload ? payload.cwd : undefined, session.cwd); | ||
| 1342 | assert.equal( | ||
| 1343 | "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined, | ||
| 1344 | "turn.completed", | ||
| 1345 | ); | ||
| 1346 | } | ||
| 1347 | } | ||
| 1348 | |||
| 1349 | routing.codex.emit({ | ||
| 1350 | type: "session.exited", | ||
| 1351 | eventId: asEventId("evt-runtime-status-session-exit"), | ||
| 1352 | provider: ProviderDriverKind.make("codex"), | ||
| 1353 | createdAt: "2026-01-01T00:00:03.000Z", | ||
| 1354 | threadId: session.threadId, | ||
| 1355 | payload: { | ||
| 1356 | exitKind: "error", | ||
| 1357 | recoverable: true, | ||
| 1358 | }, | ||
| 1359 | }); | ||
| 1360 | yield* advanceTestClock(50); | ||
| 1361 | |||
| 1362 | const afterSessionExit = yield* runtimeRepository.getByThreadId({ | ||
| 1363 | threadId: session.threadId, | ||
| 1364 | }); | ||
| 1365 | assert.equal(Option.isSome(afterSessionExit), true); | ||
| 1366 | if (Option.isSome(afterSessionExit)) { | ||
| 1367 | assert.equal(afterSessionExit.value.status, "error"); | ||
| 1368 | const payload = afterSessionExit.value.runtimePayload; | ||
| 1369 | assert.equal(payload !== null && typeof payload === "object", true); | ||
| 1370 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { | ||
| 1371 | assert.equal("activeTurnId" in payload ? payload.activeTurnId : undefined, null); | ||
| 1372 | assert.equal( | ||
| 1373 | "lastRuntimeEvent" in payload ? payload.lastRuntimeEvent : undefined, | ||
| 1374 | "session.exited", | ||
| 1375 | ); | ||
| 1376 | } | ||
| 1377 | } | ||
| 1283 | }), | 1378 | }), |
| 1284 | ); | 1379 | ); |
| 1285 | 1380 | ||
| @@ -162,6 +162,19 @@ function readPersistedCwd( · 161 unmodified lines | |||
| 162 | return trimmed.length > 0 ? trimmed : undefined; | 162 | return trimmed.length > 0 ? trimmed : undefined; |
| 163 | } | 163 | } |
| 164 | 164 | ||
| 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 | const dieOnMissingBindingInstanceId = ( | 178 | const dieOnMissingBindingInstanceId = ( |
| 166 | operation: string, | 179 | operation: string, |
| 167 | payload: { | 180 | payload: { |
| @@ -281,6 +294,70 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 113 unmodified lines | |||
| 281 | }); | 294 | }); |
| 282 | }); | 295 | }); |
| 283 | 296 | ||
| 297 | const persistRuntimeLifecycle = (event: ProviderRuntimeEvent) => | ||
| 298 | Effect.gen(function* () { | ||
| 299 | if ( | ||
| 300 | event.type !== "turn.started" && | ||
| 301 | event.type !== "turn.completed" && | ||
| 302 | event.type !== "turn.aborted" && | ||
| 303 | event.type !== "session.exited" | ||
| 304 | ) { | ||
| 305 | return; | ||
| 306 | } | ||
| 307 | |||
| 308 | const binding = Option.getOrUndefined(yield* directory.getBinding(event.threadId)); | ||
| 309 | if (!binding || event.providerInstanceId === undefined) { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | if ( | ||
| 313 | binding.provider !== event.provider || | ||
| 314 | binding.providerInstanceId !== event.providerInstanceId | ||
| 315 | ) { | ||
| 316 | yield* Effect.logWarning("provider.session.runtime.lifecycle-binding-mismatch", { | ||
| 317 | threadId: event.threadId, | ||
| 318 | eventProvider: event.provider, | ||
| 319 | eventProviderInstanceId: event.providerInstanceId, | ||
| 320 | bindingProvider: binding.provider, | ||
| 321 | bindingProviderInstanceId: binding.providerInstanceId, | ||
| 322 | eventType: event.type, | ||
| 323 | }); | ||
| 324 | return; | ||
| 325 | } | ||
| 326 | |||
| 327 | const persistedActiveTurnId = readPersistedActiveTurnId(binding.runtimePayload); | ||
| 328 | const terminalMatchesActiveTurn = | ||
| 329 | (event.type === "turn.completed" || event.type === "turn.aborted") && | ||
| 330 | event.turnId !== undefined && | ||
| 331 | persistedActiveTurnId === event.turnId; | ||
| 332 | const isSessionExit = event.type === "session.exited"; | ||
| 333 | const status = | ||
| 334 | event.type === "turn.started" || terminalMatchesActiveTurn | ||
| 335 | ? "running" | ||
| 336 | : isSessionExit | ||
| 337 | ? event.payload.recoverable === true || event.payload.exitKind === "error" | ||
| 338 | ? "error" | ||
| 339 | : "stopped" | ||
| 340 | : undefined; | ||
| 341 | const activeTurnId = | ||
| 342 | event.type === "turn.started" | ||
| 343 | ? (event.turnId ?? persistedActiveTurnId) | ||
| 344 | : terminalMatchesActiveTurn || isSessionExit | ||
| 345 | ? null | ||
| 346 | : persistedActiveTurnId; | ||
| 347 | |||
| 348 | yield* directory.upsert({ | ||
| 349 | threadId: event.threadId, | ||
| 350 | provider: event.provider, | ||
| 351 | providerInstanceId: event.providerInstanceId, | ||
| 352 | ...(status !== undefined ? { status } : {}), | ||
| 353 | runtimePayload: { | ||
| 354 | ...(activeTurnId !== undefined ? { activeTurnId } : {}), | ||
| 355 | lastRuntimeEvent: event.type, | ||
| 356 | lastRuntimeEventAt: event.createdAt, | ||
| 357 | }, | ||
| 358 | }); | ||
| 359 | }); | ||
| 360 | |||
| 284 | const processRuntimeEvent = ( | 361 | const processRuntimeEvent = ( |
| 285 | source: { | 362 | source: { |
| 286 | readonly instanceId: ProviderInstanceId; | 363 | readonly instanceId: ProviderInstanceId; |
| @@ -293,7 +370,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 6 unmodified lines | |||
| 293 | increment(providerRuntimeEventsTotal, { | 370 | increment(providerRuntimeEventsTotal, { |
| 294 | provider: canonicalEvent.provider, | 371 | provider: canonicalEvent.provider, |
| 295 | eventType: canonicalEvent.type, | 372 | eventType: canonicalEvent.type, |
| 296 | }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), | 373 | }).pipe( |
| 374 | Effect.andThen( | ||
| 375 | persistRuntimeLifecycle(canonicalEvent).pipe( | ||
| 376 | Effect.catchCause((cause) => | ||
| 377 | Effect.logWarning("provider.session.runtime.lifecycle-persist-failed", { | ||
| 378 | threadId: canonicalEvent.threadId, | ||
| 379 | provider: canonicalEvent.provider, | ||
| 380 | providerInstanceId: canonicalEvent.providerInstanceId, | ||
| 381 | eventType: canonicalEvent.type, | ||
| 382 | errorTag: causeErrorTag(cause), | ||
| 383 | }), | ||
| 384 | ), | ||
| 385 | ), | ||
| 386 | ), | ||
| 387 | Effect.andThen(publishRuntimeEvent(canonicalEvent)), | ||
| 388 | ), | ||
| 297 | ), | 389 | ), |
| 298 | ); | 390 | ); |
| 299 | 391 | ||
| @@ -4,11 +4,15 @@ The plugin deliberately uses only the public Hermes plugin and platform-adapter · 3 unmodified lines | |||
| 4 | surfaces audited at Hermes Agent commit | 4 | surfaces audited at Hermes Agent commit |
| 5 | `3910ab28c0892fcf846fc61318d2fd15689eddf1`. | 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 | | Hermes surface | T3 gateway event | | 13 | | Hermes surface | T3 gateway event | |
| 10 | | ----------------------------------------- | ---------------------------------------------- | | 14 | | ----------------------------------------- | ---------------------------------------------- | |
| 11 | | Cumulative `send` / `edit_message` output | `content.delta` | | 15 | | Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` | |
| 12 | | Final stream edit | `item.completed`, `turn.completed` | | 16 | | Final stream edit | `item.completed`, `turn.completed` | |
| 13 | | `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | | 17 | | `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | |
| 14 | | Live adapter status text | Generic `unknown` activity item | | 18 | | Live adapter status text | Generic `unknown` activity item | |
| @@ -20,14 +24,16 @@ surfaces audited at Hermes Agent commit · 5 unmodified lines | |||
| 20 | ## Known limitations | 24 | ## Known limitations |
| 21 | 25 | ||
| 22 | - The platform adapter receives cumulative rendered text, not the underlying | 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 | plan, and command-output stream categories are not publicly exposed here. | 28 | plan, and command-output stream categories are not publicly exposed here. |
| 25 | - The delta contract has no replacement operation. Rare edits that revise | 29 | - Prefix-extending cumulative edits emit `content.delta`; edits that revise or |
| 26 | already-emitted text become a meaningful generic activity instead of | 30 | clear already-emitted text emit an authoritative `content.snapshot`. |
| 27 | corrupting the T3 transcript. | 31 | - Hermes' exact first-chat T3 home-channel notice is suppressed at the adapter |
| 32 | output boundary. The plugin does not assign a home channel or redirect | ||
| 33 | proactive delivery; other Hermes platform notices pass through unchanged. | ||
| 28 | - Hermes' documented tool hook surface exposes a `task_id`, tool name, | 34 | - Hermes' documented tool hook surface exposes a `task_id`, tool name, |
| 29 | arguments, string result, and duration. The audited runtime additionally | 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 | back to the documented IDs for older versions. It projects only canonical, | 37 | back to the documented IDs for older versions. It projects only canonical, |
| 32 | whitelisted fields (command/cwd, file path, search query, image path, or MCP | 38 | whitelisted fields (command/cwd, file path, search query, image path, or MCP |
| 33 | server/operation); arbitrary arguments and raw results never cross the wire. | 39 | server/operation); arbitrary arguments and raw results never cross the wire. |
| @@ -45,4 +51,4 @@ surfaces audited at Hermes Agent commit · 11 unmodified lines | |||
| 45 | Hermes success response begins with `⏩ Steer queued`. Unknown future response | 51 | Hermes success response begins with `⏩ Steer queued`. Unknown future response |
| 46 | shapes fail closed with `protocol.error` rather than completing the turn. | 52 | shapes fail closed with `protocol.error` rather than completing the turn. |
| 47 | - Attachments are not accepted. They are the first planned post-stability | 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 | process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes | 4 | process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes |
| 5 | does not need to listen on a public port. | 5 | does not need to listen on a public port. |
| 6 | 6 | ||
| 7 | The gateway wire protocol is v2. The T3 server and Hermes plugin must be updated | ||
| 8 | together; mismatched versions fail the connection handshake closed. | ||
| 9 | |||
| 7 | Each T3 thread maps deterministically to one Hermes gateway session. A new T3 | 10 | Each T3 thread maps deterministically to one Hermes gateway session. A new T3 |
| 8 | thread creates a new session identity, later messages use the same identity, | 11 | thread creates a new session identity, later messages use the same identity, |
| 9 | active-turn messages use Hermes' native `/steer` path, and stopping a turn does | 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 | enrollment. `hermes t3 status` reports the local enrollment without revealing | 46 | enrollment. `hermes t3 status` reports the local enrollment without revealing |
| 44 | the credential. | 47 | the credential. |
| 45 | 48 | ||
| 46 | ## V1 scope | 49 | ## Initial scope |
| 47 | 50 | ||
| 48 | - Text input and live assistant streaming | 51 | - Text input and live assistant streaming |
| 52 | - Authoritative text replacement when Hermes revises cumulative streamed output | ||
| 49 | - Multiple concurrent Hermes sessions in one process | 53 | - Multiple concurrent Hermes sessions in one process |
| 50 | - Active-turn steering and interrupt | 54 | - Active-turn steering and interrupt |
| 51 | - Dangerous-command approvals | 55 | - Dangerous-command approvals |
| @@ -54,6 +58,10 @@ the credential. · 2 unmodified lines | |||
| 54 | - Reconnect with bounded backoff | 58 | - Reconnect with bounded backoff |
| 55 | - Version-incompatible and revoked credentials fail closed | 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 | Attachments intentionally advertise `false`. Adding bounded image/file input is | 65 | Attachments intentionally advertise `false`. Adding bounded image/file input is |
| 58 | the first post-stability feature and should not reuse arbitrary raw payloads. | 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 | logger = logging.getLogger(__name__) | 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 | def _hermes_version() -> str: | 46 | def _hermes_version() -> str: |
| 40 | try: | 47 | try: |
| @@ -163,6 +170,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 122 unmodified lines | |||
| 163 | if turn is None: | 170 | if turn is None: |
| 164 | return SendResult(success=False, error="no active T3 turn") | 171 | return SendResult(success=False, error="no active T3 turn") |
| 165 | try: | 172 | try: |
| 173 | if content == _T3_HOME_CHANNEL_NOTICE: | ||
| 174 | if bool((metadata or {}).get("notify")): | ||
| 175 | await self._complete_turn(turn) | ||
| 176 | return SendResult(success=True, message_id=turn.message_id) | ||
| 166 | await self._emit_assistant_content(turn, content) | 177 | await self._emit_assistant_content(turn, content) |
| 167 | if bool((metadata or {}).get("notify")): | 178 | if bool((metadata or {}).get("notify")): |
| 168 | await self._complete_turn(turn) | 179 | await self._complete_turn(turn) |
| @@ -187,6 +198,10 @@ class T3PlatformAdapter(BasePlatformAdapter): · 18 unmodified lines | |||
| 187 | if turn is None: | 198 | if turn is None: |
| 188 | return SendResult(success=False, error="no active T3 turn") | 199 | return SendResult(success=False, error="no active T3 turn") |
| 189 | try: | 200 | try: |
| 201 | if content == _T3_HOME_CHANNEL_NOTICE: | ||
| 202 | if finalize: | ||
| 203 | await self._complete_turn(turn) | ||
| 204 | return SendResult(success=True, message_id=message_id) | ||
| 190 | await self._emit_assistant_content(turn, content) | 205 | await self._emit_assistant_content(turn, content) |
| 191 | if finalize: | 206 | if finalize: |
| 192 | await self._complete_turn(turn) | 207 | await self._complete_turn(turn) |
| @@ -343,6 +358,7 @@ class T3PlatformAdapter(BasePlatformAdapter): · 150 unmodified lines | |||
| 343 | self._sessions[thread_id] = session_id | 358 | self._sessions[thread_id] = session_id |
| 344 | self._active_session_threads.add(thread_id) | 359 | self._active_session_threads.add(thread_id) |
| 345 | self._thread_by_session[session_id] = thread_id | 360 | self._thread_by_session[session_id] = thread_id |
| 361 | active_turn = self._active_turns.get(thread_id) | ||
| 346 | await self._send_frame( | 362 | await self._send_frame( |
| 347 | frame( | 363 | frame( |
| 348 | "session.ready", | 364 | "session.ready", |
| @@ -350,6 +366,11 @@ class T3PlatformAdapter(BasePlatformAdapter): · 1 unmodified line | |||
| 350 | threadId=thread_id, | 366 | threadId=thread_id, |
| 351 | sessionId=session_id, | 367 | sessionId=session_id, |
| 352 | resumed=bool(resume_id and resume_id == session_id), | 368 | resumed=bool(resume_id and resume_id == session_id), |
| 369 | **( | ||
| 370 | {"activeTurnId": active_turn.turn_id} | ||
| 371 | if active_turn is not None | ||
| 372 | else {} | ||
| 373 | ), | ||
| 353 | ) | 374 | ) |
| 354 | ) | 375 | ) |
| 355 | await self._send_status() | 376 | await self._send_status() |
| @@ -647,7 +668,6 @@ class T3PlatformAdapter(BasePlatformAdapter): · 291 unmodified lines | |||
| 647 | async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: | 668 | async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: |
| 648 | visible = str(content or "").replace(" ▉", "").replace("▉", "") | 669 | visible = str(content or "").replace(" ▉", "").replace("▉", "") |
| 649 | if not turn.assistant_started: | 670 | if not turn.assistant_started: |
| 650 | turn.assistant_started = True | ||
| 651 | await self._send_frame( | 671 | await self._send_frame( |
| 652 | frame( | 672 | frame( |
| 653 | "item.started", | 673 | "item.started", |
| @@ -660,9 +680,9 @@ class T3PlatformAdapter(BasePlatformAdapter): · 6 unmodified lines | |||
| 660 | title="Hermes response", | 680 | title="Hermes response", |
| 661 | ) | 681 | ) |
| 662 | ) | 682 | ) |
| 683 | turn.assistant_started = True | ||
| 663 | if visible.startswith(turn.visible_text): | 684 | if visible.startswith(turn.visible_text): |
| 664 | delta = visible[len(turn.visible_text) :] | 685 | delta = visible[len(turn.visible_text) :] |
| 665 | turn.visible_text = visible | ||
| 666 | if delta: | 686 | if delta: |
| 667 | await self._send_frame( | 687 | await self._send_frame( |
| 668 | frame( | 688 | frame( |
| @@ -676,13 +696,21 @@ class T3PlatformAdapter(BasePlatformAdapter): · 7 unmodified lines | |||
| 676 | contentIndex=0, | 696 | contentIndex=0, |
| 677 | ) | 697 | ) |
| 678 | ) | 698 | ) |
| 699 | turn.visible_text = visible | ||
| 679 | elif visible != turn.visible_text: | 700 | elif visible != turn.visible_text: |
| 680 | # Hermes' public platform edit hook exposes cumulative rendered text, | 701 | await self._send_frame( |
| 681 | # but the v1 T3 delta contract has no replacement operation. Preserve | 702 | frame( |
| 682 | # the valid prefix and report the rare rewrite as generic activity. | 703 | "content.snapshot", |
| 683 | await self._emit_generic_activity( | 704 | threadId=turn.thread_id, |
| 684 | turn, "Hermes revised already-streamed text; replacement is deferred." | 705 | sessionId=turn.session_id, |
| 706 | turnId=turn.turn_id, | ||
| 707 | itemId=turn.message_id, | ||
| 708 | streamKind="assistant_text", | ||
| 709 | text=visible, | ||
| 710 | contentIndex=0, | ||
| 711 | ) | ||
| 685 | ) | 712 | ) |
| 713 | turn.visible_text = visible | ||
| 686 | 714 | ||
| 687 | async def _complete_turn(self, turn: _TurnState) -> None: | 715 | async def _complete_turn(self, turn: _TurnState) -> None: |
| 688 | if self._active_turns.get(turn.thread_id) is not turn: | 716 | if self._active_turns.get(turn.thread_id) is not turn: |
| @@ -737,9 +765,9 @@ class T3PlatformAdapter(BasePlatformAdapter): · 48 unmodified lines | |||
| 737 | normalized_detail = str(detail)[:2_000] | 765 | normalized_detail = str(detail)[:2_000] |
| 738 | if turn.generic_activity_detail == normalized_detail: | 766 | if turn.generic_activity_detail == normalized_detail: |
| 739 | return | 767 | return |
| 740 | turn.generic_activity_detail = normalized_detail | 768 | activity_id = turn.generic_activity_id |
| 741 | if turn.generic_activity_id is None: | 769 | if activity_id is None: |
| 742 | turn.generic_activity_id = item_id() | 770 | activity_id = item_id() |
| 743 | event_type = "item.started" | 771 | event_type = "item.started" |
| 744 | else: | 772 | else: |
| 745 | event_type = "item.updated" | 773 | event_type = "item.updated" |
| @@ -749,13 +777,15 @@ class T3PlatformAdapter(BasePlatformAdapter): · 3 unmodified lines | |||
| 749 | threadId=turn.thread_id, | 777 | threadId=turn.thread_id, |
| 750 | sessionId=turn.session_id, | 778 | sessionId=turn.session_id, |
| 751 | turnId=turn.turn_id, | 779 | turnId=turn.turn_id, |
| 752 | itemId=turn.generic_activity_id, | 780 | itemId=activity_id, |
| 753 | itemType="unknown", | 781 | itemType="unknown", |
| 754 | status="inProgress", | 782 | status="inProgress", |
| 755 | title="Hermes activity", | 783 | title="Hermes activity", |
| 756 | detail=normalized_detail, | 784 | detail=normalized_detail, |
| 757 | ) | 785 | ) |
| 758 | ) | 786 | ) |
| 787 | turn.generic_activity_id = activity_id | ||
| 788 | turn.generic_activity_detail = normalized_detail | ||
| 759 | 789 | ||
| 760 | def emit_tool_started( | 790 | def emit_tool_started( |
| 761 | self, | 791 | self, |
| @@ -10,7 +10,7 @@ from contextlib import suppress · 9 unmodified lines | |||
| 10 | from typing import Any | 10 | from typing import Any |
| 11 | from urllib.parse import urlsplit, urlunsplit | 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 | logger = logging.getLogger(__name__) | 15 | logger = logging.getLogger(__name__) |
| 16 | 16 | ||
| @@ -93,7 +93,7 @@ async def authenticate_socket( · 76 unmodified lines | |||
| 93 | raise RuntimeError( | 93 | raise RuntimeError( |
| 94 | f"expected connection.accepted, received {message.get('type')!r}" | 94 | f"expected connection.accepted, received {message.get('type')!r}" |
| 95 | ) | 95 | ) |
| 96 | if message.get("protocolVersion") != 1: | 96 | if message.get("protocolVersion") != PROTOCOL_VERSION: |
| 97 | raise RuntimeError("T3 accepted the connection with an incompatible version") | 97 | raise RuntimeError("T3 accepted the connection with an incompatible version") |
| 98 | return message | 98 | return message |
| 99 | 99 | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | name: hermes-t3-gateway | 1 | name: hermes-t3-gateway |
| 2 | label: T3 Code | 2 | label: T3 Code |
| 3 | kind: platform | 3 | kind: platform |
| 4 | version: 0.1.0 | 4 | version: 0.2.0 |
| 5 | description: > | 5 | description: > |
| 6 | Experimental outbound gateway that exposes one Hermes Agent process as a | 6 | Experimental outbound gateway that exposes one Hermes Agent process as a |
| 7 | named T3 Code provider instance over an authenticated WebSocket. | 7 | named T3 Code provider instance over an authenticated WebSocket. |
| @@ -6,8 +6,8 @@ import uuid · 5 unmodified lines | |||
| 6 | from datetime import datetime, timezone | 6 | from datetime import datetime, timezone |
| 7 | from typing import Any | 7 | from typing import Any |
| 8 | 8 | ||
| 9 | PROTOCOL_VERSION = 1 | 9 | PROTOCOL_VERSION = 2 |
| 10 | PLUGIN_VERSION = "0.1.0" | 10 | PLUGIN_VERSION = "0.2.0" |
| 11 | WEBSOCKET_PATH = "/api/hermes-gateway/ws" | 11 | WEBSOCKET_PATH = "/api/hermes-gateway/ws" |
| 12 | 12 | ||
| 13 | CAPABILITIES = { | 13 | CAPABILITIES = { |
| @@ -176,11 +176,34 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 175 unmodified lines | |||
| 176 | self.connection = FakeConnection() | 176 | self.connection = FakeConnection() |
| 177 | self.adapter._connection = self.connection | 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 | async def test_thread_ensure_start_stream_and_complete(self): | 202 | async def test_thread_ensure_start_stream_and_complete(self): |
| 180 | await self.adapter._handle_server_frame( | 203 | await self.adapter._handle_server_frame( |
| 181 | { | 204 | { |
| 182 | "type": "session.ensure", | 205 | "type": "session.ensure", |
| 183 | "protocolVersion": 1, | 206 | "protocolVersion": 2, |
| 184 | "requestId": "ensure-1", | 207 | "requestId": "ensure-1", |
| 185 | "threadId": "thread-1", | 208 | "threadId": "thread-1", |
| 186 | } | 209 | } |
| @@ -193,7 +216,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 6 unmodified lines | |||
| 193 | await self.adapter._handle_server_frame( | 216 | await self.adapter._handle_server_frame( |
| 194 | { | 217 | { |
| 195 | "type": "turn.start", | 218 | "type": "turn.start", |
| 196 | "protocolVersion": 1, | 219 | "protocolVersion": 2, |
| 197 | "requestId": "start-1", | 220 | "requestId": "start-1", |
| 198 | "threadId": "thread-1", | 221 | "threadId": "thread-1", |
| 199 | "sessionId": ready["sessionId"], | 222 | "sessionId": ready["sessionId"], |
| @@ -216,11 +239,305 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 16 unmodified lines | |||
| 216 | ] | 239 | ] |
| 217 | self.assertEqual(deltas, ["Hello", " world"]) | 240 | self.assertEqual(deltas, ["Hello", " world"]) |
| 218 | 241 | ||
| 242 | async def test_cumulative_edits_emit_delta_snapshot_delta_then_finalize(self): | ||
| 243 | await self._start_turn("thread-snapshot", "turn-snapshot") | ||
| 244 | content_start = len(self.connection.messages) | ||
| 245 | |||
| 246 | await self.adapter.send("thread-snapshot", "Hello") | ||
| 247 | duplicate_start = len(self.connection.messages) | ||
| 248 | await self.adapter.edit_message("thread-snapshot", "message", "Hello") | ||
| 249 | self.assertEqual(len(self.connection.messages), duplicate_start) | ||
| 250 | |||
| 251 | await self.adapter.edit_message("thread-snapshot", "message", "Help") | ||
| 252 | snapshot_duplicate_start = len(self.connection.messages) | ||
| 253 | await self.adapter.edit_message("thread-snapshot", "message", "Help") | ||
| 254 | self.assertEqual(len(self.connection.messages), snapshot_duplicate_start) | ||
| 255 | |||
| 256 | await self.adapter.edit_message( | ||
| 257 | "thread-snapshot", | ||
| 258 | "message", | ||
| 259 | "Helpful", | ||
| 260 | finalize=True, | ||
| 261 | ) | ||
| 262 | |||
| 263 | content_frames = self.connection.messages[content_start:] | ||
| 264 | self.assertEqual( | ||
| 265 | [message["type"] for message in content_frames], | ||
| 266 | [ | ||
| 267 | "item.started", | ||
| 268 | "content.delta", | ||
| 269 | "content.snapshot", | ||
| 270 | "content.delta", | ||
| 271 | "item.completed", | ||
| 272 | "turn.completed", | ||
| 273 | "connection.status", | ||
| 274 | ], | ||
| 275 | ) | ||
| 276 | self.assertEqual(content_frames[1]["delta"], "Hello") | ||
| 277 | self.assertEqual(content_frames[2]["text"], "Help") | ||
| 278 | self.assertEqual(content_frames[3]["delta"], "ful") | ||
| 279 | |||
| 280 | async def test_empty_and_duplicate_cumulative_edits_are_reconciled(self): | ||
| 281 | await self._start_turn("thread-empty", "turn-empty") | ||
| 282 | content_start = len(self.connection.messages) | ||
| 283 | |||
| 284 | await self.adapter.send("thread-empty", "") | ||
| 285 | duplicate_start = len(self.connection.messages) | ||
| 286 | await self.adapter.edit_message("thread-empty", "message", "") | ||
| 287 | self.assertEqual(len(self.connection.messages), duplicate_start) | ||
| 288 | |||
| 289 | await self.adapter.edit_message("thread-empty", "message", "Visible") | ||
| 290 | await self.adapter.edit_message("thread-empty", "message", "") | ||
| 291 | empty_snapshot_end = len(self.connection.messages) | ||
| 292 | await self.adapter.edit_message("thread-empty", "message", "") | ||
| 293 | self.assertEqual(len(self.connection.messages), empty_snapshot_end) | ||
| 294 | await self.adapter.edit_message( | ||
| 295 | "thread-empty", | ||
| 296 | "message", | ||
| 297 | "", | ||
| 298 | finalize=True, | ||
| 299 | ) | ||
| 300 | |||
| 301 | content_frames = self.connection.messages[content_start:] | ||
| 302 | self.assertEqual( | ||
| 303 | [message["type"] for message in content_frames], | ||
| 304 | [ | ||
| 305 | "item.started", | ||
| 306 | "content.delta", | ||
| 307 | "content.snapshot", | ||
| 308 | "item.completed", | ||
| 309 | "turn.completed", | ||
| 310 | "connection.status", | ||
| 311 | ], | ||
| 312 | ) | ||
| 313 | self.assertEqual(content_frames[1]["delta"], "Visible") | ||
| 314 | self.assertEqual(content_frames[2]["text"], "") | ||
| 315 | |||
| 316 | async def test_failed_content_sends_do_not_advance_visible_text(self): | ||
| 317 | await self._start_turn("thread-retry", "turn-retry") | ||
| 318 | await self.adapter.send("thread-retry", "Hello") | ||
| 319 | original_send = self.connection.send | ||
| 320 | |||
| 321 | async def fail_content(message): | ||
| 322 | if message["type"] in {"content.delta", "content.snapshot"}: | ||
| 323 | raise ConnectionError("send failed") | ||
| 324 | await original_send(message) | ||
| 325 | |||
| 326 | self.connection.send = fail_content | ||
| 327 | failed = await self.adapter.edit_message( | ||
| 328 | "thread-retry", | ||
| 329 | "message", | ||
| 330 | "Hello world", | ||
| 331 | ) | ||
| 332 | self.assertFalse(failed.success) | ||
| 333 | self.assertEqual( | ||
| 334 | self.adapter._active_turns["thread-retry"].visible_text, | ||
| 335 | "Hello", | ||
| 336 | ) | ||
| 337 | |||
| 338 | self.connection.send = original_send | ||
| 339 | retried = await self.adapter.edit_message( | ||
| 340 | "thread-retry", | ||
| 341 | "message", | ||
| 342 | "Hello world", | ||
| 343 | ) | ||
| 344 | self.assertTrue(retried.success) | ||
| 345 | self.assertEqual(self.connection.messages[-1]["delta"], " world") | ||
| 346 | |||
| 347 | self.connection.send = fail_content | ||
| 348 | failed_snapshot = await self.adapter.edit_message( | ||
| 349 | "thread-retry", | ||
| 350 | "message", | ||
| 351 | "Hi", | ||
| 352 | ) | ||
| 353 | self.assertFalse(failed_snapshot.success) | ||
| 354 | self.assertEqual( | ||
| 355 | self.adapter._active_turns["thread-retry"].visible_text, | ||
| 356 | "Hello world", | ||
| 357 | ) | ||
| 358 | |||
| 359 | self.connection.send = original_send | ||
| 360 | retried_snapshot = await self.adapter.edit_message( | ||
| 361 | "thread-retry", | ||
| 362 | "message", | ||
| 363 | "Hi", | ||
| 364 | ) | ||
| 365 | self.assertTrue(retried_snapshot.success) | ||
| 366 | self.assertEqual(self.connection.messages[-1]["type"], "content.snapshot") | ||
| 367 | self.assertEqual(self.connection.messages[-1]["text"], "Hi") | ||
| 368 | |||
| 369 | async def test_failed_generic_activity_start_retries_the_full_lifecycle(self): | ||
| 370 | await self._start_turn("thread-activity-retry", "turn-activity-retry") | ||
| 371 | turn = self.adapter._active_turns["thread-activity-retry"] | ||
| 372 | original_send = self.connection.send | ||
| 373 | |||
| 374 | async def fail_activity_start(message): | ||
| 375 | if message["type"] == "item.started": | ||
| 376 | raise ConnectionError("send failed") | ||
| 377 | await original_send(message) | ||
| 378 | |||
| 379 | self.connection.send = fail_activity_start | ||
| 380 | with self.assertRaisesRegex(ConnectionError, "send failed"): | ||
| 381 | await self.adapter._emit_generic_activity(turn, "Reading repository") | ||
| 382 | |||
| 383 | self.assertIsNone(turn.generic_activity_id) | ||
| 384 | self.assertIsNone(turn.generic_activity_detail) | ||
| 385 | |||
| 386 | self.connection.send = original_send | ||
| 387 | await self.adapter._emit_generic_activity(turn, "Reading repository") | ||
| 388 | started = self.connection.messages[-1] | ||
| 389 | self.assertEqual(started["type"], "item.started") | ||
| 390 | self.assertEqual(started["detail"], "Reading repository") | ||
| 391 | self.assertEqual(turn.generic_activity_id, started["itemId"]) | ||
| 392 | self.assertEqual(turn.generic_activity_detail, "Reading repository") | ||
| 393 | |||
| 394 | await self.adapter._emit_generic_activity(turn, "Running tests") | ||
| 395 | updated = self.connection.messages[-1] | ||
| 396 | self.assertEqual(updated["type"], "item.updated") | ||
| 397 | self.assertEqual(updated["itemId"], started["itemId"]) | ||
| 398 | self.assertEqual(updated["detail"], "Running tests") | ||
| 399 | |||
| 400 | async def test_exact_t3_home_channel_notice_is_suppressed(self): | ||
| 401 | await self._start_turn("thread-notice", "turn-notice") | ||
| 402 | content_start = len(self.connection.messages) | ||
| 403 | notice = ( | ||
| 404 | "📬 No home channel is set for T3. " | ||
| 405 | "A home channel is where Hermes delivers cron job results " | ||
| 406 | "and cross-platform messages.\n\n" | ||
| 407 | "Type /sethome to make this chat your home channel, or ignore to skip." | ||
| 408 | ) | ||
| 409 | |||
| 410 | suppressed = await self.adapter.send("thread-notice", notice) | ||
| 411 | self.assertTrue(suppressed.success) | ||
| 412 | self.assertEqual(len(self.connection.messages), content_start) | ||
| 413 | self.assertFalse( | ||
| 414 | self.adapter._active_turns["thread-notice"].assistant_started | ||
| 415 | ) | ||
| 416 | |||
| 417 | await self.adapter.edit_message( | ||
| 418 | "thread-notice", | ||
| 419 | "message", | ||
| 420 | "The actual Hermes response", | ||
| 421 | finalize=True, | ||
| 422 | ) | ||
| 423 | content_frames = self.connection.messages[content_start:] | ||
| 424 | self.assertEqual( | ||
| 425 | [message["type"] for message in content_frames], | ||
| 426 | [ | ||
| 427 | "item.started", | ||
| 428 | "content.delta", | ||
| 429 | "item.completed", | ||
| 430 | "turn.completed", | ||
| 431 | "connection.status", | ||
| 432 | ], | ||
| 433 | ) | ||
| 434 | self.assertEqual(content_frames[1]["delta"], "The actual Hermes response") | ||
| 435 | |||
| 436 | async def test_terminal_send_suppresses_exact_home_notice_and_completes_turn(self): | ||
| 437 | await self._start_turn("thread-terminal-notice-send", "turn-terminal-notice-send") | ||
| 438 | content_start = len(self.connection.messages) | ||
| 439 | notice = ( | ||
| 440 | "📬 No home channel is set for T3. " | ||
| 441 | "A home channel is where Hermes delivers cron job results " | ||
| 442 | "and cross-platform messages.\n\n" | ||
| 443 | "Type /sethome to make this chat your home channel, or ignore to skip." | ||
| 444 | ) | ||
| 445 | |||
| 446 | suppressed = await self.adapter.send( | ||
| 447 | "thread-terminal-notice-send", | ||
| 448 | notice, | ||
| 449 | metadata={"notify": True}, | ||
| 450 | ) | ||
| 451 | |||
| 452 | self.assertTrue(suppressed.success) | ||
| 453 | self.assertNotIn("thread-terminal-notice-send", self.adapter._active_turns) | ||
| 454 | self.assertEqual( | ||
| 455 | [ | ||
| 456 | message["type"] | ||
| 457 | for message in self.connection.messages[content_start:] | ||
| 458 | ], | ||
| 459 | ["turn.completed", "connection.status"], | ||
| 460 | ) | ||
| 461 | |||
| 462 | async def test_terminal_edit_suppresses_exact_home_notice_and_completes_turn(self): | ||
| 463 | await self._start_turn("thread-terminal-notice-edit", "turn-terminal-notice-edit") | ||
| 464 | content_start = len(self.connection.messages) | ||
| 465 | notice = ( | ||
| 466 | "📬 No home channel is set for T3. " | ||
| 467 | "A home channel is where Hermes delivers cron job results " | ||
| 468 | "and cross-platform messages.\n\n" | ||
| 469 | "Type /sethome to make this chat your home channel, or ignore to skip." | ||
| 470 | ) | ||
| 471 | |||
| 472 | suppressed = await self.adapter.edit_message( | ||
| 473 | "thread-terminal-notice-edit", | ||
| 474 | "message", | ||
| 475 | notice, | ||
| 476 | finalize=True, | ||
| 477 | ) | ||
| 478 | |||
| 479 | self.assertTrue(suppressed.success) | ||
| 480 | self.assertNotIn("thread-terminal-notice-edit", self.adapter._active_turns) | ||
| 481 | self.assertEqual( | ||
| 482 | [ | ||
| 483 | message["type"] | ||
| 484 | for message in self.connection.messages[content_start:] | ||
| 485 | ], | ||
| 486 | ["turn.completed", "connection.status"], | ||
| 487 | ) | ||
| 488 | |||
| 489 | async def test_near_match_home_channel_text_is_not_suppressed(self): | ||
| 490 | await self._start_turn("thread-notice-near-match", "turn-notice-near-match") | ||
| 491 | content_start = len(self.connection.messages) | ||
| 492 | await self.adapter.edit_message( | ||
| 493 | "thread-notice-near-match", | ||
| 494 | "message", | ||
| 495 | ( | ||
| 496 | "📬 No home channel is set for T3. " | ||
| 497 | "A home channel is where Hermes delivers cron job results " | ||
| 498 | "and cross-platform messages.\n\n" | ||
| 499 | "Type /sethome to make this chat your home channel, " | ||
| 500 | "or ignore to skip. " | ||
| 501 | ), | ||
| 502 | finalize=True, | ||
| 503 | ) | ||
| 504 | self.assertEqual( | ||
| 505 | [ | ||
| 506 | message["type"] | ||
| 507 | for message in self.connection.messages[content_start:] | ||
| 508 | ], | ||
| 509 | [ | ||
| 510 | "item.started", | ||
| 511 | "content.delta", | ||
| 512 | "item.completed", | ||
| 513 | "turn.completed", | ||
| 514 | "connection.status", | ||
| 515 | ], | ||
| 516 | ) | ||
| 517 | |||
| 518 | async def test_session_ready_reports_an_active_turn_on_reconnect(self): | ||
| 519 | session_id = await self._start_turn("thread-reconnect", "turn-reconnect") | ||
| 520 | |||
| 521 | await self.adapter._handle_server_frame( | ||
| 522 | { | ||
| 523 | "type": "session.ensure", | ||
| 524 | "protocolVersion": 2, | ||
| 525 | "requestId": "ensure-reconnect", | ||
| 526 | "threadId": "thread-reconnect", | ||
| 527 | "resumeSessionId": session_id, | ||
| 528 | } | ||
| 529 | ) | ||
| 530 | |||
| 531 | ready = self.connection.messages[-2] | ||
| 532 | self.assertEqual(ready["type"], "session.ready") | ||
| 533 | self.assertTrue(ready["resumed"]) | ||
| 534 | self.assertEqual(ready["activeTurnId"], "turn-reconnect") | ||
| 535 | |||
| 219 | async def test_steer_uses_official_hermes_command(self): | 536 | async def test_steer_uses_official_hermes_command(self): |
| 220 | await self.adapter._handle_server_frame( | 537 | await self.adapter._handle_server_frame( |
| 221 | { | 538 | { |
| 222 | "type": "session.ensure", | 539 | "type": "session.ensure", |
| 223 | "protocolVersion": 1, | 540 | "protocolVersion": 2, |
| 224 | "requestId": "ensure-2", | 541 | "requestId": "ensure-2", |
| 225 | "threadId": "thread-2", | 542 | "threadId": "thread-2", |
| 226 | } | 543 | } |
| @@ -229,7 +546,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines | |||
| 229 | await self.adapter._handle_server_frame( | 546 | await self.adapter._handle_server_frame( |
| 230 | { | 547 | { |
| 231 | "type": "turn.start", | 548 | "type": "turn.start", |
| 232 | "protocolVersion": 1, | 549 | "protocolVersion": 2, |
| 233 | "requestId": "start-2", | 550 | "requestId": "start-2", |
| 234 | "threadId": "thread-2", | 551 | "threadId": "thread-2", |
| 235 | "sessionId": session_id, | 552 | "sessionId": session_id, |
| @@ -248,7 +565,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 12 unmodified lines | |||
| 248 | await self.adapter._handle_server_frame( | 565 | await self.adapter._handle_server_frame( |
| 249 | { | 566 | { |
| 250 | "type": "turn.steer", | 567 | "type": "turn.steer", |
| 251 | "protocolVersion": 1, | 568 | "protocolVersion": 2, |
| 252 | "requestId": "steer-2", | 569 | "requestId": "steer-2", |
| 253 | "threadId": "thread-2", | 570 | "threadId": "thread-2", |
| 254 | "sessionId": session_id, | 571 | "sessionId": session_id, |
| @@ -283,7 +600,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 28 unmodified lines | |||
| 283 | await self.adapter._handle_server_frame( | 600 | await self.adapter._handle_server_frame( |
| 284 | { | 601 | { |
| 285 | "type": "session.ensure", | 602 | "type": "session.ensure", |
| 286 | "protocolVersion": 1, | 603 | "protocolVersion": 2, |
| 287 | "requestId": "ensure-rejected-steer", | 604 | "requestId": "ensure-rejected-steer", |
| 288 | "threadId": "thread-rejected-steer", | 605 | "threadId": "thread-rejected-steer", |
| 289 | } | 606 | } |
| @@ -292,7 +609,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines | |||
| 292 | await self.adapter._handle_server_frame( | 609 | await self.adapter._handle_server_frame( |
| 293 | { | 610 | { |
| 294 | "type": "turn.start", | 611 | "type": "turn.start", |
| 295 | "protocolVersion": 1, | 612 | "protocolVersion": 2, |
| 296 | "requestId": "start-rejected-steer", | 613 | "requestId": "start-rejected-steer", |
| 297 | "threadId": "thread-rejected-steer", | 614 | "threadId": "thread-rejected-steer", |
| 298 | "sessionId": session_id, | 615 | "sessionId": session_id, |
| @@ -309,7 +626,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines | |||
| 309 | await self.adapter._handle_server_frame( | 626 | await self.adapter._handle_server_frame( |
| 310 | { | 627 | { |
| 311 | "type": "turn.steer", | 628 | "type": "turn.steer", |
| 312 | "protocolVersion": 1, | 629 | "protocolVersion": 2, |
| 313 | "requestId": "steer-rejected", | 630 | "requestId": "steer-rejected", |
| 314 | "threadId": "thread-rejected-steer", | 631 | "threadId": "thread-rejected-steer", |
| 315 | "sessionId": session_id, | 632 | "sessionId": session_id, |
| @@ -339,7 +656,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 23 unmodified lines | |||
| 339 | await self.adapter._handle_server_frame( | 656 | await self.adapter._handle_server_frame( |
| 340 | { | 657 | { |
| 341 | "type": "session.ensure", | 658 | "type": "session.ensure", |
| 342 | "protocolVersion": 1, | 659 | "protocolVersion": 2, |
| 343 | "requestId": "ensure-failed-steer", | 660 | "requestId": "ensure-failed-steer", |
| 344 | "threadId": "thread-failed-steer", | 661 | "threadId": "thread-failed-steer", |
| 345 | } | 662 | } |
| @@ -348,7 +665,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 2 unmodified lines | |||
| 348 | await self.adapter._handle_server_frame( | 665 | await self.adapter._handle_server_frame( |
| 349 | { | 666 | { |
| 350 | "type": "turn.start", | 667 | "type": "turn.start", |
| 351 | "protocolVersion": 1, | 668 | "protocolVersion": 2, |
| 352 | "requestId": "start-failed-steer", | 669 | "requestId": "start-failed-steer", |
| 353 | "threadId": "thread-failed-steer", | 670 | "threadId": "thread-failed-steer", |
| 354 | "sessionId": session_id, | 671 | "sessionId": session_id, |
| @@ -365,7 +682,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines | |||
| 365 | await self.adapter._handle_server_frame( | 682 | await self.adapter._handle_server_frame( |
| 366 | { | 683 | { |
| 367 | "type": "turn.steer", | 684 | "type": "turn.steer", |
| 368 | "protocolVersion": 1, | 685 | "protocolVersion": 2, |
| 369 | "requestId": "steer-failed", | 686 | "requestId": "steer-failed", |
| 370 | "threadId": "thread-failed-steer", | 687 | "threadId": "thread-failed-steer", |
| 371 | "sessionId": session_id, | 688 | "sessionId": session_id, |
| @@ -386,7 +703,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 14 unmodified lines | |||
| 386 | await self.adapter._handle_server_frame( | 703 | await self.adapter._handle_server_frame( |
| 387 | { | 704 | { |
| 388 | "type": "session.ensure", | 705 | "type": "session.ensure", |
| 389 | "protocolVersion": 1, | 706 | "protocolVersion": 2, |
| 390 | "requestId": "ensure-3", | 707 | "requestId": "ensure-3", |
| 391 | "threadId": "thread-3", | 708 | "threadId": "thread-3", |
| 392 | } | 709 | } |
| @@ -396,7 +713,7 @@ class AdapterTests(unittest.IsolatedAsyncioTestCase): · 3 unmodified lines | |||
| 396 | await self.adapter._handle_server_frame( | 713 | await self.adapter._handle_server_frame( |
| 397 | { | 714 | { |
| 398 | "type": "session.stop", | 715 | "type": "session.stop", |
| 399 | "protocolVersion": 1, | 716 | "protocolVersion": 2, |
| 400 | "requestId": "stop-3", | 717 | "requestId": "stop-3", |
| 401 | "threadId": "thread-3", | 718 | "threadId": "thread-3", |
| 402 | "sessionId": session_id, | 719 | "sessionId": session_id, |
| @@ -60,7 +60,7 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 59 unmodified lines | |||
| 60 | socket = FakeSocket( | 60 | socket = FakeSocket( |
| 61 | { | 61 | { |
| 62 | "type": "connection.accepted", | 62 | "type": "connection.accepted", |
| 63 | "protocolVersion": 1, | 63 | "protocolVersion": 2, |
| 64 | "instanceId": "provider-instance", | 64 | "instanceId": "provider-instance", |
| 65 | "nickname": "Research", | 65 | "nickname": "Research", |
| 66 | "credential": "persistent-secret", | 66 | "credential": "persistent-secret", |
| @@ -77,13 +77,33 @@ class ConnectionTests(unittest.IsolatedAsyncioTestCase): · 10 unmodified lines | |||
| 77 | {"type": "enrollment-token", "token": "once"}, | 77 | {"type": "enrollment-token", "token": "once"}, |
| 78 | ) | 78 | ) |
| 79 | 79 | ||
| 80 | async def test_accepted_handshake_rejects_an_incompatible_protocol(self): | ||
| 81 | socket = FakeSocket( | ||
| 82 | { | ||
| 83 | "type": "connection.accepted", | ||
| 84 | "protocolVersion": 1, | ||
| 85 | "instanceId": "provider-instance", | ||
| 86 | "nickname": "Research", | ||
| 87 | } | ||
| 88 | ) | ||
| 89 | with self.assertRaisesRegex(RuntimeError, "incompatible version"): | ||
| 90 | await connection.authenticate_socket( | ||
| 91 | socket, | ||
| 92 | authentication={ | ||
| 93 | "type": "instance-credential", | ||
| 94 | "instanceId": "provider-instance", | ||
| 95 | "credential": "secret", | ||
| 96 | }, | ||
| 97 | hermes_version="0.19.0", | ||
| 98 | ) | ||
| 99 | |||
| 80 | async def test_rejected_handshake_fails_closed(self): | 100 | async def test_rejected_handshake_fails_closed(self): |
| 81 | socket = FakeSocket( | 101 | socket = FakeSocket( |
| 82 | { | 102 | { |
| 83 | "type": "connection.rejected", | 103 | "type": "connection.rejected", |
| 84 | "code": "version-incompatible", | 104 | "code": "version-incompatible", |
| 85 | "message": "upgrade required", | 105 | "message": "upgrade required", |
| 86 | "expectedProtocolVersion": 1, | 106 | "expectedProtocolVersion": 2, |
| 87 | } | 107 | } |
| 88 | ) | 108 | ) |
| 89 | with self.assertRaises(connection.ConnectionRejected) as raised: | 109 | with self.assertRaises(connection.ConnectionRejected) as raised: |
| @@ -14,7 +14,7 @@ SPEC.loader.exec_module(protocol) · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | 15 | ||
| 16 | class ProtocolTests(unittest.TestCase): | 16 | class ProtocolTests(unittest.TestCase): |
| 17 | def test_hello_matches_v1_contract(self): | 17 | def test_hello_matches_v2_contract(self): |
| 18 | hello = protocol.connection_hello( | 18 | hello = protocol.connection_hello( |
| 19 | hermes_version="0.19.0", | 19 | hermes_version="0.19.0", |
| 20 | authentication={"type": "enrollment-token", "token": "once"}, | 20 | authentication={"type": "enrollment-token", "token": "once"}, |
| @@ -22,15 +22,16 @@ class ProtocolTests(unittest.TestCase): · 1 unmodified line | |||
| 22 | ) | 22 | ) |
| 23 | self.assertEqual(hello["type"], "connection.hello") | 23 | self.assertEqual(hello["type"], "connection.hello") |
| 24 | self.assertEqual(hello["requestId"], "request-1") | 24 | self.assertEqual(hello["requestId"], "request-1") |
| 25 | self.assertEqual(hello["protocolVersion"], 1) | 25 | self.assertEqual(hello["protocolVersion"], 2) |
| 26 | self.assertFalse(hello["capabilities"]["attachments"]) | 26 | self.assertFalse(hello["capabilities"]["attachments"]) |
| 27 | self.assertTrue(hello["capabilities"]["streaming"]) | 27 | self.assertTrue(hello["capabilities"]["streaming"]) |
| 28 | 28 | ||
| 29 | def test_server_frame_validation_is_closed(self): | 29 | def test_server_frame_validation_is_closed(self): |
| 30 | with self.assertRaisesRegex(ValueError, "unsupported"): | 30 | with self.assertRaisesRegex(ValueError, "unsupported"): |
| 31 | protocol.validate_server_frame({"type": "made.up", "protocolVersion": 1}) | 31 | protocol.validate_server_frame({"type": "made.up", "protocolVersion": 2}) |
| 32 | with self.assertRaisesRegex(ValueError, "version"): | 32 | with self.assertRaisesRegex(ValueError, "version"): |
| 33 | protocol.validate_server_frame({"type": "ping", "protocolVersion": 2}) | 33 | # Protocol v1 peers must upgrade before sending runtime frames. |
| 34 | protocol.validate_server_frame({"type": "ping", "protocolVersion": 1}) | ||
| 34 | 35 | ||
| 35 | def test_tool_types_map_to_canonical_items(self): | 36 | def test_tool_types_map_to_canonical_items(self): |
| 36 | self.assertEqual( | 37 | self.assertEqual( |
| @@ -67,12 +67,12 @@ describe("Hermes gateway management contracts", () => { · 66 unmodified lines | |||
| 67 | status: "connected", | 67 | status: "connected", |
| 68 | connectorUrl: "wss://siva.davis7.space/hermes", | 68 | connectorUrl: "wss://siva.davis7.space/hermes", |
| 69 | lastConnectedAt: "2026-07-23T12:00:00.000Z", | 69 | lastConnectedAt: "2026-07-23T12:00:00.000Z", |
| 70 | pluginVersion: "0.1.0", | 70 | pluginVersion: "0.2.0", |
| 71 | hermesVersion: "1.2.3", | 71 | hermesVersion: "1.2.3", |
| 72 | activeSessionCount: 2, | 72 | activeSessionCount: 2, |
| 73 | protocolVersion: 1, | 73 | protocolVersion: 2, |
| 74 | capabilities: { | 74 | capabilities: { |
| 75 | protocolVersion: 1, | 75 | protocolVersion: 2, |
| 76 | streaming: true, | 76 | streaming: true, |
| 77 | activity: true, | 77 | activity: true, |
| 78 | approvals: true, | 78 | approvals: true, |
| @@ -86,10 +86,10 @@ describe("Hermes gateway management contracts", () => { · 7 unmodified lines | |||
| 86 | const upgradeRequired = decodeInstanceStatus({ | 86 | const upgradeRequired = decodeInstanceStatus({ |
| 87 | ...connected, | 87 | ...connected, |
| 88 | status: "upgrade-required", | 88 | status: "upgrade-required", |
| 89 | protocolVersion: 2, | 89 | protocolVersion: 3, |
| 90 | capabilities: null, | 90 | capabilities: null, |
| 91 | }); | 91 | }); |
| 92 | expect(upgradeRequired.protocolVersion).toBe(2); | 92 | expect(upgradeRequired.protocolVersion).toBe(3); |
| 93 | expect(upgradeRequired.capabilities).toBeNull(); | 93 | expect(upgradeRequired.capabilities).toBeNull(); |
| 94 | }); | 94 | }); |
| 95 | }); | 95 | }); |
| @@ -99,11 +99,11 @@ describe("Hermes gateway handshake", () => { · 3 unmodified lines | |||
| 99 | const hello = decodeHello({ | 99 | const hello = decodeHello({ |
| 100 | type: "connection.hello", | 100 | type: "connection.hello", |
| 101 | requestId: "hello-1", | 101 | requestId: "hello-1", |
| 102 | protocolVersion: 1, | 102 | protocolVersion: 2, |
| 103 | pluginVersion: "0.1.0", | 103 | pluginVersion: "0.2.0", |
| 104 | hermesVersion: "1.2.3", | 104 | hermesVersion: "1.2.3", |
| 105 | capabilities: { | 105 | capabilities: { |
| 106 | protocolVersion: 1, | 106 | protocolVersion: 2, |
| 107 | streaming: true, | 107 | streaming: true, |
| 108 | activity: true, | 108 | activity: true, |
| 109 | approvals: true, | 109 | approvals: true, |
| @@ -123,11 +123,11 @@ describe("Hermes gateway handshake", () => { · 13 unmodified lines | |||
| 123 | const hello = decodeHello({ | 123 | const hello = decodeHello({ |
| 124 | type: "connection.hello", | 124 | type: "connection.hello", |
| 125 | requestId: "hello-2", | 125 | requestId: "hello-2", |
| 126 | protocolVersion: 1, | 126 | protocolVersion: 2, |
| 127 | pluginVersion: "0.1.0", | 127 | pluginVersion: "0.2.0", |
| 128 | hermesVersion: "1.2.3", | 128 | hermesVersion: "1.2.3", |
| 129 | capabilities: { | 129 | capabilities: { |
| 130 | protocolVersion: 1, | 130 | protocolVersion: 2, |
| 131 | streaming: true, | 131 | streaming: true, |
| 132 | activity: true, | 132 | activity: true, |
| 133 | approvals: true, | 133 | approvals: true, |
| @@ -148,11 +148,11 @@ describe("Hermes gateway handshake", () => { · 14 unmodified lines | |||
| 148 | const hello = decodeHello({ | 148 | const hello = decodeHello({ |
| 149 | type: "connection.hello", | 149 | type: "connection.hello", |
| 150 | requestId: "hello-future", | 150 | requestId: "hello-future", |
| 151 | protocolVersion: 2, | 151 | protocolVersion: 3, |
| 152 | pluginVersion: "0.2.0", | 152 | pluginVersion: "0.3.0", |
| 153 | hermesVersion: "2.0.0", | 153 | hermesVersion: "2.0.0", |
| 154 | capabilities: { | 154 | capabilities: { |
| 155 | protocolVersion: 2, | 155 | protocolVersion: 3, |
| 156 | streaming: true, | 156 | streaming: true, |
| 157 | activity: true, | 157 | activity: true, |
| 158 | approvals: true, | 158 | approvals: true, |
| @@ -165,14 +165,14 @@ describe("Hermes gateway handshake", () => { · 6 unmodified lines | |||
| 165 | }, | 165 | }, |
| 166 | }); | 166 | }); |
| 167 | 167 | ||
| 168 | expect(hello.protocolVersion).toBe(2); | 168 | expect(hello.protocolVersion).toBe(3); |
| 169 | expect(hello.capabilities.protocolVersion).toBe(2); | 169 | expect(hello.capabilities.protocolVersion).toBe(3); |
| 170 | }); | 170 | }); |
| 171 | 171 | ||
| 172 | it("reserves attachments for a future protocol version", () => { | 172 | it("reserves attachments for a future protocol version", () => { |
| 173 | expect(() => | 173 | expect(() => |
| 174 | decodeCapabilities({ | 174 | decodeCapabilities({ |
| 175 | protocolVersion: 1, | 175 | protocolVersion: 2, |
| 176 | streaming: true, | 176 | streaming: true, |
| 177 | activity: true, | 177 | activity: true, |
| 178 | approvals: true, | 178 | approvals: true, |
| @@ -183,12 +183,12 @@ describe("Hermes gateway handshake", () => { · 4 unmodified lines | |||
| 183 | }); | 183 | }); |
| 184 | }); | 184 | }); |
| 185 | 185 | ||
| 186 | describe("T3 to Hermes v1 messages", () => { | 186 | describe("T3 to Hermes v2 messages", () => { |
| 187 | it("decodes session creation and opaque resume cursors", () => { | 187 | it("decodes session creation and opaque resume cursors", () => { |
| 188 | expect( | 188 | expect( |
| 189 | decodeT3Message({ | 189 | decodeT3Message({ |
| 190 | type: "session.ensure", | 190 | type: "session.ensure", |
| 191 | protocolVersion: 1, | 191 | protocolVersion: 2, |
| 192 | requestId: "ensure-1", | 192 | requestId: "ensure-1", |
| 193 | threadId: "thread-1", | 193 | threadId: "thread-1", |
| 194 | resumeSessionId: "opaque/hermes/session/value", | 194 | resumeSessionId: "opaque/hermes/session/value", |
| @@ -197,7 +197,7 @@ describe("T3 to Hermes v1 messages", () => { · 2 unmodified lines | |||
| 197 | 197 | ||
| 198 | expect( | 198 | expect( |
| 199 | decodeResumeCursor({ | 199 | decodeResumeCursor({ |
| 200 | protocolVersion: 1, | 200 | protocolVersion: 2, |
| 201 | sessionId: "opaque/hermes/session/value", | 201 | sessionId: "opaque/hermes/session/value", |
| 202 | }).sessionId, | 202 | }).sessionId, |
| 203 | ).toBe("opaque/hermes/session/value"); | 203 | ).toBe("opaque/hermes/session/value"); |
| @@ -205,7 +205,7 @@ describe("T3 to Hermes v1 messages", () => { · 1 unmodified line | |||
| 205 | 205 | ||
| 206 | it("decodes start and steering as distinct turn operations", () => { | 206 | it("decodes start and steering as distinct turn operations", () => { |
| 207 | const context = { | 207 | const context = { |
| 208 | protocolVersion: 1, | 208 | protocolVersion: 2, |
| 209 | requestId: "turn-command-1", | 209 | requestId: "turn-command-1", |
| 210 | threadId: "thread-1", | 210 | threadId: "thread-1", |
| 211 | sessionId: "session-1", | 211 | sessionId: "session-1", |
| @@ -219,7 +219,7 @@ describe("T3 to Hermes v1 messages", () => { · 7 unmodified lines | |||
| 219 | 219 | ||
| 220 | it("decodes interrupt, approval, structured input, stop, and ping", () => { | 220 | it("decodes interrupt, approval, structured input, stop, and ping", () => { |
| 221 | const turnContext = { | 221 | const turnContext = { |
| 222 | protocolVersion: 1, | 222 | protocolVersion: 2, |
| 223 | threadId: "thread-1", | 223 | threadId: "thread-1", |
| 224 | sessionId: "session-1", | 224 | sessionId: "session-1", |
| 225 | turnId: "turn-1", | 225 | turnId: "turn-1", |
| @@ -251,7 +251,7 @@ describe("T3 to Hermes v1 messages", () => { · 25 unmodified lines | |||
| 251 | expect( | 251 | expect( |
| 252 | decodeT3Message({ | 252 | decodeT3Message({ |
| 253 | type: "session.stop", | 253 | type: "session.stop", |
| 254 | protocolVersion: 1, | 254 | protocolVersion: 2, |
| 255 | requestId: "stop-1", | 255 | requestId: "stop-1", |
| 256 | threadId: "thread-1", | 256 | threadId: "thread-1", |
| 257 | sessionId: "session-1", | 257 | sessionId: "session-1", |
| @@ -260,7 +260,7 @@ describe("T3 to Hermes v1 messages", () => { · 2 unmodified lines | |||
| 260 | expect( | 260 | expect( |
| 261 | decodeT3Message({ | 261 | decodeT3Message({ |
| 262 | type: "ping", | 262 | type: "ping", |
| 263 | protocolVersion: 1, | 263 | protocolVersion: 2, |
| 264 | requestId: "ping-1", | 264 | requestId: "ping-1", |
| 265 | sentAt: "2026-07-23T12:00:00.000Z", | 265 | sentAt: "2026-07-23T12:00:00.000Z", |
| 266 | }).type, | 266 | }).type, |
| @@ -271,7 +271,8 @@ describe("T3 to Hermes v1 messages", () => { · 4 unmodified lines | |||
| 271 | expect(() => | 271 | expect(() => |
| 272 | decodeT3Message({ | 272 | decodeT3Message({ |
| 273 | type: "ping", | 273 | type: "ping", |
| 274 | protocolVersion: 2, | 274 | // Protocol v1 peers must upgrade before sending post-handshake frames. |
| 275 | protocolVersion: 1, | ||
| 275 | requestId: "ping-1", | 276 | requestId: "ping-1", |
| 276 | sentAt: "2026-07-23T12:00:00.000Z", | 277 | sentAt: "2026-07-23T12:00:00.000Z", |
| 277 | }), | 278 | }), |
| @@ -279,25 +280,38 @@ describe("T3 to Hermes v1 messages", () => { · 1 unmodified line | |||
| 279 | }); | 280 | }); |
| 280 | }); | 281 | }); |
| 281 | 282 | ||
| 282 | describe("Hermes to T3 v1 events", () => { | 283 | describe("Hermes to T3 v2 events", () => { |
| 283 | const turnContext = { | 284 | const turnContext = { |
| 284 | protocolVersion: 1, | 285 | protocolVersion: 2, |
| 285 | threadId: "thread-1", | 286 | threadId: "thread-1", |
| 286 | sessionId: "session-1", | 287 | sessionId: "session-1", |
| 287 | turnId: "turn-1", | 288 | turnId: "turn-1", |
| 288 | }; | 289 | }; |
| 289 | 290 | ||
| 290 | it("decodes session readiness, turn start, streaming text, and completion", () => { | 291 | it("decodes session readiness, turn start, streaming text, and completion", () => { |
| 291 | expect( | 292 | const legacyReady = decodePluginMessage({ |
| 292 | decodePluginMessage({ | 293 | type: "session.ready", |
| 293 | type: "session.ready", | 294 | protocolVersion: 2, |
| 294 | protocolVersion: 1, | 295 | requestId: "ensure-1", |
| 295 | requestId: "ensure-1", | 296 | threadId: "thread-1", |
| 296 | threadId: "thread-1", | 297 | sessionId: "session-1", |
| 297 | sessionId: "session-1", | 298 | resumed: false, |
| 298 | resumed: false, | 299 | }); |
| 299 | }).type, | 300 | expect(legacyReady.type).toBe("session.ready"); |
| 300 | ).toBe("session.ready"); | 301 | const activeReady = decodePluginMessage({ |
| 302 | type: "session.ready", | ||
| 303 | protocolVersion: 2, | ||
| 304 | requestId: "ensure-2", | ||
| 305 | threadId: "thread-1", | ||
| 306 | sessionId: "session-1", | ||
| 307 | resumed: true, | ||
| 308 | activeTurnId: "turn-1", | ||
| 309 | }); | ||
| 310 | expect(activeReady.type).toBe("session.ready"); | ||
| 311 | if (activeReady.type !== "session.ready") { | ||
| 312 | throw new Error("expected session.ready"); | ||
| 313 | } | ||
| 314 | expect(activeReady.activeTurnId).toBe("turn-1"); | ||
| 301 | expect( | 315 | expect( |
| 302 | decodePluginMessage({ | 316 | decodePluginMessage({ |
| 303 | type: "turn.started", | 317 | type: "turn.started", |
| @@ -313,6 +327,19 @@ describe("Hermes to T3 v1 events", () => { · 9 unmodified lines | |||
| 313 | ...turnContext, | 327 | ...turnContext, |
| 314 | }).type, | 328 | }).type, |
| 315 | ).toBe("content.delta"); | 329 | ).toBe("content.delta"); |
| 330 | const snapshot = decodePluginMessage({ | ||
| 331 | type: "content.snapshot", | ||
| 332 | streamKind: "assistant_text", | ||
| 333 | text: "", | ||
| 334 | itemId: "message-1", | ||
| 335 | contentIndex: 0, | ||
| 336 | ...turnContext, | ||
| 337 | }); | ||
| 338 | expect(snapshot.type).toBe("content.snapshot"); | ||
| 339 | if (snapshot.type !== "content.snapshot") { | ||
| 340 | throw new Error("expected content.snapshot"); | ||
| 341 | } | ||
| 342 | expect(snapshot.text).toBe(""); | ||
| 316 | expect( | 343 | expect( |
| 317 | decodePluginMessage({ | 344 | decodePluginMessage({ |
| 318 | type: "turn.completed", | 345 | type: "turn.completed", |
| @@ -21,7 +21,7 @@ import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestrat · 20 unmodified lines | |||
| 21 | import { ProviderInstanceId } from "./providerInstance.ts"; | 21 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 22 | import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; | 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 | export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); | 26 | export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); |
| 27 | export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; | 27 | export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; |
| @@ -87,7 +87,7 @@ export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; · 59 unmodified lines | |||
| 87 | * | 87 | * |
| 88 | * This deliberately permits capability shapes from a newer protocol so T3 can | 88 | * This deliberately permits capability shapes from a newer protocol so T3 can |
| 89 | * return a structured `version-incompatible` rejection instead of failing the | 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 | * this advertisement with `HermesGatewayCapabilities`. | 91 | * this advertisement with `HermesGatewayCapabilities`. |
| 92 | */ | 92 | */ |
| 93 | export const HermesGatewayHelloCapabilities = Schema.Struct({ | 93 | export const HermesGatewayHelloCapabilities = Schema.Struct({ |
| @@ -112,7 +112,7 @@ export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.T · 18 unmodified lines | |||
| 112 | /** | 112 | /** |
| 113 | * Public instance state used by settings and provider-picker surfaces. | 113 | * Public instance state used by settings and provider-picker surfaces. |
| 114 | * | 114 | * |
| 115 | * `protocolVersion` is not restricted to v1 here so the UI can report the | 115 | * `protocolVersion` is not restricted to v2 here so the UI can report the |
| 116 | * unsupported version observed from a plugin that needs an upgrade. | 116 | * unsupported version observed from a plugin that needs an upgrade. |
| 117 | */ | 117 | */ |
| 118 | export const HermesGatewayInstanceStatus = Schema.Struct({ | 118 | export const HermesGatewayInstanceStatus = Schema.Struct({ |
| @@ -241,7 +241,7 @@ export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Typ · 122 unmodified lines | |||
| 241 | /** | 241 | /** |
| 242 | * `protocolVersion` accepts any positive integer at the initial boundary so | 242 | * `protocolVersion` accepts any positive integer at the initial boundary so |
| 243 | * T3 can reject incompatible plugins with a structured upgrade response. | 243 | * T3 can reject incompatible plugins with a structured upgrade response. |
| 244 | * Once accepted, all remaining v1 frames use the literal v1 schema. | 244 | * Once accepted, all remaining v2 frames use the literal v2 schema. |
| 245 | */ | 245 | */ |
| 246 | export const HermesGatewayConnectionHello = Schema.Struct({ | 246 | export const HermesGatewayConnectionHello = Schema.Struct({ |
| 247 | type: Schema.Literal("connection.hello"), | 247 | type: Schema.Literal("connection.hello"), |
| @@ -380,6 +380,7 @@ export const HermesGatewaySessionReady = Schema.Struct({ · 132 unmodified lines | |||
| 380 | threadId: ThreadId, | 380 | threadId: ThreadId, |
| 381 | sessionId: HermesGatewaySessionId, | 381 | sessionId: HermesGatewaySessionId, |
| 382 | resumed: Schema.Boolean, | 382 | resumed: Schema.Boolean, |
| 383 | activeTurnId: Schema.optional(TurnId), | ||
| 383 | }); | 384 | }); |
| 384 | export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type; | 385 | export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type; |
| 385 | 386 | ||
| @@ -412,6 +413,17 @@ export const HermesGatewayContentDelta = Schema.Struct({ · 26 unmodified lines | |||
| 412 | }); | 413 | }); |
| 413 | export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type; | 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 | export const HermesGatewayItemStatus = Schema.Literals([ | 427 | export const HermesGatewayItemStatus = Schema.Literals([ |
| 416 | "inProgress", | 428 | "inProgress", |
| 417 | "completed", | 429 | "completed", |
| @@ -569,6 +581,7 @@ export const HermesGatewayPluginToT3Message = Schema.Union([ · 151 unmodified lines | |||
| 569 | HermesGatewaySessionReady, | 581 | HermesGatewaySessionReady, |
| 570 | HermesGatewayTurnStarted, | 582 | HermesGatewayTurnStarted, |
| 571 | HermesGatewayContentDelta, | 583 | HermesGatewayContentDelta, |
| 584 | HermesGatewayContentSnapshot, | ||
| 572 | HermesGatewayItemStarted, | 585 | HermesGatewayItemStarted, |
| 573 | HermesGatewayItemUpdated, | 586 | HermesGatewayItemUpdated, |
| 574 | HermesGatewayItemCompleted, | 587 | HermesGatewayItemCompleted, |
| @@ -813,6 +813,16 @@ const ThreadMessageAssistantDeltaCommand = Schema.Struct({ · 812 unmodified lines | |||
| 813 | createdAt: IsoDateTime, | 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 | const ThreadMessageAssistantCompleteCommand = Schema.Struct({ | 826 | const ThreadMessageAssistantCompleteCommand = Schema.Struct({ |
| 817 | type: Schema.Literal("thread.message.assistant.complete"), | 827 | type: Schema.Literal("thread.message.assistant.complete"), |
| 818 | commandId: CommandId, | 828 | commandId: CommandId, |
| @@ -863,6 +873,7 @@ const ThreadRevertCompleteCommand = Schema.Struct({ · 44 unmodified lines | |||
| 863 | const InternalOrchestrationCommand = Schema.Union([ | 873 | const InternalOrchestrationCommand = Schema.Union([ |
| 864 | ThreadSessionSetCommand, | 874 | ThreadSessionSetCommand, |
| 865 | ThreadMessageAssistantDeltaCommand, | 875 | ThreadMessageAssistantDeltaCommand, |
| 876 | ThreadMessageAssistantReplaceCommand, | ||
| 866 | ThreadMessageAssistantCompleteCommand, | 877 | ThreadMessageAssistantCompleteCommand, |
| 867 | ThreadProposedPlanUpsertCommand, | 878 | ThreadProposedPlanUpsertCommand, |
| 868 | ThreadTurnDiffCompleteCommand, | 879 | ThreadTurnDiffCompleteCommand, |
| @@ -1028,6 +1039,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 159 unmodified lines | |||
| 1028 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | 1039 | attachments: Schema.optional(Schema.Array(ChatAttachment)), |
| 1029 | turnId: Schema.NullOr(TurnId), | 1040 | turnId: Schema.NullOr(TurnId), |
| 1030 | streaming: Schema.Boolean, | 1041 | streaming: Schema.Boolean, |
| 1042 | textOperation: Schema.optional(Schema.Literal("replace")), | ||
| 1031 | createdAt: IsoDateTime, | 1043 | createdAt: IsoDateTime, |
| 1032 | updatedAt: IsoDateTime, | 1044 | updatedAt: IsoDateTime, |
| 1033 | }); | 1045 | }); |
| @@ -69,6 +69,34 @@ describe("ProviderRuntimeEvent", () => { · 68 unmodified lines | |||
| 69 | expect(parsed.payload.planMarkdown).toBe("# Ship it"); | 69 | expect(parsed.payload.planMarkdown).toBe("# Ship it"); |
| 70 | }); | 70 | }); |
| 71 | 71 | ||
| 72 | it("decodes authoritative content snapshots, including empty text", () => { | ||
| 73 | const parsed = decodeRuntimeEvent({ | ||
| 74 | type: "content.snapshot", | ||
| 75 | eventId: "event-content-snapshot-1", | ||
| 76 | provider: "hermes", | ||
| 77 | providerInstanceId: "hermes-research", | ||
| 78 | sessionId: "runtime-session-1", | ||
| 79 | createdAt: "2026-02-28T00:00:00.000Z", | ||
| 80 | threadId: "thread-1", | ||
| 81 | turnId: "turn-1", | ||
| 82 | itemId: "message-1", | ||
| 83 | payload: { | ||
| 84 | streamKind: "assistant_text", | ||
| 85 | text: "", | ||
| 86 | contentIndex: 0, | ||
| 87 | summaryIndex: 1, | ||
| 88 | }, | ||
| 89 | }); | ||
| 90 | |||
| 91 | expect(parsed.type).toBe("content.snapshot"); | ||
| 92 | if (parsed.type !== "content.snapshot") { | ||
| 93 | throw new Error("expected content.snapshot"); | ||
| 94 | } | ||
| 95 | expect(parsed.payload.text).toBe(""); | ||
| 96 | expect(parsed.payload.contentIndex).toBe(0); | ||
| 97 | expect(parsed.payload.summaryIndex).toBe(1); | ||
| 98 | }); | ||
| 99 | |||
| 72 | it("decodes user-input.requested with structured questions", () => { | 100 | it("decodes user-input.requested with structured questions", () => { |
| 73 | const parsed = decodeRuntimeEvent({ | 101 | const parsed = decodeRuntimeEvent({ |
| 74 | type: "user-input.requested", | 102 | type: "user-input.requested", |
| @@ -170,6 +170,7 @@ const ProviderRuntimeEventType = Schema.Literals([ · 169 unmodified lines | |||
| 170 | "item.updated", | 170 | "item.updated", |
| 171 | "item.completed", | 171 | "item.completed", |
| 172 | "content.delta", | 172 | "content.delta", |
| 173 | "content.snapshot", | ||
| 173 | "request.opened", | 174 | "request.opened", |
| 174 | "request.resolved", | 175 | "request.resolved", |
| 175 | "user-input.requested", | 176 | "user-input.requested", |
| @@ -220,6 +221,7 @@ const ItemStartedType = Schema.Literal("item.started"); · 44 unmodified lines | |||
| 220 | const ItemUpdatedType = Schema.Literal("item.updated"); | 221 | const ItemUpdatedType = Schema.Literal("item.updated"); |
| 221 | const ItemCompletedType = Schema.Literal("item.completed"); | 222 | const ItemCompletedType = Schema.Literal("item.completed"); |
| 222 | const ContentDeltaType = Schema.Literal("content.delta"); | 223 | const ContentDeltaType = Schema.Literal("content.delta"); |
| 224 | const ContentSnapshotType = Schema.Literal("content.snapshot"); | ||
| 223 | const RequestOpenedType = Schema.Literal("request.opened"); | 225 | const RequestOpenedType = Schema.Literal("request.opened"); |
| 224 | const RequestResolvedType = Schema.Literal("request.resolved"); | 226 | const RequestResolvedType = Schema.Literal("request.resolved"); |
| 225 | const UserInputRequestedType = Schema.Literal("user-input.requested"); | 227 | const UserInputRequestedType = Schema.Literal("user-input.requested"); |
| @@ -418,6 +420,14 @@ const ContentDeltaPayload = Schema.Struct({ · 192 unmodified lines | |||
| 418 | }); | 420 | }); |
| 419 | export type ContentDeltaPayload = typeof ContentDeltaPayload.Type; | 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 | const RequestOpenedPayload = Schema.Struct({ | 431 | const RequestOpenedPayload = Schema.Struct({ |
| 422 | requestType: CanonicalRequestType, | 432 | requestType: CanonicalRequestType, |
| 423 | detail: Schema.optional(TrimmedNonEmptyStringSchema), | 433 | detail: Schema.optional(TrimmedNonEmptyStringSchema), |
| @@ -791,6 +801,13 @@ const ProviderRuntimeContentDeltaEvent = Schema.Struct({ · 367 unmodified lines | |||
| 791 | }); | 801 | }); |
| 792 | export type ProviderRuntimeContentDeltaEvent = typeof ProviderRuntimeContentDeltaEvent.Type; | 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 | const ProviderRuntimeRequestOpenedEvent = Schema.Struct({ | 811 | const ProviderRuntimeRequestOpenedEvent = Schema.Struct({ |
| 795 | ...ProviderRuntimeEventBase.fields, | 812 | ...ProviderRuntimeEventBase.fields, |
| 796 | type: RequestOpenedType, | 813 | type: RequestOpenedType, |
| @@ -989,6 +1006,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ · 192 unmodified lines | |||
| 989 | ProviderRuntimeItemUpdatedEvent, | 1006 | ProviderRuntimeItemUpdatedEvent, |
| 990 | ProviderRuntimeItemCompletedEvent, | 1007 | ProviderRuntimeItemCompletedEvent, |
| 991 | ProviderRuntimeContentDeltaEvent, | 1008 | ProviderRuntimeContentDeltaEvent, |
| 1009 | ProviderRuntimeContentSnapshotEvent, | ||
| 992 | ProviderRuntimeRequestOpenedEvent, | 1010 | ProviderRuntimeRequestOpenedEvent, |
| 993 | ProviderRuntimeRequestResolvedEvent, | 1011 | ProviderRuntimeRequestResolvedEvent, |
| 994 | ProviderRuntimeUserInputRequestedEvent, | 1012 | ProviderRuntimeUserInputRequestedEvent, |