| ... | ... | @@ -1,5 +1,22 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | | import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | CheckpointRef, |
| 4 | EnvironmentId, |
| 5 | EventId, |
| 6 | MessageId, |
| 7 | ProjectId, |
| 8 | ProviderInstanceId, |
| 9 | ThreadId, |
| 10 | TurnId, |
| 11 | type OrchestrationThread, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import { |
| 14 | applyThreadDetailEvent, |
| 15 | createEnvironmentThreadDetailAtoms, |
| 16 | EMPTY_ENVIRONMENT_THREAD_STATE, |
| 17 | } from "@t3tools/client-runtime/state/threads"; |
| 18 | import * as Option from "effect/Option"; |
| 19 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 3 | 20 | import { |
| 4 | 21 | computeStableMessagesTimelineRows, |
| 5 | 22 | computeMessageDurationStart, |
| ... | ... | @@ -12,6 +29,7 @@ import { |
| 12 | 29 | shouldFollowWorkGroupAppend, |
| 13 | 30 | shouldPreserveAssistantLineBreaks, |
| 14 | 31 | type MessagesTimelineRow, |
| 32 | type MessagesTimelineRowsProjection, |
| 15 | 33 | workEntryDisplayLabel, |
| 16 | 34 | } from "./MessagesTimeline.logic"; |
| 17 | 35 | import { |
| ... | ... | @@ -19,8 +37,8 @@ import { |
| 19 | 37 | deriveTimelineEntries, |
| 20 | 38 | deriveTimelineEntriesWithState, |
| 21 | 39 | type WorkLogEntry, |
| 40 | type TimelineEntriesProjection, |
| 22 | 41 | } from "../../session-logic"; |
| 23 | | import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic"; |
| 24 | 42 | import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; |
| 25 | 43 | |
| 26 | 44 | describe("streaming row projection", () => { |
| ... | ... | @@ -97,8 +115,8 @@ describe("streaming row projection", () => { |
| 97 | 115 | runningTurnId: turnId, |
| 98 | 116 | isWorking: true, |
| 99 | 117 | activeTurnStartedAt: time(5), |
| 100 | | turnDiffSummaryByAssistantMessageId: new Map<MessageId, TurnDiffSummary>(), |
| 101 | | revertTurnCountByUserMessageId: new Map<MessageId, number>(), |
| 118 | turnDiffSummaries: [], |
| 119 | supportsConversationRollback: false, |
| 102 | 120 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; |
| 103 | 121 | return { messages, work, timeline, input, time, turnId, historyTurnId }; |
| 104 | 122 | } |
| ... | ... | @@ -243,52 +261,54 @@ describe("streaming row projection", () => { |
| 243 | 261 | }, |
| 244 | 262 | ); |
| 245 | 263 | |
| 246 | | it("reuses rows when the revert map is rebuilt from the streamed entries", () => { |
| 264 | it("owns checkpoint lookups across streaming and equal source snapshots", () => { |
| 247 | 265 | const initial = fixture("Partial"); |
| 248 | | const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 }; |
| 249 | | const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>([ |
| 250 | | [ |
| 251 | | MessageId.make("history-assistant"), |
| 252 | | { |
| 253 | | turnId: initial.historyTurnId, |
| 254 | | checkpointTurnCount: 1, |
| 255 | | checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), |
| 256 | | status: "ready", |
| 257 | | files: [], |
| 258 | | assistantMessageId: MessageId.make("history-assistant"), |
| 259 | | completedAt: initial.time(4), |
| 260 | | }, |
| 261 | | ], |
| 262 | | ]); |
| 263 | | let revertMap: Map<MessageId, number> | null = null; |
| 264 | | // Mirrors ChatView: the map is derived from each delta's entries. |
| 265 | | const build = (timelineEntries: typeof initial.timeline.entries) => { |
| 266 | | revertMap = buildRevertTurnCountByUserMessageId( |
| 267 | | { |
| 268 | | supportsConversationRollback: true, |
| 269 | | timelineEntries, |
| 270 | | turnDiffSummaryByAssistantMessageId, |
| 271 | | inferredCheckpointTurnCountByTurnId, |
| 272 | | }, |
| 273 | | revertMap, |
| 274 | | ); |
| 275 | | return { |
| 276 | | ...initial.input, |
| 277 | | timelineEntries, |
| 278 | | turnDiffSummaryByAssistantMessageId, |
| 279 | | revertTurnCountByUserMessageId: revertMap, |
| 280 | | }; |
| 266 | let checkpointLookupReads = 0; |
| 267 | const summary: TurnDiffSummary = { |
| 268 | turnId: initial.historyTurnId, |
| 269 | get checkpointTurnCount() { |
| 270 | checkpointLookupReads += 1; |
| 271 | return 1; |
| 272 | }, |
| 273 | checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), |
| 274 | status: "ready", |
| 275 | files: [], |
| 276 | get assistantMessageId() { |
| 277 | checkpointLookupReads += 1; |
| 278 | return MessageId.make("history-assistant"); |
| 279 | }, |
| 280 | get completedAt() { |
| 281 | checkpointLookupReads += 1; |
| 282 | return initial.time(4); |
| 283 | }, |
| 281 | 284 | }; |
| 282 | | const previous = deriveMessagesTimelineRowsWithState(build(initial.timeline.entries)); |
| 285 | const input = { |
| 286 | ...initial.input, |
| 287 | turnDiffSummaries: [summary], |
| 288 | supportsConversationRollback: true, |
| 289 | expandedTurnIds: new Set([initial.historyTurnId]), |
| 290 | expandedWorkGroupIds: new Set<string>(), |
| 291 | }; |
| 292 | const previous = deriveMessagesTimelineRowsWithState(input); |
| 293 | expect(checkpointLookupReads).toBeGreaterThan(0); |
| 283 | 294 | expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe( |
| 284 | 295 | true, |
| 285 | 296 | ); |
| 286 | 297 | const last = initial.messages.at(-1)!; |
| 287 | 298 | const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }]; |
| 288 | 299 | const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); |
| 289 | | const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous); |
| 290 | | |
| 291 | | expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries))); |
| 300 | const nextInput = { |
| 301 | ...input, |
| 302 | timelineEntries: timeline.entries, |
| 303 | turnDiffSummaries: [...input.turnDiffSummaries], |
| 304 | latestTurn: { ...input.latestTurn }, |
| 305 | expandedTurnIds: new Set(input.expandedTurnIds), |
| 306 | expandedWorkGroupIds: new Set(input.expandedWorkGroupIds), |
| 307 | }; |
| 308 | checkpointLookupReads = 0; |
| 309 | const next = deriveMessagesTimelineRowsWithState(nextInput, previous); |
| 310 | expect(checkpointLookupReads).toBe(0); |
| 311 | expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput)); |
| 292 | 312 | for (const [index, row] of previous.rows.entries()) { |
| 293 | 313 | if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { |
| 294 | 314 | expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } }); |
| ... | ... | @@ -296,6 +316,205 @@ describe("streaming row projection", () => { |
| 296 | 316 | expect(next.rows[index]).toBe(row); |
| 297 | 317 | } |
| 298 | 318 | } |
| 319 | |
| 320 | const changed = deriveMessagesTimelineRowsWithState( |
| 321 | { ...nextInput, turnDiffSummaries: [{ ...summary, checkpointTurnCount: 3 }] }, |
| 322 | next, |
| 323 | ); |
| 324 | expect( |
| 325 | changed.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), |
| 326 | ).toMatchObject({ revertTurnCount: 2 }); |
| 327 | const unsupported = deriveMessagesTimelineRowsWithState( |
| 328 | { ...changed.input, supportsConversationRollback: false }, |
| 329 | changed, |
| 330 | ); |
| 331 | expect( |
| 332 | unsupported.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), |
| 333 | ).toMatchObject({ revertTurnCount: undefined }); |
| 334 | expect( |
| 335 | previous.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), |
| 336 | ).toMatchObject({ revertTurnCount: 0 }); |
| 337 | }); |
| 338 | |
| 339 | it("reuses long-thread rows through detail events, selectors, and attachment previews", () => { |
| 340 | const initial = fixture("Partial"); |
| 341 | let checkpointLookupReads = 0; |
| 342 | const history = Array.from({ length: 250 }, (_, index) => { |
| 343 | const turnId = TurnId.make(`older-turn-${index}`); |
| 344 | const user = { |
| 345 | ...initial.messages[0]!, |
| 346 | id: MessageId.make(`older-user-${index}`), |
| 347 | createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5)).toISOString(), |
| 348 | attachments: [ |
| 349 | { |
| 350 | type: "image" as const, |
| 351 | id: "image", |
| 352 | name: "image.png", |
| 353 | mimeType: "image/png", |
| 354 | sizeBytes: 42, |
| 355 | }, |
| 356 | ], |
| 357 | }; |
| 358 | const assistant = { |
| 359 | ...initial.messages[1]!, |
| 360 | id: MessageId.make(`older-assistant-${index}`), |
| 361 | turnId, |
| 362 | createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5 + 3)).toISOString(), |
| 363 | }; |
| 364 | const checkpoint: TurnDiffSummary = { |
| 365 | turnId, |
| 366 | get checkpointTurnCount() { |
| 367 | checkpointLookupReads += 1; |
| 368 | return index + 1; |
| 369 | }, |
| 370 | checkpointRef: CheckpointRef.make(`refs/t3/checkpoints/older-${index}`), |
| 371 | status: "ready", |
| 372 | files: [], |
| 373 | get assistantMessageId() { |
| 374 | checkpointLookupReads += 1; |
| 375 | return assistant.id; |
| 376 | }, |
| 377 | get completedAt() { |
| 378 | checkpointLookupReads += 1; |
| 379 | return assistant.createdAt; |
| 380 | }, |
| 381 | }; |
| 382 | return { user, assistant, checkpoint }; |
| 383 | }); |
| 384 | const liveMessage = initial.messages.at(-1)!; |
| 385 | let thread: OrchestrationThread = { |
| 386 | id: ThreadId.make("streaming-thread"), |
| 387 | projectId: ProjectId.make("project"), |
| 388 | title: "Long thread", |
| 389 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 390 | runtimeMode: "full-access", |
| 391 | interactionMode: "default", |
| 392 | branch: null, |
| 393 | worktreePath: null, |
| 394 | latestTurn: { |
| 395 | ...initial.input.latestTurn, |
| 396 | requestedAt: initial.time(5), |
| 397 | assistantMessageId: liveMessage.id, |
| 398 | }, |
| 399 | createdAt: initial.time(0), |
| 400 | updatedAt: initial.time(7), |
| 401 | archivedAt: null, |
| 402 | settledOverride: null, |
| 403 | settledAt: null, |
| 404 | deletedAt: null, |
| 405 | messages: [ |
| 406 | ...history.flatMap(({ user, assistant }) => [user, assistant]), |
| 407 | ...initial.messages, |
| 408 | ], |
| 409 | proposedPlans: [], |
| 410 | activities: [], |
| 411 | checkpoints: history.map(({ checkpoint }) => checkpoint), |
| 412 | session: null, |
| 413 | }; |
| 414 | const state = Atom.make( |
| 415 | AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }), |
| 416 | ); |
| 417 | const details = createEnvironmentThreadDetailAtoms(() => state); |
| 418 | const ref = { environmentId: EnvironmentId.make("local"), threadId: thread.id }; |
| 419 | const registry = AtomRegistry.make(); |
| 420 | const unmount = registry.mount(details.detailAtom(ref)); |
| 421 | const preview = createMessageAttachmentPreviewProjector(); |
| 422 | let imageUrl = "https://first.test/image"; |
| 423 | let timeline: TimelineEntriesProjection | null = null; |
| 424 | let projection: MessagesTimelineRowsProjection | null = null; |
| 425 | const project = () => { |
| 426 | const selected = registry.get(details.detailAtom(ref)); |
| 427 | if (selected === null) throw new Error("Missing thread detail"); |
| 428 | const messages = selected.messages.map((message) => preview(message, () => imageUrl)); |
| 429 | timeline = deriveTimelineEntriesWithState( |
| 430 | messages, |
| 431 | selected.proposedPlans, |
| 432 | initial.work, |
| 433 | timeline, |
| 434 | ); |
| 435 | projection = deriveMessagesTimelineRowsWithState( |
| 436 | { |
| 437 | timelineEntries: timeline.entries, |
| 438 | latestTurn: selected.latestTurn, |
| 439 | runningTurnId: |
| 440 | selected.latestTurn?.state === "running" ? selected.latestTurn.turnId : null, |
| 441 | isWorking: selected.latestTurn?.state === "running", |
| 442 | activeTurnStartedAt: selected.latestTurn?.startedAt ?? null, |
| 443 | turnDiffSummaries: selected.checkpoints, |
| 444 | supportsConversationRollback: true, |
| 445 | }, |
| 446 | projection, |
| 447 | ); |
| 448 | return projection; |
| 449 | }; |
| 450 | const send = (text: string, sequence: number, streaming = true) => { |
| 451 | const result = applyThreadDetailEvent(thread, { |
| 452 | eventId: EventId.make(`delta-${sequence}`), |
| 453 | sequence, |
| 454 | commandId: null, |
| 455 | causationEventId: null, |
| 456 | correlationId: null, |
| 457 | metadata: {}, |
| 458 | occurredAt: initial.time(8 + sequence), |
| 459 | aggregateKind: "thread", |
| 460 | aggregateId: thread.id, |
| 461 | type: "thread.message-sent", |
| 462 | payload: { |
| 463 | threadId: thread.id, |
| 464 | messageId: liveMessage.id, |
| 465 | role: "assistant", |
| 466 | text, |
| 467 | turnId: initial.turnId, |
| 468 | streaming, |
| 469 | createdAt: liveMessage.createdAt, |
| 470 | updatedAt: initial.time(8 + sequence), |
| 471 | }, |
| 472 | }); |
| 473 | if (result.kind !== "updated") throw new Error("Message event did not update the thread"); |
| 474 | thread = result.thread; |
| 475 | registry.set( |
| 476 | state, |
| 477 | AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }), |
| 478 | ); |
| 479 | return project(); |
| 480 | }; |
| 481 | |
| 482 | try { |
| 483 | const first = project(); |
| 484 | const saved = structuredClone(first.rows); |
| 485 | expect(checkpointLookupReads).toBeGreaterThan(0); |
| 486 | checkpointLookupReads = 0; |
| 487 | for (let index = 0; index < 10; index += 1) { |
| 488 | const next = send(` ${index}`, index + 1); |
| 489 | for (const [rowIndex, row] of first.rows.entries()) { |
| 490 | if ( |
| 491 | (row.kind === "message" || row.kind === "assistant-meta") && |
| 492 | row.message.id === liveMessage.id |
| 493 | ) |
| 494 | continue; |
| 495 | expect(next.rows[rowIndex]).toBe(row); |
| 496 | } |
| 497 | } |
| 498 | expect(checkpointLookupReads).toBe(0); |
| 499 | const streamed = project(); |
| 500 | expect(streamed.rows).toEqual(deriveMessagesTimelineRows(streamed.input)); |
| 501 | |
| 502 | imageUrl = "https://renewed.test/image"; |
| 503 | const renewed = project(); |
| 504 | expect(renewed.rows[0]).not.toBe(first.rows[0]); |
| 505 | expect(renewed.rows[0]).toMatchObject({ |
| 506 | message: { attachments: [{ previewUrl: imageUrl }] }, |
| 507 | }); |
| 508 | const completed = send("Complete", 11, false); |
| 509 | expect(completed.rows).toEqual(deriveMessagesTimelineRows(completed.input)); |
| 510 | expect( |
| 511 | completed.rows.find((row) => row.kind === "message" && row.message.id === liveMessage.id), |
| 512 | ).toMatchObject({ message: { text: "Complete" }, assistantCopyStreaming: false }); |
| 513 | expect(first.rows).toEqual(saved); |
| 514 | } finally { |
| 515 | unmount(); |
| 516 | registry.dispose(); |
| 517 | } |
| 299 | 518 | }); |
| 300 | 519 | |
| 301 | 520 | it.each(["completion", "turn", "role", "ordering"] as const)( |
| ... | ... | @@ -407,7 +626,7 @@ describe("streaming row projection", () => { |
| 407 | 626 | ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) } |
| 408 | 627 | : message, |
| 409 | 628 | ); |
| 410 | | check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) }); |
| 629 | check({ supportsConversationRollback: true }); |
| 411 | 630 | }); |
| 412 | 631 | }); |
| 413 | 632 | |
| ... | ... | @@ -574,8 +793,8 @@ describe("work entry labels", () => { |
| 574 | 793 | ], |
| 575 | 794 | isWorking: false, |
| 576 | 795 | activeTurnStartedAt: null, |
| 577 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 578 | | revertTurnCountByUserMessageId: new Map(), |
| 796 | turnDiffSummaries: [], |
| 797 | supportsConversationRollback: false, |
| 579 | 798 | }); |
| 580 | 799 | const directRow = rows.find((row) => row.kind === "work"); |
| 581 | 800 | expect(directRow).toMatchObject({ |
| ... | ... | @@ -887,8 +1106,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 887 | 1106 | ], |
| 888 | 1107 | isWorking: false, |
| 889 | 1108 | activeTurnStartedAt: null, |
| 890 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 891 | | revertTurnCountByUserMessageId: new Map(), |
| 1109 | turnDiffSummaries: [], |
| 1110 | supportsConversationRollback: false, |
| 892 | 1111 | }); |
| 893 | 1112 | |
| 894 | 1113 | expect(rows).toEqual([ |
| ... | ... | @@ -950,8 +1169,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 950 | 1169 | expandedTurnIds: new Set(["turn-1" as never]), |
| 951 | 1170 | isWorking: false, |
| 952 | 1171 | activeTurnStartedAt: null, |
| 953 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 954 | | revertTurnCountByUserMessageId: new Map(), |
| 1172 | turnDiffSummaries: [], |
| 1173 | supportsConversationRollback: false, |
| 955 | 1174 | }); |
| 956 | 1175 | |
| 957 | 1176 | const assistantRows = rows.filter( |
| ... | ... | @@ -1004,8 +1223,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1004 | 1223 | }, |
| 1005 | 1224 | isWorking: false, |
| 1006 | 1225 | activeTurnStartedAt: null, |
| 1007 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1008 | | revertTurnCountByUserMessageId: new Map(), |
| 1226 | turnDiffSummaries: [], |
| 1227 | supportsConversationRollback: false, |
| 1009 | 1228 | }); |
| 1010 | 1229 | |
| 1011 | 1230 | const assistantRows = rows.filter( |
| ... | ... | @@ -1061,10 +1280,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1061 | 1280 | ], |
| 1062 | 1281 | isWorking: false, |
| 1063 | 1282 | activeTurnStartedAt: null, |
| 1064 | | turnDiffSummaryByAssistantMessageId: new Map([ |
| 1065 | | ["assistant-1" as never, assistantTurnDiffSummary], |
| 1066 | | ]), |
| 1067 | | revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]), |
| 1283 | turnDiffSummaries: [assistantTurnDiffSummary], |
| 1284 | supportsConversationRollback: true, |
| 1068 | 1285 | }); |
| 1069 | 1286 | |
| 1070 | 1287 | const userRow = rows.find( |
| ... | ... | @@ -1142,8 +1359,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1142 | 1359 | timelineEntries, |
| 1143 | 1360 | isWorking: false, |
| 1144 | 1361 | activeTurnStartedAt: null, |
| 1145 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1146 | | revertTurnCountByUserMessageId: new Map(), |
| 1362 | turnDiffSummaries: [], |
| 1363 | supportsConversationRollback: false, |
| 1147 | 1364 | }); |
| 1148 | 1365 | |
| 1149 | 1366 | const foldRow = collapsedRows.find( |
| ... | ... | @@ -1165,8 +1382,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1165 | 1382 | expandedTurnIds: new Set(["turn-1" as never]), |
| 1166 | 1383 | isWorking: false, |
| 1167 | 1384 | activeTurnStartedAt: null, |
| 1168 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1169 | | revertTurnCountByUserMessageId: new Map(), |
| 1385 | turnDiffSummaries: [], |
| 1386 | supportsConversationRollback: false, |
| 1170 | 1387 | }); |
| 1171 | 1388 | |
| 1172 | 1389 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| ... | ... | @@ -1235,8 +1452,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1235 | 1452 | }, |
| 1236 | 1453 | isWorking: false, |
| 1237 | 1454 | activeTurnStartedAt: null, |
| 1238 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1239 | | revertTurnCountByUserMessageId: new Map(), |
| 1455 | turnDiffSummaries: [], |
| 1456 | supportsConversationRollback: false, |
| 1240 | 1457 | }; |
| 1241 | 1458 | const rows = deriveMessagesTimelineRows({ ...input, timelineEntries }); |
| 1242 | 1459 | |
| ... | ... | @@ -1318,8 +1535,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1318 | 1535 | timelineEntries, |
| 1319 | 1536 | isWorking: false, |
| 1320 | 1537 | activeTurnStartedAt: null, |
| 1321 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1322 | | revertTurnCountByUserMessageId: new Map(), |
| 1538 | turnDiffSummaries: [], |
| 1539 | supportsConversationRollback: false, |
| 1323 | 1540 | }); |
| 1324 | 1541 | |
| 1325 | 1542 | expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]); |
| ... | ... | @@ -1421,8 +1638,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1421 | 1638 | }, |
| 1422 | 1639 | isWorking: true, |
| 1423 | 1640 | activeTurnStartedAt: "2026-01-01T00:00:14Z", |
| 1424 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1425 | | revertTurnCountByUserMessageId: new Map(), |
| 1641 | turnDiffSummaries: [], |
| 1642 | supportsConversationRollback: false, |
| 1426 | 1643 | }); |
| 1427 | 1644 | |
| 1428 | 1645 | const foldRow = rows.find( |
| ... | ... | @@ -1458,8 +1675,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1458 | 1675 | }, |
| 1459 | 1676 | isWorking: false, |
| 1460 | 1677 | activeTurnStartedAt: null, |
| 1461 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1462 | | revertTurnCountByUserMessageId: new Map(), |
| 1678 | turnDiffSummaries: [], |
| 1679 | supportsConversationRollback: false, |
| 1463 | 1680 | }); |
| 1464 | 1681 | |
| 1465 | 1682 | expect(rows).toEqual([ |
| ... | ... | @@ -1526,8 +1743,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1526 | 1743 | }, |
| 1527 | 1744 | isWorking: true, |
| 1528 | 1745 | activeTurnStartedAt: "2026-01-01T00:01:00Z", |
| 1529 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1530 | | revertTurnCountByUserMessageId: new Map(), |
| 1746 | turnDiffSummaries: [], |
| 1747 | supportsConversationRollback: false, |
| 1531 | 1748 | }); |
| 1532 | 1749 | |
| 1533 | 1750 | expect(rows.map((row) => row.id)).toEqual([ |
| ... | ... | @@ -1580,8 +1797,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1580 | 1797 | }, |
| 1581 | 1798 | isWorking: true, |
| 1582 | 1799 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 1583 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1584 | | revertTurnCountByUserMessageId: new Map(), |
| 1800 | turnDiffSummaries: [], |
| 1801 | supportsConversationRollback: false, |
| 1585 | 1802 | }); |
| 1586 | 1803 | |
| 1587 | 1804 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); |
| ... | ... | @@ -1674,8 +1891,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1674 | 1891 | }, |
| 1675 | 1892 | isWorking: true, |
| 1676 | 1893 | activeTurnStartedAt: "2026-01-01T00:01:00Z", |
| 1677 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1678 | | revertTurnCountByUserMessageId: new Map(), |
| 1894 | turnDiffSummaries: [], |
| 1895 | supportsConversationRollback: false, |
| 1679 | 1896 | }); |
| 1680 | 1897 | |
| 1681 | 1898 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); |
| ... | ... | @@ -1754,8 +1971,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1754 | 1971 | }, |
| 1755 | 1972 | isWorking: true, |
| 1756 | 1973 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 1757 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1758 | | revertTurnCountByUserMessageId: new Map(), |
| 1974 | turnDiffSummaries: [], |
| 1975 | supportsConversationRollback: false, |
| 1759 | 1976 | }); |
| 1760 | 1977 | |
| 1761 | 1978 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); |
| ... | ... | @@ -1827,8 +2044,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1827 | 2044 | }, |
| 1828 | 2045 | isWorking: true, |
| 1829 | 2046 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 1830 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1831 | | revertTurnCountByUserMessageId: new Map(), |
| 2047 | turnDiffSummaries: [], |
| 2048 | supportsConversationRollback: false, |
| 1832 | 2049 | }); |
| 1833 | 2050 | |
| 1834 | 2051 | expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]); |
| ... | ... | @@ -1879,8 +2096,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1879 | 2096 | expandedTurnIds: new Set([turnId]), |
| 1880 | 2097 | isWorking: false, |
| 1881 | 2098 | activeTurnStartedAt: null, |
| 1882 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1883 | | revertTurnCountByUserMessageId: new Map(), |
| 2099 | turnDiffSummaries: [], |
| 2100 | supportsConversationRollback: false, |
| 1884 | 2101 | }); |
| 1885 | 2102 | |
| 1886 | 2103 | expect(rows.find((row) => row.kind === "work")).toMatchObject({ |
| ... | ... | @@ -1947,8 +2164,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1947 | 2164 | }, |
| 1948 | 2165 | isWorking: true, |
| 1949 | 2166 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 1950 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1951 | | revertTurnCountByUserMessageId: new Map(), |
| 2167 | turnDiffSummaries: [], |
| 2168 | supportsConversationRollback: false, |
| 1952 | 2169 | }); |
| 1953 | 2170 | |
| 1954 | 2171 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]); |
| ... | ... | @@ -1994,8 +2211,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1994 | 2211 | latestTurn: null, |
| 1995 | 2212 | isWorking: true, |
| 1996 | 2213 | activeTurnStartedAt: "2026-01-01T00:01:00Z", |
| 1997 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 1998 | | revertTurnCountByUserMessageId: new Map(), |
| 2214 | turnDiffSummaries: [], |
| 2215 | supportsConversationRollback: false, |
| 1999 | 2216 | }); |
| 2000 | 2217 | |
| 2001 | 2218 | expect(rows.some((row) => row.kind === "work-live")).toBe(false); |
| ... | ... | @@ -2055,8 +2272,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2055 | 2272 | }, |
| 2056 | 2273 | isWorking: true, |
| 2057 | 2274 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 2058 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2059 | | revertTurnCountByUserMessageId: new Map(), |
| 2275 | turnDiffSummaries: [], |
| 2276 | supportsConversationRollback: false, |
| 2060 | 2277 | }); |
| 2061 | 2278 | |
| 2062 | 2279 | expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([ |
| ... | ... | @@ -2100,8 +2317,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2100 | 2317 | }, |
| 2101 | 2318 | isWorking: true, |
| 2102 | 2319 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 2103 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2104 | | revertTurnCountByUserMessageId: new Map(), |
| 2320 | turnDiffSummaries: [], |
| 2321 | supportsConversationRollback: false, |
| 2105 | 2322 | }); |
| 2106 | 2323 | |
| 2107 | 2324 | const workLiveRow = rows.find((row) => row.kind === "work-live"); |
| ... | ... | @@ -2148,8 +2365,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2148 | 2365 | }, |
| 2149 | 2366 | isWorking: true, |
| 2150 | 2367 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 2151 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2152 | | revertTurnCountByUserMessageId: new Map(), |
| 2368 | turnDiffSummaries: [], |
| 2369 | supportsConversationRollback: false, |
| 2153 | 2370 | }); |
| 2154 | 2371 | |
| 2155 | 2372 | const initialRows = deriveRows(null); |
| ... | ... | @@ -2226,8 +2443,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2226 | 2443 | runningTurnId: "turn-2" as never, |
| 2227 | 2444 | isWorking: true, |
| 2228 | 2445 | activeTurnStartedAt: "2026-01-01T00:01:00Z", |
| 2229 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2230 | | revertTurnCountByUserMessageId: new Map(), |
| 2446 | turnDiffSummaries: [], |
| 2447 | supportsConversationRollback: false, |
| 2231 | 2448 | }); |
| 2232 | 2449 | |
| 2233 | 2450 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ |
| ... | ... | @@ -2271,8 +2488,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2271 | 2488 | expandedTurnIds: new Set(["turn-1" as never]), |
| 2272 | 2489 | isWorking: false, |
| 2273 | 2490 | activeTurnStartedAt: null, |
| 2274 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2275 | | revertTurnCountByUserMessageId: new Map(), |
| 2491 | turnDiffSummaries: [], |
| 2492 | supportsConversationRollback: false, |
| 2276 | 2493 | }); |
| 2277 | 2494 | |
| 2278 | 2495 | const assistantRows = rows.filter( |
| ... | ... | @@ -2309,8 +2526,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2309 | 2526 | }, |
| 2310 | 2527 | isWorking: true, |
| 2311 | 2528 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 2312 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2313 | | revertTurnCountByUserMessageId: new Map(), |
| 2529 | turnDiffSummaries: [], |
| 2530 | supportsConversationRollback: false, |
| 2314 | 2531 | }); |
| 2315 | 2532 | |
| 2316 | 2533 | const assistantRow = rows.find( |
| ... | ... | @@ -2373,8 +2590,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2373 | 2590 | timelineEntries, |
| 2374 | 2591 | isWorking: false, |
| 2375 | 2592 | activeTurnStartedAt: null, |
| 2376 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2377 | | revertTurnCountByUserMessageId: new Map(), |
| 2593 | turnDiffSummaries: [], |
| 2594 | supportsConversationRollback: false, |
| 2378 | 2595 | }; |
| 2379 | 2596 | const collapsedRows = deriveMessagesTimelineRows(baseInput); |
| 2380 | 2597 | const expandedRows = deriveMessagesTimelineRows({ |
| ... | ... | @@ -2467,8 +2684,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2467 | 2684 | timelineEntries, |
| 2468 | 2685 | isWorking: false, |
| 2469 | 2686 | activeTurnStartedAt: null, |
| 2470 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2471 | | revertTurnCountByUserMessageId: new Map(), |
| 2687 | turnDiffSummaries: [], |
| 2688 | supportsConversationRollback: false, |
| 2472 | 2689 | }); |
| 2473 | 2690 | |
| 2474 | 2691 | expect(row).toMatchObject({ |
| ... | ... | @@ -2509,8 +2726,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2509 | 2726 | expandedTurnIds: new Set([turnId]), |
| 2510 | 2727 | runningTurnId: isWorking ? turnId : null, |
| 2511 | 2728 | activeTurnStartedAt: isWorking ? createdAt : null, |
| 2512 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2513 | | revertTurnCountByUserMessageId: new Map(), |
| 2729 | turnDiffSummaries: [], |
| 2730 | supportsConversationRollback: false, |
| 2514 | 2731 | }; |
| 2515 | 2732 | const expandedRows = deriveMessagesTimelineRows({ |
| 2516 | 2733 | ...input, |
| ... | ... | @@ -2549,8 +2766,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2549 | 2766 | timelineEntries, |
| 2550 | 2767 | isWorking: false, |
| 2551 | 2768 | activeTurnStartedAt: null, |
| 2552 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2553 | | revertTurnCountByUserMessageId: new Map(), |
| 2769 | turnDiffSummaries: [], |
| 2770 | supportsConversationRollback: false, |
| 2554 | 2771 | }); |
| 2555 | 2772 | |
| 2556 | 2773 | expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| ... | ... | @@ -2596,8 +2813,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 2596 | 2813 | timelineEntries, |
| 2597 | 2814 | isWorking: false, |
| 2598 | 2815 | activeTurnStartedAt: null, |
| 2599 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2600 | | revertTurnCountByUserMessageId: new Map(), |
| 2816 | turnDiffSummaries: [], |
| 2817 | supportsConversationRollback: false, |
| 2601 | 2818 | }); |
| 2602 | 2819 | |
| 2603 | 2820 | expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| ... | ... | @@ -2653,8 +2870,8 @@ describe("computeStableMessagesTimelineRows", () => { |
| 2653 | 2870 | runningTurnId: turnId, |
| 2654 | 2871 | isWorking: true, |
| 2655 | 2872 | activeTurnStartedAt: startedAt, |
| 2656 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2657 | | revertTurnCountByUserMessageId: new Map(), |
| 2873 | turnDiffSummaries: [], |
| 2874 | supportsConversationRollback: false, |
| 2658 | 2875 | }; |
| 2659 | 2876 | const assistantEntry = { |
| 2660 | 2877 | id: "assistant-entry", |
| ... | ... | @@ -2731,8 +2948,8 @@ describe("computeStableMessagesTimelineRows", () => { |
| 2731 | 2948 | ], |
| 2732 | 2949 | isWorking: false, |
| 2733 | 2950 | activeTurnStartedAt: null, |
| 2734 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2735 | | revertTurnCountByUserMessageId: new Map(), |
| 2951 | turnDiffSummaries: [], |
| 2952 | supportsConversationRollback: false, |
| 2736 | 2953 | }); |
| 2737 | 2954 | |
| 2738 | 2955 | const initial = computeStableMessagesTimelineRows(rows, { |
| ... | ... | @@ -2780,8 +2997,8 @@ describe("computeStableMessagesTimelineRows", () => { |
| 2780 | 2997 | ], |
| 2781 | 2998 | isWorking: false, |
| 2782 | 2999 | activeTurnStartedAt: null, |
| 2783 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2784 | | revertTurnCountByUserMessageId: new Map(), |
| 3000 | turnDiffSummaries: [], |
| 3001 | supportsConversationRollback: false, |
| 2785 | 3002 | }); |
| 2786 | 3003 | |
| 2787 | 3004 | const firstRows = createRows(); |
| ... | ... | @@ -2836,8 +3053,8 @@ describe("computeStableMessagesTimelineRows", () => { |
| 2836 | 3053 | ], |
| 2837 | 3054 | isWorking: false, |
| 2838 | 3055 | activeTurnStartedAt: null, |
| 2839 | | turnDiffSummaryByAssistantMessageId: new Map(), |
| 2840 | | revertTurnCountByUserMessageId: new Map(), |
| 3056 | turnDiffSummaries: [], |
| 3057 | supportsConversationRollback: false, |
| 2841 | 3058 | }); |
| 2842 | 3059 | |
| 2843 | 3060 | const initial = computeStableMessagesTimelineRows(firstRows, { |