| author | |
| committer | |
| log | 6365919f2e5bcfb4fa4020b95e19af26ae40979f |
| tree | a0a61525de2cc0b0aa99ae9a091ee2ff20479133 |
| parent | 77b655c47b82c9b2aad09e447117b7618a16e21d |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 69 insertions(+), 43 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+46-26| ... | ... | @@ -29,6 +29,7 @@ import * as Deferred from "effect/Deferred"; |
| 29 | 29 | import * as Exit from "effect/Exit"; |
| 30 | 30 | import * as Layer from "effect/Layer"; |
| 31 | 31 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 32 | import * as Option from "effect/Option"; | |
| 32 | 33 | import * as PubSub from "effect/PubSub"; |
| 33 | 34 | import * as Scope from "effect/Scope"; |
| 34 | 35 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -170,6 +171,7 @@ describe("ProviderCommandReactor", () => { |
| 170 | 171 | readonly threadModelSelection?: ModelSelection; |
| 171 | 172 | readonly sessionModelSwitch?: "unsupported" | "in-session"; |
| 172 | 173 | readonly requiresNewThreadForModelChange?: boolean; |
| 174 | readonly unreadableHistory?: boolean; | |
| 173 | 175 | readonly titleRegenerationCompletionDispatchFailures?: number; |
| 174 | 176 | readonly titleRegenerationBeforeStart?: "one" | "two"; |
| 175 | 177 | readonly serverActivation?: Effect.Effect<void>; |
| ... | ... | @@ -512,6 +514,23 @@ describe("ProviderCommandReactor", () => { |
| 512 | 514 | createdAt: now, |
| 513 | 515 | }), |
| 514 | 516 | ); |
| 517 | if (input?.unreadableHistory === true) { | |
| 518 | // Metadata commands must not decode this unrelated message body. | |
| 519 | await runtime.runPromise( | |
| 520 | Effect.gen(function* () { | |
| 521 | const sql = yield* SqlClient.SqlClient; | |
| 522 | yield* sql` | |
| 523 | INSERT INTO projection_thread_messages ( | |
| 524 | message_id, thread_id, turn_id, role, text, attachments_json, | |
| 525 | is_streaming, created_at, updated_at | |
| 526 | ) VALUES ( | |
| 527 | 'old-unreadable-message', 'thread-1', NULL, 'assistant', | |
| 528 | 'Old assistant output', 'invalid json', 0, ${now}, ${now} | |
| 529 | ) | |
| 530 | `; | |
| 531 | }), | |
| 532 | ); | |
| 533 | } | |
| 515 | 534 | if (input?.titleRegenerationBeforeStart === "two") { |
| 516 | 535 | await Effect.runPromise( |
| 517 | 536 | engine.dispatch({ |
| ... | ... | @@ -561,6 +580,7 @@ describe("ProviderCommandReactor", () => { |
| 561 | 580 | |
| 562 | 581 | return { |
| 563 | 582 | engine, |
| 583 | snapshotQuery, | |
| 564 | 584 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), |
| 565 | 585 | readPendingTurnStarts: () => |
| 566 | 586 | runtime!.runPromise( |
| ... | ... | @@ -3533,8 +3553,8 @@ describe("ProviderCommandReactor", () => { |
| 3533 | 3553 | }); |
| 3534 | 3554 | }); |
| 3535 | 3555 | |
| 3536 | it("reacts to thread.approval.respond by forwarding provider approval response", async () => { | |
| 3537 | const harness = await createHarness(); | |
| 3556 | it("forwards approval responses without reading unrelated message bodies", async () => { | |
| 3557 | const harness = await createHarness({ unreadableHistory: true }); | |
| 3538 | 3558 | const now = "2026-01-01T00:00:00.000Z"; |
| 3539 | 3559 | |
| 3540 | 3560 | await Effect.runPromise( |
| ... | ... | @@ -3566,7 +3586,7 @@ describe("ProviderCommandReactor", () => { |
| 3566 | 3586 | }), |
| 3567 | 3587 | ); |
| 3568 | 3588 | |
| 3569 | await waitFor(() => harness.respondToRequest.mock.calls.length === 1); | |
| 3589 | await harness.drain(); | |
| 3570 | 3590 | expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ |
| 3571 | 3591 | threadId: "thread-1", |
| 3572 | 3592 | requestId: "approval-request-1", |
| ... | ... | @@ -3574,8 +3594,8 @@ describe("ProviderCommandReactor", () => { |
| 3574 | 3594 | }); |
| 3575 | 3595 | }); |
| 3576 | 3596 | |
| 3577 | it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { | |
| 3578 | const harness = await createHarness(); | |
| 3597 | it("forwards user input answers without reading unrelated message bodies", async () => { | |
| 3598 | const harness = await createHarness({ unreadableHistory: true }); | |
| 3579 | 3599 | const now = "2026-01-01T00:00:00.000Z"; |
| 3580 | 3600 | |
| 3581 | 3601 | await Effect.runPromise( |
| ... | ... | @@ -3609,7 +3629,7 @@ describe("ProviderCommandReactor", () => { |
| 3609 | 3629 | }), |
| 3610 | 3630 | ); |
| 3611 | 3631 | |
| 3612 | await waitFor(() => harness.respondToUserInput.mock.calls.length === 1); | |
| 3632 | await harness.drain(); | |
| 3613 | 3633 | expect(harness.respondToUserInput.mock.calls[0]?.[0]).toEqual({ |
| 3614 | 3634 | threadId: "thread-1", |
| 3615 | 3635 | requestId: "user-input-request-1", |
| ... | ... | @@ -3823,12 +3843,12 @@ describe("ProviderCommandReactor", () => { |
| 3823 | 3843 | expect(resolvedActivity).toBeUndefined(); |
| 3824 | 3844 | }); |
| 3825 | 3845 | |
| 3826 | it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { | |
| 3827 | const harness = await createHarness(); | |
| 3828 | const now = "2026-01-01T00:00:00.000Z"; | |
| 3846 | effectIt.effect("stops a provider session without reading unrelated message bodies", () => | |
| 3847 | Effect.gen(function* () { | |
| 3848 | const harness = yield* Effect.promise(() => createHarness({ unreadableHistory: true })); | |
| 3849 | const now = "2026-01-01T00:00:00.000Z"; | |
| 3829 | 3850 | |
| 3830 | await harness.runEffect( | |
| 3831 | harness.engine.dispatch({ | |
| 3851 | yield* harness.engine.dispatch({ | |
| 3832 | 3852 | type: "thread.session.set", |
| 3833 | 3853 | commandId: CommandId.make("cmd-session-set-for-stop"), |
| 3834 | 3854 | threadId: ThreadId.make("thread-1"), |
| ... | ... | @@ -3843,27 +3863,27 @@ describe("ProviderCommandReactor", () => { |
| 3843 | 3863 | updatedAt: now, |
| 3844 | 3864 | }, |
| 3845 | 3865 | createdAt: now, |
| 3846 | }), | |
| 3847 | ); | |
| 3866 | }); | |
| 3848 | 3867 | |
| 3849 | await harness.runEffect( | |
| 3850 | harness.engine.dispatch({ | |
| 3868 | yield* harness.engine.dispatch({ | |
| 3851 | 3869 | type: "thread.session.stop", |
| 3852 | 3870 | commandId: CommandId.make("cmd-session-stop"), |
| 3853 | 3871 | threadId: ThreadId.make("thread-1"), |
| 3854 | 3872 | createdAt: now, |
| 3855 | }), | |
| 3856 | ); | |
| 3873 | }); | |
| 3857 | 3874 | |
| 3858 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | |
| 3859 | const readModel = await harness.readModel(); | |
| 3860 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 3861 | expect(thread?.session).not.toBeNull(); | |
| 3862 | expect(thread?.session?.status).toBe("stopped"); | |
| 3863 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 3864 | expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); | |
| 3865 | expect(thread?.session?.activeTurnId).toBeNull(); | |
| 3866 | }); | |
| 3875 | yield* Effect.promise(() => harness.drain()); | |
| 3876 | expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); | |
| 3877 | const thread = yield* harness.snapshotQuery | |
| 3878 | .getThreadShellById(ThreadId.make("thread-1")) | |
| 3879 | .pipe(Effect.map(Option.getOrThrow)); | |
| 3880 | expect(thread.session).not.toBeNull(); | |
| 3881 | expect(thread.session?.status).toBe("stopped"); | |
| 3882 | expect(thread.session?.threadId).toBe("thread-1"); | |
| 3883 | expect(thread.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); | |
| 3884 | expect(thread.session?.activeTurnId).toBeNull(); | |
| 3885 | }), | |
| 3886 | ); | |
| 3867 | 3887 | |
| 3868 | 3888 | effectIt.effect("stops a ready provider session after automatic settlement", () => |
| 3869 | 3889 | Effect.gen(function* () { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+23-17| ... | ... | @@ -419,7 +419,7 @@ const make = Effect.gen(function* () { |
| 419 | 419 | readonly detail: string; |
| 420 | 420 | readonly createdAt: string; |
| 421 | 421 | }) { |
| 422 | const thread = yield* resolveThread(input.threadId); | |
| 422 | const thread = yield* resolveThreadShell(input.threadId); | |
| 423 | 423 | if (!thread) { |
| 424 | 424 | return; |
| 425 | 425 | } |
| ... | ... | @@ -447,7 +447,7 @@ const make = Effect.gen(function* () { |
| 447 | 447 | compactingThreadIds.delete(threadId); |
| 448 | 448 | return; |
| 449 | 449 | } |
| 450 | const thread = yield* resolveThread(threadId); | |
| 450 | const thread = yield* resolveThreadShell(threadId); | |
| 451 | 451 | if (!thread?.session) return; |
| 452 | 452 | if ( |
| 453 | 453 | thread.session.status !== "starting" && |
| ... | ... | @@ -525,7 +525,13 @@ const make = Effect.gen(function* () { |
| 525 | 525 | ); |
| 526 | 526 | }); |
| 527 | 527 | |
| 528 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { | |
| 528 | const resolveThreadShell = Effect.fnUntraced(function* (threadId: ThreadId) { | |
| 529 | return yield* projectionSnapshotQuery | |
| 530 | .getThreadShellById(threadId) | |
| 531 | .pipe(Effect.map(Option.getOrUndefined)); | |
| 532 | }); | |
| 533 | ||
| 534 | const resolveThreadDetail = Effect.fnUntraced(function* (threadId: ThreadId) { | |
| 529 | 535 | return yield* projectionSnapshotQuery |
| 530 | 536 | .getThreadDetailById(threadId, { activityKinds: [] }) |
| 531 | 537 | .pipe(Effect.map(Option.getOrUndefined)); |
| ... | ... | @@ -571,7 +577,7 @@ const make = Effect.gen(function* () { |
| 571 | 577 | readonly pendingTurnStart?: boolean; |
| 572 | 578 | }, |
| 573 | 579 | ) { |
| 574 | const thread = yield* resolveThread(threadId); | |
| 580 | const thread = yield* resolveThreadShell(threadId); | |
| 575 | 581 | if (!thread) { |
| 576 | 582 | return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); |
| 577 | 583 | } |
| ... | ... | @@ -834,7 +840,7 @@ const make = Effect.gen(function* () { |
| 834 | 840 | readonly interactionMode?: "default" | "plan"; |
| 835 | 841 | readonly createdAt: string; |
| 836 | 842 | }) { |
| 837 | const thread = yield* resolveThread(input.threadId); | |
| 843 | const thread = yield* resolveThreadShell(input.threadId); | |
| 838 | 844 | if (!thread) { |
| 839 | 845 | return yield* Effect.die( |
| 840 | 846 | new Error(`Thread '${input.threadId}' was not found in read model.`), |
| ... | ... | @@ -975,7 +981,7 @@ const make = Effect.gen(function* () { |
| 975 | 981 | ); |
| 976 | 982 | if (!generated) return; |
| 977 | 983 | |
| 978 | const thread = yield* resolveThread(input.threadId); | |
| 984 | const thread = yield* resolveThreadShell(input.threadId); | |
| 979 | 985 | if (!thread) return; |
| 980 | 986 | if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { |
| 981 | 987 | return; |
| ... | ... | @@ -1007,7 +1013,7 @@ const make = Effect.gen(function* () { |
| 1007 | 1013 | return { _tag: "Superseded" } as const; |
| 1008 | 1014 | } |
| 1009 | 1015 | |
| 1010 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1016 | const thread = yield* resolveThreadDetail(event.payload.threadId); | |
| 1011 | 1017 | if (!thread || thread.titleRegeneration?.requestId !== requestId) { |
| 1012 | 1018 | return { _tag: "Superseded" } as const; |
| 1013 | 1019 | } |
| ... | ... | @@ -1040,7 +1046,7 @@ const make = Effect.gen(function* () { |
| 1040 | 1046 | return { _tag: "Completed", title: undefined } as const; |
| 1041 | 1047 | } |
| 1042 | 1048 | |
| 1043 | const latestThread = yield* resolveThread(event.payload.threadId); | |
| 1049 | const latestThread = yield* resolveThreadShell(event.payload.threadId); | |
| 1044 | 1050 | if ( |
| 1045 | 1051 | !latestThread || |
| 1046 | 1052 | latestThread.titleRegeneration?.requestId !== requestId || |
| ... | ... | @@ -1177,7 +1183,7 @@ const make = Effect.gen(function* () { |
| 1177 | 1183 | return; |
| 1178 | 1184 | } |
| 1179 | 1185 | |
| 1180 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1186 | const thread = yield* resolveThreadDetail(event.payload.threadId); | |
| 1181 | 1187 | if (!thread) { |
| 1182 | 1188 | return; |
| 1183 | 1189 | } |
| ... | ... | @@ -1366,7 +1372,7 @@ const make = Effect.gen(function* () { |
| 1366 | 1372 | "Context compaction requires an existing conversation.", |
| 1367 | 1373 | ); |
| 1368 | 1374 | } |
| 1369 | const latestThread = yield* resolveThread(event.payload.threadId); | |
| 1375 | const latestThread = yield* resolveThreadShell(event.payload.threadId); | |
| 1370 | 1376 | if ( |
| 1371 | 1377 | compactingThreadIds.has(event.payload.threadId) || |
| 1372 | 1378 | latestThread?.session?.status === "starting" || |
| ... | ... | @@ -1436,7 +1442,7 @@ const make = Effect.gen(function* () { |
| 1436 | 1442 | const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( |
| 1437 | 1443 | event: Extract<ProviderIntentEvent, { type: "thread.turn-interrupt-requested" }>, |
| 1438 | 1444 | ) { |
| 1439 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1445 | const thread = yield* resolveThreadShell(event.payload.threadId); | |
| 1440 | 1446 | if (!thread) { |
| 1441 | 1447 | return; |
| 1442 | 1448 | } |
| ... | ... | @@ -1459,7 +1465,7 @@ const make = Effect.gen(function* () { |
| 1459 | 1465 | |
| 1460 | 1466 | const detail = formatFailureDetail(cause); |
| 1461 | 1467 | return Effect.gen(function* () { |
| 1462 | const latestThread = yield* resolveThread(event.payload.threadId); | |
| 1468 | const latestThread = yield* resolveThreadShell(event.payload.threadId); | |
| 1463 | 1469 | const latestSession = latestThread?.session; |
| 1464 | 1470 | if ( |
| 1465 | 1471 | !latestSession || |
| ... | ... | @@ -1487,7 +1493,7 @@ const make = Effect.gen(function* () { |
| 1487 | 1493 | ); |
| 1488 | 1494 | }), |
| 1489 | 1495 | ); |
| 1490 | const stoppedThread = yield* resolveThread(event.payload.threadId); | |
| 1496 | const stoppedThread = yield* resolveThreadShell(event.payload.threadId); | |
| 1491 | 1497 | const stoppedSession = stoppedThread?.session; |
| 1492 | 1498 | if ( |
| 1493 | 1499 | !stoppedSession || |
| ... | ... | @@ -1531,7 +1537,7 @@ const make = Effect.gen(function* () { |
| 1531 | 1537 | const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( |
| 1532 | 1538 | event: Extract<ProviderIntentEvent, { type: "thread.approval-response-requested" }>, |
| 1533 | 1539 | ) { |
| 1534 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1540 | const thread = yield* resolveThreadShell(event.payload.threadId); | |
| 1535 | 1541 | if (!thread) { |
| 1536 | 1542 | return; |
| 1537 | 1543 | } |
| ... | ... | @@ -1575,7 +1581,7 @@ const make = Effect.gen(function* () { |
| 1575 | 1581 | function* ( |
| 1576 | 1582 | event: Extract<ProviderIntentEvent, { type: "thread.user-input-response-requested" }>, |
| 1577 | 1583 | ) { |
| 1578 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1584 | const thread = yield* resolveThreadShell(event.payload.threadId); | |
| 1579 | 1585 | if (!thread) { |
| 1580 | 1586 | return; |
| 1581 | 1587 | } |
| ... | ... | @@ -1619,7 +1625,7 @@ const make = Effect.gen(function* () { |
| 1619 | 1625 | const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( |
| 1620 | 1626 | event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>, |
| 1621 | 1627 | ) { |
| 1622 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1628 | const thread = yield* resolveThreadShell(event.payload.threadId); | |
| 1623 | 1629 | if (!thread) { |
| 1624 | 1630 | return; |
| 1625 | 1631 | } |
| ... | ... | @@ -1696,7 +1702,7 @@ const make = Effect.gen(function* () { |
| 1696 | 1702 | yield* threadTitleRegenerationWorker.enqueue(event); |
| 1697 | 1703 | return; |
| 1698 | 1704 | case "thread.runtime-mode-set": { |
| 1699 | const thread = yield* resolveThread(event.payload.threadId); | |
| 1705 | const thread = yield* resolveThreadShell(event.payload.threadId); | |
| 1700 | 1706 | if (!thread?.session || thread.session.status === "stopped") { |
| 1701 | 1707 | return; |
| 1702 | 1708 | } |