| author | |
| committer | |
| log | 721b6b4c6f596bd1d1b3af585ed9c62c4b6f72ab |
| tree | 8e1ca6264b4dd9d492d019b52a500a06bc953e82 |
| parent | 40009735eea88b2266cf7d112217e85a688b3e63 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>8 files changed, 198 insertions(+), 160 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+108-108| ... | ... | @@ -750,6 +750,57 @@ describe("ProviderCommandReactor", () => { |
| 750 | 750 | }); |
| 751 | 751 | }); |
| 752 | 752 | |
| 753 | it("preserves the active session model when in-session model switching is unsupported", async () => { | |
| 754 | const harness = await createHarness({ sessionModelSwitch: "unsupported" }); | |
| 755 | const now = new Date().toISOString(); | |
| 756 | ||
| 757 | await Effect.runPromise( | |
| 758 | harness.engine.dispatch({ | |
| 759 | type: "thread.turn.start", | |
| 760 | commandId: CommandId.make("cmd-turn-start-unsupported-1"), | |
| 761 | threadId: ThreadId.make("thread-1"), | |
| 762 | message: { | |
| 763 | messageId: asMessageId("user-message-unsupported-1"), | |
| 764 | role: "user", | |
| 765 | text: "first", | |
| 766 | attachments: [], | |
| 767 | }, | |
| 768 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 769 | runtimeMode: "approval-required", | |
| 770 | createdAt: now, | |
| 771 | }), | |
| 772 | ); | |
| 773 | ||
| 774 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 775 | ||
| 776 | await Effect.runPromise( | |
| 777 | harness.engine.dispatch({ | |
| 778 | type: "thread.turn.start", | |
| 779 | commandId: CommandId.make("cmd-turn-start-unsupported-2"), | |
| 780 | threadId: ThreadId.make("thread-1"), | |
| 781 | message: { | |
| 782 | messageId: asMessageId("user-message-unsupported-2"), | |
| 783 | role: "user", | |
| 784 | text: "second", | |
| 785 | attachments: [], | |
| 786 | }, | |
| 787 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 788 | runtimeMode: "approval-required", | |
| 789 | createdAt: now, | |
| 790 | }), | |
| 791 | ); | |
| 792 | ||
| 793 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | |
| 794 | ||
| 795 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ | |
| 796 | threadId: ThreadId.make("thread-1"), | |
| 797 | modelSelection: { | |
| 798 | provider: "codex", | |
| 799 | model: "gpt-5-codex", | |
| 800 | }, | |
| 801 | }); | |
| 802 | }); | |
| 803 | ||
| 753 | 804 | it("rejects a first turn when requested provider conflicts with the thread model", async () => { |
| 754 | 805 | const harness = await createHarness({ |
| 755 | 806 | threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, |
| ... | ... | @@ -802,57 +853,6 @@ describe("ProviderCommandReactor", () => { |
| 802 | 853 | }); |
| 803 | 854 | }); |
| 804 | 855 | |
| 805 | it("preserves the active session model when in-session model switching is unsupported", async () => { | |
| 806 | const harness = await createHarness({ sessionModelSwitch: "unsupported" }); | |
| 807 | const now = new Date().toISOString(); | |
| 808 | ||
| 809 | await Effect.runPromise( | |
| 810 | harness.engine.dispatch({ | |
| 811 | type: "thread.turn.start", | |
| 812 | commandId: CommandId.make("cmd-turn-start-unsupported-1"), | |
| 813 | threadId: ThreadId.make("thread-1"), | |
| 814 | message: { | |
| 815 | messageId: asMessageId("user-message-unsupported-1"), | |
| 816 | role: "user", | |
| 817 | text: "first", | |
| 818 | attachments: [], | |
| 819 | }, | |
| 820 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 821 | runtimeMode: "approval-required", | |
| 822 | createdAt: now, | |
| 823 | }), | |
| 824 | ); | |
| 825 | ||
| 826 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 827 | ||
| 828 | await Effect.runPromise( | |
| 829 | harness.engine.dispatch({ | |
| 830 | type: "thread.turn.start", | |
| 831 | commandId: CommandId.make("cmd-turn-start-unsupported-2"), | |
| 832 | threadId: ThreadId.make("thread-1"), | |
| 833 | message: { | |
| 834 | messageId: asMessageId("user-message-unsupported-2"), | |
| 835 | role: "user", | |
| 836 | text: "second", | |
| 837 | attachments: [], | |
| 838 | }, | |
| 839 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 840 | runtimeMode: "approval-required", | |
| 841 | createdAt: now, | |
| 842 | }), | |
| 843 | ); | |
| 844 | ||
| 845 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | |
| 846 | ||
| 847 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ | |
| 848 | threadId: ThreadId.make("thread-1"), | |
| 849 | modelSelection: { | |
| 850 | provider: "codex", | |
| 851 | model: "gpt-5-codex", | |
| 852 | }, | |
| 853 | }); | |
| 854 | }); | |
| 855 | ||
| 856 | 856 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 857 | 857 | const harness = await createHarness(); |
| 858 | 858 | const now = new Date().toISOString(); |
| ... | ... | @@ -1100,23 +1100,33 @@ describe("ProviderCommandReactor", () => { |
| 1100 | 1100 | }); |
| 1101 | 1101 | }); |
| 1102 | 1102 | |
| 1103 | it("rejects provider changes after a thread is already bound to a session provider", async () => { | |
| 1103 | it("does not stop the active session when restart fails before rebind", async () => { | |
| 1104 | 1104 | const harness = await createHarness(); |
| 1105 | 1105 | const now = new Date().toISOString(); |
| 1106 | 1106 | |
| 1107 | await Effect.runPromise( | |
| 1108 | harness.engine.dispatch({ | |
| 1109 | type: "thread.runtime-mode.set", | |
| 1110 | commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), | |
| 1111 | threadId: ThreadId.make("thread-1"), | |
| 1112 | runtimeMode: "full-access", | |
| 1113 | createdAt: now, | |
| 1114 | }), | |
| 1115 | ); | |
| 1116 | ||
| 1107 | 1117 | await Effect.runPromise( |
| 1108 | 1118 | harness.engine.dispatch({ |
| 1109 | 1119 | type: "thread.turn.start", |
| 1110 | commandId: CommandId.make("cmd-turn-start-provider-switch-1"), | |
| 1120 | commandId: CommandId.make("cmd-turn-start-restart-failure-1"), | |
| 1111 | 1121 | threadId: ThreadId.make("thread-1"), |
| 1112 | 1122 | message: { |
| 1113 | messageId: asMessageId("user-message-provider-switch-1"), | |
| 1123 | messageId: asMessageId("user-message-restart-failure-1"), | |
| 1114 | 1124 | role: "user", |
| 1115 | 1125 | text: "first", |
| 1116 | 1126 | attachments: [], |
| 1117 | 1127 | }, |
| 1118 | 1128 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1119 | runtimeMode: "approval-required", | |
| 1129 | runtimeMode: "full-access", | |
| 1120 | 1130 | createdAt: now, |
| 1121 | 1131 | }), |
| 1122 | 1132 | ); |
| ... | ... | @@ -1124,22 +1134,15 @@ describe("ProviderCommandReactor", () => { |
| 1124 | 1134 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 1125 | 1135 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 1126 | 1136 | |
| 1137 | harness.startSession.mockImplementationOnce( | |
| 1138 | (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, | |
| 1139 | ); | |
| 1140 | ||
| 1127 | 1141 | await Effect.runPromise( |
| 1128 | 1142 | harness.engine.dispatch({ |
| 1129 | type: "thread.turn.start", | |
| 1130 | commandId: CommandId.make("cmd-turn-start-provider-switch-2"), | |
| 1143 | type: "thread.runtime-mode.set", | |
| 1144 | commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), | |
| 1131 | 1145 | threadId: ThreadId.make("thread-1"), |
| 1132 | message: { | |
| 1133 | messageId: asMessageId("user-message-provider-switch-2"), | |
| 1134 | role: "user", | |
| 1135 | text: "second", | |
| 1136 | attachments: [], | |
| 1137 | }, | |
| 1138 | modelSelection: { | |
| 1139 | provider: "claudeAgent", | |
| 1140 | model: "claude-opus-4-6", | |
| 1141 | }, | |
| 1142 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 1143 | 1146 | runtimeMode: "approval-required", |
| 1144 | 1147 | createdAt: now, |
| 1145 | 1148 | }), |
| ... | ... | @@ -1148,57 +1151,37 @@ describe("ProviderCommandReactor", () => { |
| 1148 | 1151 | await waitFor(async () => { |
| 1149 | 1152 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 1150 | 1153 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 1151 | return ( | |
| 1152 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | |
| 1153 | false | |
| 1154 | ); | |
| 1154 | return thread?.runtimeMode === "approval-required"; | |
| 1155 | 1155 | }); |
| 1156 | await waitFor(() => harness.startSession.mock.calls.length === 2); | |
| 1157 | await harness.drain(); | |
| 1156 | 1158 | |
| 1157 | expect(harness.startSession.mock.calls.length).toBe(1); | |
| 1158 | expect(harness.sendTurn.mock.calls.length).toBe(1); | |
| 1159 | 1159 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 1160 | expect(harness.sendTurn.mock.calls.length).toBe(1); | |
| 1160 | 1161 | |
| 1161 | 1162 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 1162 | 1163 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 1163 | 1164 | expect(thread?.session?.threadId).toBe("thread-1"); |
| 1164 | expect(thread?.session?.providerName).toBe("codex"); | |
| 1165 | expect(thread?.session?.runtimeMode).toBe("approval-required"); | |
| 1166 | expect( | |
| 1167 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | |
| 1168 | ).toMatchObject({ | |
| 1169 | payload: { | |
| 1170 | detail: expect.stringContaining("cannot switch to 'claudeAgent'"), | |
| 1171 | }, | |
| 1172 | }); | |
| 1165 | expect(thread?.session?.runtimeMode).toBe("full-access"); | |
| 1173 | 1166 | }); |
| 1174 | 1167 | |
| 1175 | it("does not stop the active session when restart fails before rebind", async () => { | |
| 1168 | it("rejects provider changes after a thread is already bound to a session provider", async () => { | |
| 1176 | 1169 | const harness = await createHarness(); |
| 1177 | 1170 | const now = new Date().toISOString(); |
| 1178 | 1171 | |
| 1179 | await Effect.runPromise( | |
| 1180 | harness.engine.dispatch({ | |
| 1181 | type: "thread.runtime-mode.set", | |
| 1182 | commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), | |
| 1183 | threadId: ThreadId.make("thread-1"), | |
| 1184 | runtimeMode: "full-access", | |
| 1185 | createdAt: now, | |
| 1186 | }), | |
| 1187 | ); | |
| 1188 | ||
| 1189 | 1172 | await Effect.runPromise( |
| 1190 | 1173 | harness.engine.dispatch({ |
| 1191 | 1174 | type: "thread.turn.start", |
| 1192 | commandId: CommandId.make("cmd-turn-start-restart-failure-1"), | |
| 1175 | commandId: CommandId.make("cmd-turn-start-provider-switch-1"), | |
| 1193 | 1176 | threadId: ThreadId.make("thread-1"), |
| 1194 | 1177 | message: { |
| 1195 | messageId: asMessageId("user-message-restart-failure-1"), | |
| 1178 | messageId: asMessageId("user-message-provider-switch-1"), | |
| 1196 | 1179 | role: "user", |
| 1197 | 1180 | text: "first", |
| 1198 | 1181 | attachments: [], |
| 1199 | 1182 | }, |
| 1200 | 1183 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1201 | runtimeMode: "full-access", | |
| 1184 | runtimeMode: "approval-required", | |
| 1202 | 1185 | createdAt: now, |
| 1203 | 1186 | }), |
| 1204 | 1187 | ); |
| ... | ... | @@ -1206,15 +1189,22 @@ describe("ProviderCommandReactor", () => { |
| 1206 | 1189 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 1207 | 1190 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 1208 | 1191 | |
| 1209 | harness.startSession.mockImplementationOnce( | |
| 1210 | (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, | |
| 1211 | ); | |
| 1212 | ||
| 1213 | 1192 | await Effect.runPromise( |
| 1214 | 1193 | harness.engine.dispatch({ |
| 1215 | type: "thread.runtime-mode.set", | |
| 1216 | commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), | |
| 1194 | type: "thread.turn.start", | |
| 1195 | commandId: CommandId.make("cmd-turn-start-provider-switch-2"), | |
| 1217 | 1196 | threadId: ThreadId.make("thread-1"), |
| 1197 | message: { | |
| 1198 | messageId: asMessageId("user-message-provider-switch-2"), | |
| 1199 | role: "user", | |
| 1200 | text: "second", | |
| 1201 | attachments: [], | |
| 1202 | }, | |
| 1203 | modelSelection: { | |
| 1204 | provider: "claudeAgent", | |
| 1205 | model: "claude-opus-4-6", | |
| 1206 | }, | |
| 1207 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 1218 | 1208 | runtimeMode: "approval-required", |
| 1219 | 1209 | createdAt: now, |
| 1220 | 1210 | }), |
| ... | ... | @@ -1223,18 +1213,28 @@ describe("ProviderCommandReactor", () => { |
| 1223 | 1213 | await waitFor(async () => { |
| 1224 | 1214 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 1225 | 1215 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 1226 | return thread?.runtimeMode === "approval-required"; | |
| 1216 | return ( | |
| 1217 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | |
| 1218 | false | |
| 1219 | ); | |
| 1227 | 1220 | }); |
| 1228 | await waitFor(() => harness.startSession.mock.calls.length === 2); | |
| 1229 | await harness.drain(); | |
| 1230 | 1221 | |
| 1231 | expect(harness.stopSession.mock.calls.length).toBe(0); | |
| 1222 | expect(harness.startSession.mock.calls.length).toBe(1); | |
| 1232 | 1223 | expect(harness.sendTurn.mock.calls.length).toBe(1); |
| 1224 | expect(harness.stopSession.mock.calls.length).toBe(0); | |
| 1233 | 1225 | |
| 1234 | 1226 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 1235 | 1227 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| 1236 | 1228 | expect(thread?.session?.threadId).toBe("thread-1"); |
| 1237 | expect(thread?.session?.runtimeMode).toBe("full-access"); | |
| 1229 | expect(thread?.session?.providerName).toBe("codex"); | |
| 1230 | expect(thread?.session?.runtimeMode).toBe("approval-required"); | |
| 1231 | expect( | |
| 1232 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | |
| 1233 | ).toMatchObject({ | |
| 1234 | payload: { | |
| 1235 | detail: expect.stringContaining("cannot switch to 'claudeAgent'"), | |
| 1236 | }, | |
| 1237 | }); | |
| 1238 | 1238 | }); |
| 1239 | 1239 | |
| 1240 | 1240 | it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+4-10| ... | ... | @@ -247,7 +247,7 @@ const make = Effect.gen(function* () { |
| 247 | 247 | detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, |
| 248 | 248 | }); |
| 249 | 249 | } |
| 250 | const preferredProvider: ProviderKind = currentProvider ?? threadProvider; | |
| 250 | const preferredProvider: ProviderKind = threadProvider; | |
| 251 | 251 | const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; |
| 252 | 252 | const effectiveCwd = resolveThreadWorkspaceCwd({ |
| 253 | 253 | thread, |
| ... | ... | @@ -293,9 +293,6 @@ const make = Effect.gen(function* () { |
| 293 | 293 | thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; |
| 294 | 294 | if (existingSessionThreadId) { |
| 295 | 295 | const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; |
| 296 | const providerChanged = | |
| 297 | requestedModelSelection !== undefined && | |
| 298 | requestedModelSelection.provider !== currentProvider; | |
| 299 | 296 | const sessionModelSwitch = |
| 300 | 297 | currentProvider === undefined |
| 301 | 298 | ? "in-session" |
| ... | ... | @@ -312,17 +309,15 @@ const make = Effect.gen(function* () { |
| 312 | 309 | |
| 313 | 310 | if ( |
| 314 | 311 | !runtimeModeChanged && |
| 315 | !providerChanged && | |
| 316 | 312 | !shouldRestartForModelChange && |
| 317 | 313 | !shouldRestartForModelSelectionChange |
| 318 | 314 | ) { |
| 319 | 315 | return existingSessionThreadId; |
| 320 | 316 | } |
| 321 | 317 | |
| 322 | const resumeCursor = | |
| 323 | providerChanged || shouldRestartForModelChange | |
| 324 | ? undefined | |
| 325 | : (activeSession?.resumeCursor ?? undefined); | |
| 318 | const resumeCursor = shouldRestartForModelChange | |
| 319 | ? undefined | |
| 320 | : (activeSession?.resumeCursor ?? undefined); | |
| 326 | 321 | yield* Effect.logInfo("provider command reactor restarting provider session", { |
| 327 | 322 | threadId, |
| 328 | 323 | existingSessionThreadId, |
| ... | ... | @@ -331,7 +326,6 @@ const make = Effect.gen(function* () { |
| 331 | 326 | currentRuntimeMode: thread.session?.runtimeMode, |
| 332 | 327 | desiredRuntimeMode: thread.runtimeMode, |
| 333 | 328 | runtimeModeChanged, |
| 334 | providerChanged, | |
| 335 | 329 | modelChanged, |
| 336 | 330 | shouldRestartForModelChange, |
| 337 | 331 | shouldRestartForModelSelectionChange, |
apps/server/src/provider/Layers/CodexAdapter.test.ts-1| ... | ... | @@ -144,7 +144,6 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory |
| 144 | 144 | getProvider: () => |
| 145 | 145 | Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), |
| 146 | 146 | getBinding: () => Effect.succeed(Option.none()), |
| 147 | remove: () => Effect.void, | |
| 148 | 147 | listThreadIds: () => Effect.succeed([]), |
| 149 | 148 | listBindings: () => Effect.succeed([]), |
| 150 | 149 | }); |
apps/server/src/provider/Layers/ProviderService.test.ts+76-15| ... | ... | @@ -18,7 +18,6 @@ import { |
| 18 | 18 | TurnId, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | import { it, assert, vi } from "@effect/vitest"; |
| 21 | import { assertFailure } from "@effect/vitest/utils"; | |
| 22 | 21 | |
| 23 | 22 | import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; |
| 24 | 23 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | ... | @@ -574,20 +573,31 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 574 | 573 | }); |
| 575 | 574 | |
| 576 | 575 | yield* provider.stopSession({ threadId: session.threadId }); |
| 577 | const sendAfterStop = yield* Effect.result( | |
| 578 | provider.sendTurn({ | |
| 579 | threadId: session.threadId, | |
| 580 | input: "after-stop", | |
| 581 | attachments: [], | |
| 582 | }), | |
| 583 | ); | |
| 584 | assertFailure( | |
| 585 | sendAfterStop, | |
| 586 | new ProviderValidationError({ | |
| 587 | operation: "ProviderService.sendTurn", | |
| 588 | issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`, | |
| 589 | }), | |
| 590 | ); | |
| 576 | routing.codex.startSession.mockClear(); | |
| 577 | routing.codex.sendTurn.mockClear(); | |
| 578 | ||
| 579 | yield* provider.sendTurn({ | |
| 580 | threadId: session.threadId, | |
| 581 | input: "after-stop", | |
| 582 | attachments: [], | |
| 583 | }); | |
| 584 | ||
| 585 | assert.equal(routing.codex.startSession.mock.calls.length, 1); | |
| 586 | const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; | |
| 587 | assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); | |
| 588 | if (resumedStartInput && typeof resumedStartInput === "object") { | |
| 589 | const startPayload = resumedStartInput as { | |
| 590 | provider?: string; | |
| 591 | cwd?: string; | |
| 592 | resumeCursor?: unknown; | |
| 593 | threadId?: string; | |
| 594 | }; | |
| 595 | assert.equal(startPayload.provider, "codex"); | |
| 596 | assert.equal(startPayload.cwd, "/tmp/project"); | |
| 597 | assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); | |
| 598 | assert.equal(startPayload.threadId, session.threadId); | |
| 599 | } | |
| 600 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); | |
| 591 | 601 | }), |
| 592 | 602 | ); |
| 593 | 603 | |
| ... | ... | @@ -631,6 +641,57 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 631 | 641 | }), |
| 632 | 642 | ); |
| 633 | 643 | |
| 644 | it.effect("preserves the persisted binding when stopping a session", () => | |
| 645 | Effect.gen(function* () { | |
| 646 | const provider = yield* ProviderService; | |
| 647 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 648 | ||
| 649 | const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { | |
| 650 | provider: "codex", | |
| 651 | threadId: asThreadId("thread-reap-preserve"), | |
| 652 | cwd: "/tmp/project-reap-preserve", | |
| 653 | runtimeMode: "full-access", | |
| 654 | }); | |
| 655 | ||
| 656 | yield* provider.stopSession({ threadId: initial.threadId }); | |
| 657 | ||
| 658 | const persistedAfterStop = yield* runtimeRepository.getByThreadId({ | |
| 659 | threadId: initial.threadId, | |
| 660 | }); | |
| 661 | assert.equal(Option.isSome(persistedAfterStop), true); | |
| 662 | if (Option.isSome(persistedAfterStop)) { | |
| 663 | assert.equal(persistedAfterStop.value.status, "stopped"); | |
| 664 | assert.deepEqual(persistedAfterStop.value.resumeCursor, initial.resumeCursor); | |
| 665 | } | |
| 666 | ||
| 667 | routing.codex.startSession.mockClear(); | |
| 668 | routing.codex.sendTurn.mockClear(); | |
| 669 | ||
| 670 | yield* provider.sendTurn({ | |
| 671 | threadId: initial.threadId, | |
| 672 | input: "resume after reap", | |
| 673 | attachments: [], | |
| 674 | }); | |
| 675 | ||
| 676 | assert.equal(routing.codex.startSession.mock.calls.length, 1); | |
| 677 | const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; | |
| 678 | assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); | |
| 679 | if (resumedStartInput && typeof resumedStartInput === "object") { | |
| 680 | const startPayload = resumedStartInput as { | |
| 681 | provider?: string; | |
| 682 | cwd?: string; | |
| 683 | resumeCursor?: unknown; | |
| 684 | threadId?: string; | |
| 685 | }; | |
| 686 | assert.equal(startPayload.provider, "codex"); | |
| 687 | assert.equal(startPayload.cwd, "/tmp/project-reap-preserve"); | |
| 688 | assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); | |
| 689 | assert.equal(startPayload.threadId, initial.threadId); | |
| 690 | } | |
| 691 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); | |
| 692 | }), | |
| 693 | ); | |
| 694 | ||
| 634 | 695 | it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => |
| 635 | 696 | Effect.gen(function* () { |
| 636 | 697 | const provider = yield* ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+8-1| ... | ... | @@ -620,7 +620,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 620 | 620 | if (routed.isActive) { |
| 621 | 621 | yield* routed.adapter.stopSession(routed.threadId); |
| 622 | 622 | } |
| 623 | yield* directory.remove(input.threadId); | |
| 623 | yield* directory.upsert({ | |
| 624 | threadId: input.threadId, | |
| 625 | provider: routed.adapter.provider, | |
| 626 | status: "stopped", | |
| 627 | runtimePayload: { | |
| 628 | activeTurnId: null, | |
| 629 | }, | |
| 630 | }); | |
| 624 | 631 | yield* analytics.record("provider.session.stopped", { |
| 625 | 632 | provider: routed.adapter.provider, |
| 626 | 633 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-13| ... | ... | @@ -5,7 +5,7 @@ import path from "node:path"; |
| 5 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | 7 | import { it, assert } from "@effect/vitest"; |
| 8 | import { assertFailure, assertSome } from "@effect/vitest/utils"; | |
| 8 | import { assertSome } from "@effect/vitest/utils"; | |
| 9 | 9 | import { Effect, Layer, Option } from "effect"; |
| 10 | 10 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 11 | 11 | |
| ... | ... | @@ -15,7 +15,6 @@ import { |
| 15 | 15 | } from "../../persistence/Layers/Sqlite.ts"; |
| 16 | 16 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; |
| 17 | 17 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| 18 | import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; | |
| 19 | 18 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 20 | 19 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 21 | 20 | |
| ... | ... | @@ -31,7 +30,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli |
| 31 | 30 | } |
| 32 | 31 | |
| 33 | 32 | it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { |
| 34 | it("upserts, reads, and removes thread bindings", () => | |
| 33 | it("upserts and reads thread bindings", () => | |
| 35 | 34 | Effect.gen(function* () { |
| 36 | 35 | const directory = yield* ProviderSessionDirectory; |
| 37 | 36 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; |
| ... | ... | @@ -76,16 +75,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 76 | 75 | |
| 77 | 76 | const threadIds = yield* directory.listThreadIds(); |
| 78 | 77 | assert.deepEqual(threadIds, [nextThreadId]); |
| 79 | ||
| 80 | yield* directory.remove(nextThreadId); | |
| 81 | const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result); | |
| 82 | assertFailure( | |
| 83 | missingProvider, | |
| 84 | new ProviderSessionDirectoryPersistenceError({ | |
| 85 | operation: "ProviderSessionDirectory.getProvider", | |
| 86 | detail: `No persisted provider binding found for thread '${nextThreadId}'.`, | |
| 87 | }), | |
| 88 | ); | |
| 89 | 78 | })); |
| 90 | 79 | |
| 91 | 80 | it("persists runtime fields and merges payload updates", () => |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts-8| ... | ... | @@ -145,13 +145,6 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 145 | 145 | ), |
| 146 | 146 | ); |
| 147 | 147 | |
| 148 | const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => | |
| 149 | repository | |
| 150 | .deleteByThreadId({ threadId }) | |
| 151 | .pipe( | |
| 152 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), | |
| 153 | ); | |
| 154 | ||
| 155 | 148 | const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => |
| 156 | 149 | repository.list().pipe( |
| 157 | 150 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), |
| ... | ... | @@ -174,7 +167,6 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 174 | 167 | upsert, |
| 175 | 168 | getProvider, |
| 176 | 169 | getBinding, |
| 177 | remove, | |
| 178 | 170 | listThreadIds, |
| 179 | 171 | listBindings, |
| 180 | 172 | } satisfies ProviderSessionDirectoryShape; |
apps/server/src/provider/Services/ProviderSessionDirectory.ts-4| ... | ... | @@ -45,10 +45,6 @@ export interface ProviderSessionDirectoryShape { |
| 45 | 45 | threadId: ThreadId, |
| 46 | 46 | ) => Effect.Effect<Option.Option<ProviderRuntimeBinding>, ProviderSessionDirectoryReadError>; |
| 47 | 47 | |
| 48 | readonly remove: ( | |
| 49 | threadId: ThreadId, | |
| 50 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; | |
| 51 | ||
| 52 | 48 | readonly listThreadIds: () => Effect.Effect< |
| 53 | 49 | ReadonlyArray<ThreadId>, |
| 54 | 50 | ProviderSessionDirectoryPersistenceError |