| ... | ... | @@ -175,6 +175,8 @@ describe("ProviderCommandReactor", () => { |
| 175 | 175 | readonly titleRegenerationBeforeStart?: "one" | "two"; |
| 176 | 176 | readonly serverActivation?: Effect.Effect<void>; |
| 177 | 177 | readonly beforeReadySessionDispatch?: () => Effect.Effect<void>; |
| 178 | readonly beforeTurnStartDispatch?: () => Effect.Effect<void>; |
| 179 | readonly afterTurnStartDispatch?: () => Effect.Effect<void>; |
| 178 | 180 | readonly compactThreadEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 179 | 181 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 180 | 182 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| ... | ... | @@ -431,11 +433,21 @@ describe("ProviderCommandReactor", () => { |
| 431 | 433 | return Effect.die(new Error("Injected title regeneration completion failure")); |
| 432 | 434 | } |
| 433 | 435 | } |
| 434 | | return ( |
| 436 | const isReplay = |
| 437 | command.type === "thread.turn.start" && |
| 438 | command.commandId.startsWith("server:after-compaction:"); |
| 439 | const before = |
| 435 | 440 | command.type === "thread.session.set" && command.session.status === "ready" |
| 436 | | ? (input?.beforeReadySessionDispatch?.() ?? Effect.void) |
| 437 | | : Effect.void |
| 438 | | ).pipe(Effect.andThen(engine.dispatch(command))); |
| 441 | ? input?.beforeReadySessionDispatch |
| 442 | : isReplay |
| 443 | ? input?.beforeTurnStartDispatch |
| 444 | : undefined; |
| 445 | return (before?.() ?? Effect.void).pipe( |
| 446 | Effect.andThen(engine.dispatch(command)), |
| 447 | Effect.tap(() => |
| 448 | isReplay ? (input?.afterTurnStartDispatch?.() ?? Effect.void) : Effect.void, |
| 449 | ), |
| 450 | ); |
| 439 | 451 | }, |
| 440 | 452 | get streamDomainEvents() { |
| 441 | 453 | return engine.streamDomainEvents; |
| ... | ... | @@ -973,86 +985,203 @@ describe("ProviderCommandReactor", () => { |
| 973 | 985 | }), |
| 974 | 986 | ); |
| 975 | 987 | |
| 976 | | effectIt.effect("keeps turns blocked until compaction restores the session", () => |
| 977 | | Effect.gen(function* () { |
| 978 | | const readyDispatchStarted = yield* Deferred.make<void>(); |
| 979 | | const releaseReadyDispatch = yield* Deferred.make<void>(); |
| 980 | | let blockReadyDispatch = false; |
| 981 | | const harness = yield* Effect.promise(() => |
| 982 | | createHarness({ |
| 983 | | beforeReadySessionDispatch: () => |
| 984 | | blockReadyDispatch |
| 985 | | ? Deferred.succeed(readyDispatchStarted, undefined).pipe( |
| 986 | | Effect.andThen(Deferred.await(releaseReadyDispatch)), |
| 987 | | ) |
| 988 | | : Effect.void, |
| 989 | | }), |
| 990 | | ); |
| 991 | | const threadId = ThreadId.make("thread-1"); |
| 992 | | const now = "2026-01-01T00:00:00.000Z"; |
| 993 | | const dispatchTurn = (id: string, text: string, createdAt: string) => |
| 994 | | harness.engine.dispatch({ |
| 995 | | type: "thread.turn.start", |
| 996 | | commandId: CommandId.make(`cmd-${id}`), |
| 988 | effectIt.effect.each(["resume", "stop before resume", "stop after send"])( |
| 989 | "queues messages until compaction restores the session (%s)", |
| 990 | (scenario) => |
| 991 | Effect.gen(function* () { |
| 992 | const stopBeforeResume = scenario === "stop before resume"; |
| 993 | const readyDispatchStarted = yield* Deferred.make<void>(); |
| 994 | const releaseReadyDispatch = yield* Deferred.make<void>(); |
| 995 | const firstSent = yield* Deferred.make<void>(); |
| 996 | const queuedSent = yield* Deferred.make<void>(); |
| 997 | const resumeStarted = yield* Deferred.make<void>(); |
| 998 | const releaseResume = yield* Deferred.make<void>(); |
| 999 | const resumeDispatched = yield* Deferred.make<void>(); |
| 1000 | const queuedSendStarted = yield* Deferred.make<void>(); |
| 1001 | const releaseQueuedSend = yield* Deferred.make<void>(); |
| 1002 | let blockReadyDispatch = false; |
| 1003 | const harness = yield* Effect.promise(() => |
| 1004 | createHarness({ |
| 1005 | beforeTurnStartDispatch: () => |
| 1006 | stopBeforeResume |
| 1007 | ? Deferred.succeed(resumeStarted, undefined).pipe( |
| 1008 | Effect.andThen(Deferred.await(releaseResume)), |
| 1009 | ) |
| 1010 | : Effect.void, |
| 1011 | afterTurnStartDispatch: () => Deferred.succeed(resumeDispatched, undefined), |
| 1012 | beforeReadySessionDispatch: () => |
| 1013 | blockReadyDispatch |
| 1014 | ? Deferred.succeed(readyDispatchStarted, undefined).pipe( |
| 1015 | Effect.andThen(Deferred.await(releaseReadyDispatch)), |
| 1016 | ) |
| 1017 | : Effect.void, |
| 1018 | }), |
| 1019 | ); |
| 1020 | const threadId = ThreadId.make("thread-1"); |
| 1021 | let sentCount = 0; |
| 1022 | harness.sendTurn.mockImplementation(() => |
| 1023 | Effect.succeed({ threadId, turnId: asTurnId("turn-1") }).pipe( |
| 1024 | Effect.tap(() => { |
| 1025 | sentCount++; |
| 1026 | return sentCount === 1 |
| 1027 | ? Deferred.succeed(firstSent, undefined) |
| 1028 | : sentCount === 2 && scenario === "stop after send" |
| 1029 | ? Deferred.succeed(queuedSendStarted, undefined).pipe( |
| 1030 | Effect.andThen(Deferred.await(releaseQueuedSend)), |
| 1031 | ) |
| 1032 | : sentCount === 3 |
| 1033 | ? Deferred.succeed(queuedSent, undefined) |
| 1034 | : Effect.void; |
| 1035 | }), |
| 1036 | ), |
| 1037 | ); |
| 1038 | const now = "2026-01-01T00:00:00.000Z"; |
| 1039 | const dispatchTurn = (id: string, text: string, createdAt: string) => |
| 1040 | harness.engine.dispatch({ |
| 1041 | type: "thread.turn.start", |
| 1042 | commandId: CommandId.make(`cmd-${id}`), |
| 1043 | threadId, |
| 1044 | message: { |
| 1045 | messageId: asMessageId(`user-message-${id}`), |
| 1046 | role: "user", |
| 1047 | text, |
| 1048 | attachments: [], |
| 1049 | }, |
| 1050 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1051 | runtimeMode: "approval-required", |
| 1052 | createdAt, |
| 1053 | }); |
| 1054 | |
| 1055 | yield* dispatchTurn("before-blocked-compact", "hello", now); |
| 1056 | yield* Deferred.await(firstSent); |
| 1057 | yield* harness.engine.dispatch({ |
| 1058 | type: "thread.session.set", |
| 1059 | commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), |
| 997 | 1060 | threadId, |
| 998 | | message: { |
| 999 | | messageId: asMessageId(`user-message-${id}`), |
| 1000 | | role: "user", |
| 1001 | | text, |
| 1002 | | attachments: [], |
| 1061 | session: { |
| 1062 | threadId, |
| 1063 | status: "ready", |
| 1064 | providerName: "codex", |
| 1065 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1066 | runtimeMode: "approval-required", |
| 1067 | activeTurnId: null, |
| 1068 | lastError: null, |
| 1069 | updatedAt: now, |
| 1003 | 1070 | }, |
| 1004 | | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1005 | | runtimeMode: "approval-required", |
| 1006 | | createdAt, |
| 1071 | createdAt: now, |
| 1007 | 1072 | }); |
| 1008 | 1073 | |
| 1009 | | yield* dispatchTurn("before-blocked-compact", "hello", now); |
| 1010 | | yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); |
| 1011 | | yield* harness.engine.dispatch({ |
| 1012 | | type: "thread.session.set", |
| 1013 | | commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), |
| 1014 | | threadId, |
| 1015 | | session: { |
| 1016 | | threadId, |
| 1017 | | status: "ready", |
| 1018 | | providerName: "codex", |
| 1019 | | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1020 | | runtimeMode: "approval-required", |
| 1021 | | activeTurnId: null, |
| 1022 | | lastError: null, |
| 1023 | | updatedAt: now, |
| 1024 | | }, |
| 1025 | | createdAt: now, |
| 1026 | | }); |
| 1027 | | |
| 1028 | | blockReadyDispatch = true; |
| 1029 | | yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); |
| 1030 | | yield* Deferred.await(readyDispatchStarted); |
| 1074 | blockReadyDispatch = true; |
| 1075 | yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); |
| 1076 | yield* Deferred.await(readyDispatchStarted); |
| 1031 | 1077 | |
| 1032 | | yield* dispatchTurn("during-compact-recovery", "too soon", "2026-01-01T00:00:02.000Z"); |
| 1033 | | yield* Effect.promise(() => |
| 1034 | | waitFor(async () => { |
| 1035 | | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); |
| 1036 | | return ( |
| 1037 | | thread?.activities.some( |
| 1038 | | (activity) => activity.kind === "provider.turn.start.failed", |
| 1039 | | ) === true |
| 1078 | yield* harness.engine.dispatch({ |
| 1079 | type: "thread.interaction-mode.set", |
| 1080 | commandId: CommandId.make("cmd-queued-mode-plan"), |
| 1081 | threadId, |
| 1082 | interactionMode: "plan", |
| 1083 | createdAt: now, |
| 1084 | }); |
| 1085 | yield* dispatchTurn("during-compact-recovery", "first queued", "2026-01-01T00:00:02.000Z"); |
| 1086 | yield* harness.engine.dispatch({ |
| 1087 | type: "thread.interaction-mode.set", |
| 1088 | commandId: CommandId.make("cmd-queued-mode-default"), |
| 1089 | threadId, |
| 1090 | interactionMode: "default", |
| 1091 | createdAt: now, |
| 1092 | }); |
| 1093 | yield* dispatchTurn( |
| 1094 | "during-compact-recovery-2", |
| 1095 | "second queued", |
| 1096 | "2026-01-01T00:00:03.000Z", |
| 1097 | ); |
| 1098 | yield* Effect.promise(() => harness.drain()); |
| 1099 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); |
| 1100 | const beforeRestore = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| 1101 | (entry) => entry.id === threadId, |
| 1102 | ); |
| 1103 | expect( |
| 1104 | beforeRestore?.activities.filter( |
| 1105 | (activity) => activity.kind === "provider.turn.start.failed", |
| 1106 | ), |
| 1107 | ).toEqual([]); |
| 1108 | expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ |
| 1109 | { threadId: "thread-1" }, |
| 1110 | ]); |
| 1111 | |
| 1112 | yield* Deferred.succeed(releaseReadyDispatch, undefined); |
| 1113 | if (scenario === "stop after send") { |
| 1114 | yield* Deferred.await(queuedSendStarted); |
| 1115 | yield* harness.engine.dispatch({ |
| 1116 | type: "thread.session.stop", |
| 1117 | commandId: CommandId.make("cmd-stop-after-queued-send"), |
| 1118 | threadId, |
| 1119 | createdAt: "2026-01-01T00:00:04.000Z", |
| 1120 | }); |
| 1121 | yield* Effect.promise(() => harness.drain()); |
| 1122 | const stoppedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| 1123 | (entry) => entry.id === threadId, |
| 1040 | 1124 | ); |
| 1041 | | }), |
| 1042 | | ); |
| 1043 | | expect(harness.sendTurn).toHaveBeenCalledTimes(1); |
| 1044 | | expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ |
| 1045 | | { threadId: "thread-1" }, |
| 1046 | | ]); |
| 1047 | | |
| 1048 | | yield* Deferred.succeed(releaseReadyDispatch, undefined); |
| 1049 | | yield* Effect.promise(() => |
| 1050 | | waitFor(async () => { |
| 1051 | | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); |
| 1052 | | return thread?.session?.status === "ready"; |
| 1053 | | }), |
| 1054 | | ); |
| 1055 | | }), |
| 1125 | expect(stoppedThread?.session?.status).toBe("stopped"); |
| 1126 | expect( |
| 1127 | stoppedThread?.activities.filter( |
| 1128 | (activity) => activity.summary === "Queued message was not sent", |
| 1129 | ), |
| 1130 | ).toEqual([ |
| 1131 | expect.objectContaining({ |
| 1132 | payload: { |
| 1133 | requestId: "user-message-during-compact-recovery-2", |
| 1134 | detail: expect.any(String), |
| 1135 | }, |
| 1136 | }), |
| 1137 | ]); |
| 1138 | expect(harness.sendTurn).toHaveBeenCalledTimes(2); |
| 1139 | yield* Deferred.succeed(releaseQueuedSend, undefined); |
| 1140 | return; |
| 1141 | } |
| 1142 | if (stopBeforeResume) { |
| 1143 | yield* Deferred.await(resumeStarted); |
| 1144 | yield* dispatchTurn("compact-during-resume", "/compact", "2026-01-01T00:00:04.000Z"); |
| 1145 | yield* Effect.promise(() => harness.drain()); |
| 1146 | expect(harness.compactThread).toHaveBeenCalledTimes(1); |
| 1147 | yield* harness.engine.dispatch({ |
| 1148 | type: "thread.session.stop", |
| 1149 | commandId: CommandId.make("cmd-stop-before-queued-resume"), |
| 1150 | threadId, |
| 1151 | createdAt: "2026-01-01T00:00:04.000Z", |
| 1152 | }); |
| 1153 | yield* Effect.promise(() => harness.drain()); |
| 1154 | yield* Deferred.succeed(releaseResume, undefined); |
| 1155 | yield* Deferred.await(resumeDispatched); |
| 1156 | yield* Effect.promise(() => harness.drain()); |
| 1157 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); |
| 1158 | const stoppedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| 1159 | (entry) => entry.id === threadId, |
| 1160 | ); |
| 1161 | expect(stoppedThread?.session?.status).toBe("stopped"); |
| 1162 | expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); |
| 1163 | expect( |
| 1164 | stoppedThread?.activities.filter( |
| 1165 | (activity) => activity.summary === "Queued message was not sent", |
| 1166 | ), |
| 1167 | ).toHaveLength(2); |
| 1168 | return; |
| 1169 | } |
| 1170 | yield* Deferred.await(queuedSent); |
| 1171 | expect(harness.sendTurn.mock.calls.slice(1).map(([request]) => request)).toEqual([ |
| 1172 | expect.objectContaining({ input: "first queued", interactionMode: "plan" }), |
| 1173 | expect.objectContaining({ input: "second queued", interactionMode: "default" }), |
| 1174 | ]); |
| 1175 | const afterRestore = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| 1176 | (entry) => entry.id === threadId, |
| 1177 | ); |
| 1178 | expect( |
| 1179 | afterRestore?.messages.filter((message) => message.text === "first queued"), |
| 1180 | ).toHaveLength(1); |
| 1181 | expect( |
| 1182 | afterRestore?.messages.filter((message) => message.text === "second queued"), |
| 1183 | ).toHaveLength(1); |
| 1184 | }), |
| 1056 | 1185 | ); |
| 1057 | 1186 | |
| 1058 | 1187 | effectIt.effect("does not overwrite concurrent session state after compaction failure", () => |
| ... | ... | @@ -1136,6 +1265,21 @@ describe("ProviderCommandReactor", () => { |
| 1136 | 1265 | (entry) => entry.id === threadId, |
| 1137 | 1266 | ); |
| 1138 | 1267 | expect(compactingThread?.session?.status).toBe("starting"); |
| 1268 | yield* harness.engine.dispatch({ |
| 1269 | type: "thread.turn.start", |
| 1270 | commandId: CommandId.make("cmd-queued-before-stop"), |
| 1271 | threadId, |
| 1272 | message: { |
| 1273 | messageId: asMessageId("user-message-queued-before-stop"), |
| 1274 | role: "user", |
| 1275 | text: "do not restart after stopping", |
| 1276 | attachments: [], |
| 1277 | }, |
| 1278 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1279 | runtimeMode: "approval-required", |
| 1280 | createdAt: now, |
| 1281 | }); |
| 1282 | yield* Effect.promise(() => harness.drain()); |
| 1139 | 1283 | yield* harness.engine.dispatch({ |
| 1140 | 1284 | type: "thread.session.stop", |
| 1141 | 1285 | commandId: CommandId.make("cmd-stop-during-compact"), |
| ... | ... | @@ -1151,7 +1295,7 @@ describe("ProviderCommandReactor", () => { |
| 1151 | 1295 | ); |
| 1152 | 1296 | return ( |
| 1153 | 1297 | compactingThread?.activities.some( |
| 1154 | | (activity) => activity.kind === "provider.turn.start.failed", |
| 1298 | (activity) => activity.summary === "Context compaction failed", |
| 1155 | 1299 | ) === true |
| 1156 | 1300 | ); |
| 1157 | 1301 | }), |
| ... | ... | @@ -1167,6 +1311,14 @@ describe("ProviderCommandReactor", () => { |
| 1167 | 1311 | (entry) => entry.id === threadId, |
| 1168 | 1312 | ); |
| 1169 | 1313 | expect(recoveredThread?.session?.status).toBe("ready"); |
| 1314 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); |
| 1315 | expect( |
| 1316 | recoveredThread?.activities.find( |
| 1317 | (activity) => activity.summary === "Queued message was not sent", |
| 1318 | ), |
| 1319 | ).toMatchObject({ |
| 1320 | payload: { requestId: "user-message-queued-before-stop" }, |
| 1321 | }); |
| 1170 | 1322 | expect( |
| 1171 | 1323 | recoveredThread?.activities.find( |
| 1172 | 1324 | (activity) => activity.kind === "provider.session.stop.failed", |