Keep segment lifecycle state for every provider item so interleaved deltas, approval pauses, and item completions cannot affect sibling assistant messages.
Unified · Split
| @@ -2220,10 +2220,7 @@ describe("ProviderRuntimeIngestion", () => { · 2219 unmodified lines | |||
| 2220 | threadId: asThreadId("thread-1"), | 2220 | threadId: asThreadId("thread-1"), |
| 2221 | turnId, | 2221 | turnId, |
| 2222 | }); | 2222 | }); |
| 2223 | await waitForThread( | 2223 | await waitForThread(harness.readModel, (thread) => thread.session?.activeTurnId === turnId); |
| 2224 | harness.readModel, | ||
| 2225 | (thread) => thread.session?.activeTurnId === turnId, | ||
| 2226 | ); | ||
| 2227 | 2224 | ||
| 2228 | for (const [itemId, delta] of [ | 2225 | for (const [itemId, delta] of [ |
| 2229 | ["item-buffered-first", "first item"], | 2226 | ["item-buffered-first", "first item"], |
| @@ -2251,12 +2248,11 @@ describe("ProviderRuntimeIngestion", () => { · 21 unmodified lines | |||
| 2251 | payload: { state: "completed" }, | 2248 | payload: { state: "completed" }, |
| 2252 | }); | 2249 | }); |
| 2253 | 2250 | ||
| 2254 | const thread = await waitForThread( | 2251 | const thread = await waitForThread(harness.readModel, (entry) => |
| 2255 | harness.readModel, | 2252 | entry.messages.some( |
| 2256 | (entry) => | 2253 | (message: ProviderRuntimeTestMessage) => |
| 2257 | entry.messages.some((message: ProviderRuntimeTestMessage) => | ||
| 2258 | message.id === "assistant:item-buffered-second" && !message.streaming, | 2254 | message.id === "assistant:item-buffered-second" && !message.streaming, |
| 2259 | ), | 2255 | ), |
| 2260 | ); | 2256 | ); |
| 2261 | expect(thread.messages).toEqual( | 2257 | expect(thread.messages).toEqual( |
| 2262 | expect.arrayContaining([ | 2258 | expect.arrayContaining([ |
| @@ -2266,6 +2262,175 @@ describe("ProviderRuntimeIngestion", () => { · 3 unmodified lines | |||
| 2266 | ); | 2262 | ); |
| 2267 | }); | 2263 | }); |
| 2268 | 2264 | ||
| 2265 | it("keeps interleaved buffered assistant deltas with their provider items", async () => { | ||
| 2266 | const harness = await createHarness(); | ||
| 2267 | const base = { | ||
| 2268 | provider: ProviderDriverKind.make("codex"), | ||
| 2269 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 2270 | threadId: asThreadId("thread-1"), | ||
| 2271 | turnId: asTurnId("turn-buffered-interleaved-items"), | ||
| 2272 | }; | ||
| 2273 | |||
| 2274 | await harness.emitAndDrain([ | ||
| 2275 | { ...base, type: "turn.started", eventId: asEventId("interleaved-start") }, | ||
| 2276 | { | ||
| 2277 | ...base, | ||
| 2278 | type: "content.delta", | ||
| 2279 | eventId: asEventId("interleaved-a-1"), | ||
| 2280 | itemId: asItemId("item-interleaved-a"), | ||
| 2281 | payload: { streamKind: "assistant_text", delta: "first " }, | ||
| 2282 | }, | ||
| 2283 | { | ||
| 2284 | ...base, | ||
| 2285 | type: "content.delta", | ||
| 2286 | eventId: asEventId("interleaved-b"), | ||
| 2287 | itemId: asItemId("item-interleaved-b"), | ||
| 2288 | payload: { streamKind: "assistant_text", delta: "second" }, | ||
| 2289 | }, | ||
| 2290 | { | ||
| 2291 | ...base, | ||
| 2292 | type: "content.delta", | ||
| 2293 | eventId: asEventId("interleaved-a-2"), | ||
| 2294 | itemId: asItemId("item-interleaved-a"), | ||
| 2295 | payload: { streamKind: "assistant_text", delta: "third" }, | ||
| 2296 | }, | ||
| 2297 | { | ||
| 2298 | ...base, | ||
| 2299 | type: "turn.completed", | ||
| 2300 | eventId: asEventId("interleaved-completed"), | ||
| 2301 | payload: { state: "completed" }, | ||
| 2302 | }, | ||
| 2303 | ]); | ||
| 2304 | |||
| 2305 | const messages = (await harness.readModel()).threads[0]?.messages ?? []; | ||
| 2306 | expect(messages).toEqual( | ||
| 2307 | expect.arrayContaining([ | ||
| 2308 | expect.objectContaining({ id: "assistant:item-interleaved-a", text: "first third" }), | ||
| 2309 | expect.objectContaining({ id: "assistant:item-interleaved-b", text: "second" }), | ||
| 2310 | ]), | ||
| 2311 | ); | ||
| 2312 | expect( | ||
| 2313 | messages.some((message) => message.id === "assistant:item-interleaved-a:segment:1"), | ||
| 2314 | ).toBe(false); | ||
| 2315 | }); | ||
| 2316 | |||
| 2317 | it("finalizes every open assistant item at an approval boundary", async () => { | ||
| 2318 | const harness = await createHarness(); | ||
| 2319 | const base = { | ||
| 2320 | provider: ProviderDriverKind.make("codex"), | ||
| 2321 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 2322 | threadId: asThreadId("thread-1"), | ||
| 2323 | turnId: asTurnId("turn-buffered-multiple-items-approval"), | ||
| 2324 | }; | ||
| 2325 | |||
| 2326 | await harness.emitAndDrain([ | ||
| 2327 | { ...base, type: "turn.started", eventId: asEventId("multiple-approval-start") }, | ||
| 2328 | { | ||
| 2329 | ...base, | ||
| 2330 | type: "content.delta", | ||
| 2331 | eventId: asEventId("multiple-approval-a"), | ||
| 2332 | itemId: asItemId("item-multiple-approval-a"), | ||
| 2333 | payload: { streamKind: "assistant_text", delta: "first item" }, | ||
| 2334 | }, | ||
| 2335 | { | ||
| 2336 | ...base, | ||
| 2337 | type: "content.delta", | ||
| 2338 | eventId: asEventId("multiple-approval-b"), | ||
| 2339 | itemId: asItemId("item-multiple-approval-b"), | ||
| 2340 | payload: { streamKind: "assistant_text", delta: "second item" }, | ||
| 2341 | }, | ||
| 2342 | { | ||
| 2343 | ...base, | ||
| 2344 | type: "request.opened", | ||
| 2345 | eventId: asEventId("multiple-approval-opened"), | ||
| 2346 | requestId: ApprovalRequestId.make("req-multiple-approval"), | ||
| 2347 | payload: { requestType: "command_execution_approval", detail: "pwd" }, | ||
| 2348 | }, | ||
| 2349 | ]); | ||
| 2350 | |||
| 2351 | const messages = (await harness.readModel()).threads[0]?.messages ?? []; | ||
| 2352 | expect(messages).toEqual( | ||
| 2353 | expect.arrayContaining([ | ||
| 2354 | expect.objectContaining({ | ||
| 2355 | id: "assistant:item-multiple-approval-a", | ||
| 2356 | text: "first item", | ||
| 2357 | streaming: false, | ||
| 2358 | }), | ||
| 2359 | expect.objectContaining({ | ||
| 2360 | id: "assistant:item-multiple-approval-b", | ||
| 2361 | text: "second item", | ||
| 2362 | streaming: false, | ||
| 2363 | }), | ||
| 2364 | ]), | ||
| 2365 | ); | ||
| 2366 | }); | ||
| 2367 | |||
| 2368 | it("completes only the matching assistant item when items overlap", async () => { | ||
| 2369 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); | ||
| 2370 | const base = { | ||
| 2371 | provider: ProviderDriverKind.make("codex"), | ||
| 2372 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 2373 | threadId: asThreadId("thread-1"), | ||
| 2374 | turnId: asTurnId("turn-streaming-overlapping-items"), | ||
| 2375 | }; | ||
| 2376 | |||
| 2377 | await harness.emitAndDrain([ | ||
| 2378 | { ...base, type: "turn.started", eventId: asEventId("overlap-start") }, | ||
| 2379 | { | ||
| 2380 | ...base, | ||
| 2381 | type: "content.delta", | ||
| 2382 | eventId: asEventId("overlap-a"), | ||
| 2383 | itemId: asItemId("item-overlap-a"), | ||
| 2384 | payload: { streamKind: "assistant_text", delta: "first" }, | ||
| 2385 | }, | ||
| 2386 | { | ||
| 2387 | ...base, | ||
| 2388 | type: "content.delta", | ||
| 2389 | eventId: asEventId("overlap-b-1"), | ||
| 2390 | itemId: asItemId("item-overlap-b"), | ||
| 2391 | payload: { streamKind: "assistant_text", delta: "second" }, | ||
| 2392 | }, | ||
| 2393 | { | ||
| 2394 | ...base, | ||
| 2395 | type: "item.completed", | ||
| 2396 | eventId: asEventId("overlap-a-completed"), | ||
| 2397 | itemId: asItemId("item-overlap-a"), | ||
| 2398 | payload: { itemType: "assistant_message", status: "completed" }, | ||
| 2399 | }, | ||
| 2400 | ]); | ||
| 2401 | |||
| 2402 | let messages = (await harness.readModel()).threads[0]?.messages ?? []; | ||
| 2403 | expect(messages.find((message) => message.id === "assistant:item-overlap-a")?.streaming).toBe( | ||
| 2404 | false, | ||
| 2405 | ); | ||
| 2406 | expect(messages.find((message) => message.id === "assistant:item-overlap-b")?.streaming).toBe( | ||
| 2407 | true, | ||
| 2408 | ); | ||
| 2409 | |||
| 2410 | await harness.emitAndDrain([ | ||
| 2411 | { | ||
| 2412 | ...base, | ||
| 2413 | type: "content.delta", | ||
| 2414 | eventId: asEventId("overlap-b-2"), | ||
| 2415 | itemId: asItemId("item-overlap-b"), | ||
| 2416 | payload: { streamKind: "assistant_text", delta: " item" }, | ||
| 2417 | }, | ||
| 2418 | { | ||
| 2419 | ...base, | ||
| 2420 | type: "item.completed", | ||
| 2421 | eventId: asEventId("overlap-b-completed"), | ||
| 2422 | itemId: asItemId("item-overlap-b"), | ||
| 2423 | payload: { itemType: "assistant_message", status: "completed" }, | ||
| 2424 | }, | ||
| 2425 | ]); | ||
| 2426 | |||
| 2427 | messages = (await harness.readModel()).threads[0]?.messages ?? []; | ||
| 2428 | expect(messages.find((message) => message.id === "assistant:item-overlap-b")).toMatchObject({ | ||
| 2429 | text: "second item", | ||
| 2430 | streaming: false, | ||
| 2431 | }); | ||
| 2432 | }); | ||
| 2433 | |||
| 2269 | it("flushes and completes buffered assistant text when an approval request opens", async () => { | 2434 | it("flushes and completes buffered assistant text when an approval request opens", async () => { |
| 2270 | const harness = await createHarness(); | 2435 | const harness = await createHarness(); |
| 2271 | const now = "2026-01-01T00:00:00.000Z"; | 2436 | const now = "2026-01-01T00:00:00.000Z"; |
| @@ -90,7 +90,6 @@ function findTaskTitleInActivities( · 89 unmodified lines | |||
| 90 | } | 90 | } |
| 91 | 91 | ||
| 92 | interface AssistantSegmentState { | 92 | interface AssistantSegmentState { |
| 93 | baseKey: string; | ||
| 94 | nextSegmentIndex: number; | 93 | nextSegmentIndex: number; |
| 95 | activeMessageId: MessageId | null; | 94 | activeMessageId: MessageId | null; |
| 96 | } | 95 | } |
| @@ -136,27 +135,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine · 39 unmodified lines | |||
| 136 | return left === right; | 135 | return left === right; |
| 137 | } | 136 | } |
| 138 | 137 | ||
| 139 | function hasAssistantMessageForTurn( | ||
| 140 | messages: ReadonlyArray<OrchestrationMessage>, | ||
| 141 | turnId: TurnId, | ||
| 142 | options?: { readonly streamingOnly?: boolean }, | ||
| 143 | ): boolean { | ||
| 144 | for (let index = 0; index < messages.length; index += 1) { | ||
| 145 | const message = messages[index]; | ||
| 146 | if (!message) { | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | if (message.role !== "assistant" || message.turnId !== turnId) { | ||
| 150 | continue; | ||
| 151 | } | ||
| 152 | if (options?.streamingOnly === true && !message.streaming) { | ||
| 153 | continue; | ||
| 154 | } | ||
| 155 | return true; | ||
| 156 | } | ||
| 157 | return false; | ||
| 158 | } | ||
| 159 | |||
| 160 | function findMessageById( | 138 | function findMessageById( |
| 161 | messages: ReadonlyArray<OrchestrationMessage>, | 139 | messages: ReadonlyArray<OrchestrationMessage>, |
| 162 | messageId: MessageId, | 140 | messageId: MessageId, |
| @@ -973,13 +951,13 @@ const make = Effect.gen(function* () { · 810 unmodified lines | |||
| 973 | lookup: () => Effect.succeed(""), | 951 | lookup: () => Effect.succeed(""), |
| 974 | }); | 952 | }); |
| 975 | 953 | ||
| 976 | const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({ | 954 | const assistantSegmentStateByTurnKey = yield* Cache.make< |
| 955 | string, | ||
| 956 | Map<string, AssistantSegmentState> | ||
| 957 | >({ | ||
| 977 | capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, | 958 | capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, |
| 978 | timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, | 959 | timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, |
| 979 | lookup: () => | 960 | lookup: () => Effect.succeed(new Map()), |
| 980 | Effect.die( | ||
| 981 | new Error("assistant segment state should be read through getOption before initialization"), | ||
| 982 | ), | ||
| 983 | }); | 961 | }); |
| 984 | 962 | ||
| 985 | const bufferedProposedPlanById = yield* Cache.make<string, { text: string; createdAt: string }>({ | 963 | const bufferedProposedPlanById = yield* Cache.make<string, { text: string; createdAt: string }>({ |
| @@ -1071,52 +1049,59 @@ const make = Effect.gen(function* () { · 85 unmodified lines | |||
| 1071 | const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => | 1049 | const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => |
| 1072 | Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); | 1050 | Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); |
| 1073 | 1051 | ||
| 1074 | const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => | 1052 | const getAssistantSegmentStatesForTurn = (threadId: ThreadId, turnId: TurnId) => |
| 1075 | Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); | 1053 | Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); |
| 1076 | 1054 | ||
| 1055 | const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId, baseKey: string) => | ||
| 1056 | getAssistantSegmentStatesForTurn(threadId, turnId).pipe( | ||
| 1057 | Effect.map((states) => | ||
| 1058 | Option.flatMap(states, (entries) => Option.fromNullishOr(entries.get(baseKey))), | ||
| 1059 | ), | ||
| 1060 | ); | ||
| 1061 | |||
| 1077 | const setAssistantSegmentStateForTurn = ( | 1062 | const setAssistantSegmentStateForTurn = ( |
| 1078 | threadId: ThreadId, | 1063 | threadId: ThreadId, |
| 1079 | turnId: TurnId, | 1064 | turnId: TurnId, |
| 1065 | baseKey: string, | ||
| 1080 | state: AssistantSegmentState, | 1066 | state: AssistantSegmentState, |
| 1081 | ) => Cache.set(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId), state); | 1067 | ) => |
| 1068 | getAssistantSegmentStatesForTurn(threadId, turnId).pipe( | ||
| 1069 | Effect.flatMap((existingStates) => { | ||
| 1070 | const nextStates = new Map(Option.getOrElse(existingStates, () => new Map())); | ||
| 1071 | nextStates.set(baseKey, state); | ||
| 1072 | return Cache.set( | ||
| 1073 | assistantSegmentStateByTurnKey, | ||
| 1074 | providerTurnKey(threadId, turnId), | ||
| 1075 | nextStates, | ||
| 1076 | ); | ||
| 1077 | }), | ||
| 1078 | ); | ||
| 1082 | 1079 | ||
| 1083 | const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => | 1080 | const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => |
| 1084 | Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); | 1081 | Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); |
| 1085 | 1082 | ||
| 1086 | const getActiveAssistantMessageIdForTurn = (threadId: ThreadId, turnId: TurnId) => | ||
| 1087 | getAssistantSegmentStateForTurn(threadId, turnId).pipe( | ||
| 1088 | Effect.map((state) => | ||
| 1089 | Option.flatMap(state, (entry) => | ||
| 1090 | entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(), | ||
| 1091 | ), | ||
| 1092 | ), | ||
| 1093 | ); | ||
| 1094 | |||
| 1095 | const startAssistantSegmentForTurn = (input: { | 1083 | const startAssistantSegmentForTurn = (input: { |
| 1096 | threadId: ThreadId; | 1084 | threadId: ThreadId; |
| 1097 | turnId: TurnId; | 1085 | turnId: TurnId; |
| 1098 | baseKey: string; | 1086 | baseKey: string; |
| 1099 | }) => | 1087 | }) => |
| 1100 | getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe( | 1088 | getAssistantSegmentStateForTurn(input.threadId, input.turnId, input.baseKey).pipe( |
| 1101 | Effect.flatMap((existingState) => | 1089 | Effect.flatMap((existingState) => |
| 1102 | Effect.gen(function* () { | 1090 | Effect.gen(function* () { |
| 1103 | const nextState = Option.match(existingState, { | 1091 | const segmentIndex = Option.match(existingState, { |
| 1104 | onNone: () => ({ | 1092 | onNone: () => 0, |
| 1105 | baseKey: input.baseKey, | 1093 | onSome: (state) => state.nextSegmentIndex, |
| 1106 | nextSegmentIndex: 1, | ||
| 1107 | activeMessageId: assistantSegmentMessageId(input.baseKey, 0), | ||
| 1108 | }), | ||
| 1109 | onSome: (state) => { | ||
| 1110 | const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0; | ||
| 1111 | const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex); | ||
| 1112 | return { | ||
| 1113 | baseKey: input.baseKey, | ||
| 1114 | nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1, | ||
| 1115 | activeMessageId: messageId, | ||
| 1116 | } satisfies AssistantSegmentState; | ||
| 1117 | }, | ||
| 1118 | }); | 1094 | }); |
| 1119 | yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState); | 1095 | const nextState: AssistantSegmentState = { |
| 1096 | nextSegmentIndex: segmentIndex + 1, | ||
| 1097 | activeMessageId: assistantSegmentMessageId(input.baseKey, segmentIndex), | ||
| 1098 | }; | ||
| 1099 | yield* setAssistantSegmentStateForTurn( | ||
| 1100 | input.threadId, | ||
| 1101 | input.turnId, | ||
| 1102 | input.baseKey, | ||
| 1103 | nextState, | ||
| 1104 | ); | ||
| 1120 | return nextState.activeMessageId!; | 1105 | return nextState.activeMessageId!; |
| 1121 | }), | 1106 | }), |
| 1122 | ), | 1107 | ), |
| @@ -1133,12 +1118,12 @@ const make = Effect.gen(function* () { · 10 unmodified lines | |||
| 1133 | } | 1118 | } |
| 1134 | 1119 | ||
| 1135 | const baseKey = assistantSegmentBaseKeyFromEvent(input.event); | 1120 | const baseKey = assistantSegmentBaseKeyFromEvent(input.event); |
| 1136 | const segmentState = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); | 1121 | const segmentState = yield* getAssistantSegmentStateForTurn( |
| 1137 | if ( | 1122 | input.threadId, |
| 1138 | Option.isSome(segmentState) && | 1123 | input.turnId, |
| 1139 | segmentState.value.baseKey === baseKey && | 1124 | baseKey, |
| 1140 | segmentState.value.activeMessageId !== null | 1125 | ); |
| 1141 | ) { | 1126 | if (Option.isSome(segmentState) && segmentState.value.activeMessageId !== null) { |
| 1142 | return segmentState.value.activeMessageId; | 1127 | return segmentState.value.activeMessageId; |
| 1143 | } | 1128 | } |
| 1144 | 1129 | ||
| @@ -1313,46 +1298,51 @@ const make = Effect.gen(function* () { · 168 unmodified lines | |||
| 1313 | yield* clearAssistantMessageState(input.messageId); | 1298 | yield* clearAssistantMessageState(input.messageId); |
| 1314 | }); | 1299 | }); |
| 1315 | 1300 | ||
| 1316 | const finalizeActiveAssistantSegmentForTurn = (input: { | 1301 | const finalizeActiveAssistantSegmentsForTurn = (input: { |
| 1317 | event: ProviderRuntimeEvent; | 1302 | event: ProviderRuntimeEvent; |
| 1318 | threadId: ThreadId; | 1303 | threadId: ThreadId; |
| 1319 | turnId: TurnId; | 1304 | turnId: TurnId; |
| 1320 | createdAt: string; | 1305 | createdAt: string; |
| 1321 | commandTag: string; | 1306 | commandTag: string; |
| 1322 | finalDeltaCommandTag: string; | 1307 | finalDeltaCommandTag: string; |
| 1323 | hasProjectedMessage: boolean; | 1308 | projectedMessageIds: ReadonlySet<MessageId>; |
| 1324 | flushedMessageIds?: ReadonlySet<MessageId>; | 1309 | flushedMessageIds?: ReadonlySet<MessageId>; |
| 1325 | }) => | 1310 | }) => |
| 1326 | Effect.gen(function* () { | 1311 | Effect.gen(function* () { |
| 1327 | const activeMessageId = yield* getActiveAssistantMessageIdForTurn( | 1312 | const segmentStates = yield* getAssistantSegmentStatesForTurn(input.threadId, input.turnId); |
| 1328 | input.threadId, | 1313 | if (Option.isNone(segmentStates)) { |
| 1329 | input.turnId, | ||
| 1330 | ); | ||
| 1331 | if (Option.isNone(activeMessageId)) { | ||
| 1332 | return; | 1314 | return; |
| 1333 | } | 1315 | } |
| 1334 | 1316 | ||
| 1335 | yield* finalizeAssistantMessage({ | 1317 | yield* Effect.forEach( |
| 1336 | event: input.event, | 1318 | segmentStates.value, |
| 1337 | threadId: input.threadId, | 1319 | ([baseKey, state]) => |
| 1338 | messageId: activeMessageId.value, | 1320 | Effect.gen(function* () { |
| 1339 | turnId: input.turnId, | 1321 | if (state.activeMessageId === null) { |
| 1340 | createdAt: input.createdAt, | 1322 | return; |
| 1341 | commandTag: input.commandTag, | 1323 | } |
| 1342 | finalDeltaCommandTag: input.finalDeltaCommandTag, | ||
| 1343 | hasProjectedMessage: | ||
| 1344 | input.hasProjectedMessage || | ||
| 1345 | (input.flushedMessageIds?.has(activeMessageId.value) ?? false), | ||
| 1346 | }); | ||
| 1347 | yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value); | ||
| 1348 | 1324 | ||
| 1349 | const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); | 1325 | const messageId = state.activeMessageId; |
| 1350 | if (Option.isSome(state)) { | 1326 | yield* finalizeAssistantMessage({ |
| 1351 | yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, { | 1327 | event: input.event, |
| 1352 | ...state.value, | 1328 | threadId: input.threadId, |
| 1353 | activeMessageId: null, | 1329 | messageId, |
| 1354 | }); | 1330 | turnId: input.turnId, |
| 1355 | } | 1331 | createdAt: input.createdAt, |
| 1332 | commandTag: input.commandTag, | ||
| 1333 | finalDeltaCommandTag: input.finalDeltaCommandTag, | ||
| 1334 | hasProjectedMessage: | ||
| 1335 | input.projectedMessageIds.has(messageId) || | ||
| 1336 | (input.flushedMessageIds?.has(messageId) ?? false), | ||
| 1337 | }); | ||
| 1338 | yield* forgetAssistantMessageId(input.threadId, input.turnId, messageId); | ||
| 1339 | yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, baseKey, { | ||
| 1340 | ...state, | ||
| 1341 | activeMessageId: null, | ||
| 1342 | }); | ||
| 1343 | }), | ||
| 1344 | { concurrency: 1 }, | ||
| 1345 | ).pipe(Effect.asVoid); | ||
| 1356 | }); | 1346 | }); |
| 1357 | 1347 | ||
| 1358 | const upsertProposedPlan = (input: { | 1348 | const upsertProposedPlan = (input: { |
| @@ -1816,7 +1806,7 @@ const make = Effect.gen(function* () { · 457 unmodified lines | |||
| 1816 | : "assistant-delta-flush-on-user-input-requested", | 1806 | : "assistant-delta-flush-on-user-input-requested", |
| 1817 | }) | 1807 | }) |
| 1818 | : new Set<MessageId>(); | 1808 | : new Set<MessageId>(); |
| 1819 | yield* finalizeActiveAssistantSegmentForTurn({ | 1809 | yield* finalizeActiveAssistantSegmentsForTurn({ |
| 1820 | event, | 1810 | event, |
| 1821 | threadId: thread.id, | 1811 | threadId: thread.id, |
| 1822 | turnId: pauseForUserTurnId, | 1812 | turnId: pauseForUserTurnId, |
| @@ -1829,11 +1819,13 @@ const make = Effect.gen(function* () { · 6 unmodified lines | |||
| 1829 | event.type === "request.opened" | 1819 | event.type === "request.opened" |
| 1830 | ? "assistant-delta-finalize-on-request-opened" | 1820 | ? "assistant-delta-finalize-on-request-opened" |
| 1831 | : "assistant-delta-finalize-on-user-input-requested", | 1821 | : "assistant-delta-finalize-on-user-input-requested", |
| 1832 | hasProjectedMessage: | 1822 | projectedMessageIds: new Set( |
| 1833 | detailedThread !== null && | 1823 | (detailedThread?.messages ?? []) |
| 1834 | hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, { | 1824 | .filter( |
| 1835 | streamingOnly: true, | 1825 | (message) => message.role === "assistant" && message.turnId === pauseForUserTurnId, |
| 1836 | }), | 1826 | ) |
| 1827 | .map((message) => message.id), | ||
| 1828 | ), | ||
| 1837 | flushedMessageIds, | 1829 | flushedMessageIds, |
| 1838 | }); | 1830 | }); |
| 1839 | } | 1831 | } |
| @@ -1846,9 +1838,8 @@ const make = Effect.gen(function* () { · 6 unmodified lines | |||
| 1846 | const assistantCompletion = | 1838 | const assistantCompletion = |
| 1847 | event.type === "item.completed" && event.payload.itemType === "assistant_message" | 1839 | event.type === "item.completed" && event.payload.itemType === "assistant_message" |
| 1848 | ? { | 1840 | ? { |
| 1849 | messageId: MessageId.make( | 1841 | baseKey: assistantSegmentBaseKeyFromEvent(event), |
| 1850 | `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, | 1842 | messageId: assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(event), 0), |
| 1851 | ), | ||
| 1852 | fallbackText: event.payload.detail, | 1843 | fallbackText: event.payload.detail, |
| 1853 | } | 1844 | } |
| 1854 | : undefined; | 1845 | : undefined; |
| @@ -1865,11 +1856,12 @@ const make = Effect.gen(function* () { · 10 unmodified lines | |||
| 1865 | const detailedThread = yield* getLoadedThreadDetail(); | 1856 | const detailedThread = yield* getLoadedThreadDetail(); |
| 1866 | const messages = detailedThread?.messages ?? []; | 1857 | const messages = detailedThread?.messages ?? []; |
| 1867 | const turnId = toTurnId(event.turnId); | 1858 | const turnId = toTurnId(event.turnId); |
| 1868 | const activeAssistantMessageId = turnId | 1859 | const segmentState = turnId |
| 1869 | ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) | 1860 | ? yield* getAssistantSegmentStateForTurn(thread.id, turnId, assistantCompletion.baseKey) |
| 1870 | : Option.none<MessageId>(); | 1861 | : Option.none<AssistantSegmentState>(); |
| 1871 | const hasAssistantMessagesForTurn = | 1862 | const activeAssistantMessageId = Option.flatMap(segmentState, (state) => |
| 1872 | turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; | 1863 | Option.fromNullishOr(state.activeMessageId), |
| 1864 | ); | ||
| 1873 | const assistantMessageId = Option.getOrElse( | 1865 | const assistantMessageId = Option.getOrElse( |
| 1874 | activeAssistantMessageId, | 1866 | activeAssistantMessageId, |
| 1875 | () => assistantCompletion.messageId, | 1867 | () => assistantCompletion.messageId, |
| @@ -1879,10 +1871,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 1879 | !existingAssistantMessage || existingAssistantMessage.text.length === 0; | 1871 | !existingAssistantMessage || existingAssistantMessage.text.length === 0; |
| 1880 | 1872 | ||
| 1881 | const shouldSkipRedundantCompletion = | 1873 | const shouldSkipRedundantCompletion = |
| 1882 | Option.isNone(activeAssistantMessageId) && | 1874 | Option.isSome(segmentState) && segmentState.value.activeMessageId === null; |
| 1883 | turnId !== undefined && | ||
| 1884 | hasAssistantMessagesForTurn && | ||
| 1885 | (assistantCompletion.fallbackText?.trim().length ?? 0) === 0; | ||
| 1886 | 1875 | ||
| 1887 | if (!shouldSkipRedundantCompletion) { | 1876 | if (!shouldSkipRedundantCompletion) { |
| 1888 | if (turnId && Option.isNone(activeAssistantMessageId)) { | 1877 | if (turnId && Option.isNone(activeAssistantMessageId)) { |
| @@ -1905,12 +1894,19 @@ const make = Effect.gen(function* () { · 16 unmodified lines | |||
| 1905 | 1894 | ||
| 1906 | if (turnId) { | 1895 | if (turnId) { |
| 1907 | yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); | 1896 | yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 1897 | if (Option.isSome(segmentState)) { | ||
| 1898 | yield* setAssistantSegmentStateForTurn( | ||
| 1899 | thread.id, | ||
| 1900 | turnId, | ||
| 1901 | assistantCompletion.baseKey, | ||
| 1902 | { | ||
| 1903 | ...segmentState.value, | ||
| 1904 | activeMessageId: null, | ||
| 1905 | }, | ||
| 1906 | ); | ||
| 1907 | } | ||
| 1908 | } | 1908 | } |
| 1909 | } | 1909 | } |
| 1910 | |||
| 1911 | if (turnId) { | ||
| 1912 | yield* clearAssistantSegmentStateForTurn(thread.id, turnId); | ||
| 1913 | } | ||
| 1914 | } | 1910 | } |
| 1915 | 1911 | ||
| 1916 | if (proposedPlanCompletion) { | 1912 | if (proposedPlanCompletion) { |