| ... | @@ -175,6 +175,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -175,6 +175,8 @@ describe("ProviderCommandReactor", () => { |
| 175 | readonly titleRegenerationBeforeStart?: "one" | "two"; | 175 | readonly titleRegenerationBeforeStart?: "one" | "two"; |
| 176 | readonly serverActivation?: Effect.Effect<void>; | 176 | readonly serverActivation?: Effect.Effect<void>; |
| 177 | readonly beforeReadySessionDispatch?: () => Effect.Effect<void>; | 177 | readonly beforeReadySessionDispatch?: () => Effect.Effect<void>; |
| | 178 | readonly beforeTurnStartDispatch?: () => Effect.Effect<void>; |
| | 179 | readonly afterTurnStartDispatch?: () => Effect.Effect<void>; |
| 178 | readonly compactThreadEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; | 180 | readonly compactThreadEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 179 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; | 181 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 180 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; | 182 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| ... | @@ -431,11 +433,21 @@ describe("ProviderCommandReactor", () => { | ... | @@ -431,11 +433,21 @@ describe("ProviderCommandReactor", () => { |
| 431 | return Effect.die(new Error("Injected title regeneration completion failure")); | 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 | command.type === "thread.session.set" && command.session.status === "ready" | 440 | command.type === "thread.session.set" && command.session.status === "ready" |
| 436 | ? (input?.beforeReadySessionDispatch?.() ?? Effect.void) | 441 | ? input?.beforeReadySessionDispatch |
| 437 | : Effect.void | 442 | : isReplay |
| 438 | ).pipe(Effect.andThen(engine.dispatch(command))); | 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 | get streamDomainEvents() { | 452 | get streamDomainEvents() { |
| 441 | return engine.streamDomainEvents; | 453 | return engine.streamDomainEvents; |
| ... | @@ -973,86 +985,203 @@ describe("ProviderCommandReactor", () => { | ... | @@ -973,86 +985,203 @@ describe("ProviderCommandReactor", () => { |
| 973 | }), | 985 | }), |
| 974 | ); | 986 | ); |
| 975 | | 987 | |
| 976 | effectIt.effect("keeps turns blocked until compaction restores the session", () => | 988 | effectIt.effect.each(["resume", "stop before resume", "stop after send"])( |
| 977 | Effect.gen(function* () { | 989 | "queues messages until compaction restores the session (%s)", |
| 978 | const readyDispatchStarted = yield* Deferred.make<void>(); | 990 | (scenario) => |
| 979 | const releaseReadyDispatch = yield* Deferred.make<void>(); | 991 | Effect.gen(function* () { |
| 980 | let blockReadyDispatch = false; | 992 | const stopBeforeResume = scenario === "stop before resume"; |
| 981 | const harness = yield* Effect.promise(() => | 993 | const readyDispatchStarted = yield* Deferred.make<void>(); |
| 982 | createHarness({ | 994 | const releaseReadyDispatch = yield* Deferred.make<void>(); |
| 983 | beforeReadySessionDispatch: () => | 995 | const firstSent = yield* Deferred.make<void>(); |
| 984 | blockReadyDispatch | 996 | const queuedSent = yield* Deferred.make<void>(); |
| 985 | ? Deferred.succeed(readyDispatchStarted, undefined).pipe( | 997 | const resumeStarted = yield* Deferred.make<void>(); |
| 986 | Effect.andThen(Deferred.await(releaseReadyDispatch)), | 998 | const releaseResume = yield* Deferred.make<void>(); |
| 987 | ) | 999 | const resumeDispatched = yield* Deferred.make<void>(); |
| 988 | : Effect.void, | 1000 | const queuedSendStarted = yield* Deferred.make<void>(); |
| 989 | }), | 1001 | const releaseQueuedSend = yield* Deferred.make<void>(); |
| 990 | ); | 1002 | let blockReadyDispatch = false; |
| 991 | const threadId = ThreadId.make("thread-1"); | 1003 | const harness = yield* Effect.promise(() => |
| 992 | const now = "2026-01-01T00:00:00.000Z"; | 1004 | createHarness({ |
| 993 | const dispatchTurn = (id: string, text: string, createdAt: string) => | 1005 | beforeTurnStartDispatch: () => |
| 994 | harness.engine.dispatch({ | 1006 | stopBeforeResume |
| 995 | type: "thread.turn.start", | 1007 | ? Deferred.succeed(resumeStarted, undefined).pipe( |
| 996 | commandId: CommandId.make(`cmd-${id}`), | 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 | threadId, | 1060 | threadId, |
| 998 | message: { | 1061 | session: { |
| 999 | messageId: asMessageId(`user-message-${id}`), | 1062 | threadId, |
| 1000 | role: "user", | 1063 | status: "ready", |
| 1001 | text, | 1064 | providerName: "codex", |
| 1002 | attachments: [], | 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, | 1071 | createdAt: now, |
| 1005 | runtimeMode: "approval-required", | | |
| 1006 | createdAt, | | |
| 1007 | }); | 1072 | }); |
| 1008 | | 1073 | |
| 1009 | yield* dispatchTurn("before-blocked-compact", "hello", now); | 1074 | blockReadyDispatch = true; |
| 1010 | yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); | 1075 | yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); |
| 1011 | yield* harness.engine.dispatch({ | 1076 | yield* Deferred.await(readyDispatchStarted); |
| 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); | | |
| 1031 | | 1077 | |
| 1032 | yield* dispatchTurn("during-compact-recovery", "too soon", "2026-01-01T00:00:02.000Z"); | 1078 | yield* harness.engine.dispatch({ |
| 1033 | yield* Effect.promise(() => | 1079 | type: "thread.interaction-mode.set", |
| 1034 | waitFor(async () => { | 1080 | commandId: CommandId.make("cmd-queued-mode-plan"), |
| 1035 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); | 1081 | threadId, |
| 1036 | return ( | 1082 | interactionMode: "plan", |
| 1037 | thread?.activities.some( | 1083 | createdAt: now, |
| 1038 | (activity) => activity.kind === "provider.turn.start.failed", | 1084 | }); |
| 1039 | ) === true | 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 | }), | 1125 | expect(stoppedThread?.session?.status).toBe("stopped"); |
| 1042 | ); | 1126 | expect( |
| 1043 | expect(harness.sendTurn).toHaveBeenCalledTimes(1); | 1127 | stoppedThread?.activities.filter( |
| 1044 | expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ | 1128 | (activity) => activity.summary === "Queued message was not sent", |
| 1045 | { threadId: "thread-1" }, | 1129 | ), |
| 1046 | ]); | 1130 | ).toEqual([ |
| 1047 | | 1131 | expect.objectContaining({ |
| 1048 | yield* Deferred.succeed(releaseReadyDispatch, undefined); | 1132 | payload: { |
| 1049 | yield* Effect.promise(() => | 1133 | requestId: "user-message-during-compact-recovery-2", |
| 1050 | waitFor(async () => { | 1134 | detail: expect.any(String), |
| 1051 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); | 1135 | }, |
| 1052 | return thread?.session?.status === "ready"; | 1136 | }), |
| 1053 | }), | 1137 | ]); |
| 1054 | ); | 1138 | expect(harness.sendTurn).toHaveBeenCalledTimes(2); |
| 1055 | }), | 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 | effectIt.effect("does not overwrite concurrent session state after compaction failure", () => | 1187 | effectIt.effect("does not overwrite concurrent session state after compaction failure", () => |
| ... | @@ -1136,6 +1265,21 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1136,6 +1265,21 @@ describe("ProviderCommandReactor", () => { |
| 1136 | (entry) => entry.id === threadId, | 1265 | (entry) => entry.id === threadId, |
| 1137 | ); | 1266 | ); |
| 1138 | expect(compactingThread?.session?.status).toBe("starting"); | 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 | yield* harness.engine.dispatch({ | 1283 | yield* harness.engine.dispatch({ |
| 1140 | type: "thread.session.stop", | 1284 | type: "thread.session.stop", |
| 1141 | commandId: CommandId.make("cmd-stop-during-compact"), | 1285 | commandId: CommandId.make("cmd-stop-during-compact"), |
| ... | @@ -1151,7 +1295,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1151,7 +1295,7 @@ describe("ProviderCommandReactor", () => { |
| 1151 | ); | 1295 | ); |
| 1152 | return ( | 1296 | return ( |
| 1153 | compactingThread?.activities.some( | 1297 | compactingThread?.activities.some( |
| 1154 | (activity) => activity.kind === "provider.turn.start.failed", | 1298 | (activity) => activity.summary === "Context compaction failed", |
| 1155 | ) === true | 1299 | ) === true |
| 1156 | ); | 1300 | ); |
| 1157 | }), | 1301 | }), |
| ... | @@ -1167,6 +1311,14 @@ describe("ProviderCommandReactor", () => { | ... | @@ -1167,6 +1311,14 @@ describe("ProviderCommandReactor", () => { |
| 1167 | (entry) => entry.id === threadId, | 1311 | (entry) => entry.id === threadId, |
| 1168 | ); | 1312 | ); |
| 1169 | expect(recoveredThread?.session?.status).toBe("ready"); | 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 | expect( | 1322 | expect( |
| 1171 | recoveredThread?.activities.find( | 1323 | recoveredThread?.activities.find( |
| 1172 | (activity) => activity.kind === "provider.session.stop.failed", | 1324 | (activity) => activity.kind === "provider.session.stop.failed", |