| 1 | import { |
| 2 | MessageId, |
| 3 | RuntimeRequestId, |
| 4 | CheckpointId, |
| 5 | CheckpointScopeId, |
| 6 | NodeId, |
| 7 | PlanId, |
| 8 | ProviderInstanceId, |
| 9 | ProviderThreadId, |
| 10 | RunAttemptId, |
| 11 | RunId, |
| 12 | ScheduledTaskId, |
| 13 | ThreadId, |
| 14 | TurnItemId, |
| 15 | type OrchestrationV2ProjectedTurnItem, |
| 16 | type OrchestrationV2ExecutionNode, |
| 17 | type OrchestrationV2RunAttempt, |
| 18 | type OrchestrationV2TurnItem, |
| 19 | } from "@t3tools/contracts"; |
| 20 | import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell"; |
| 21 | import { deriveMessagesTimelineRows } from "./components/chat/MessagesTimeline.logic"; |
| 22 | import * as DateTime from "effect/DateTime"; |
| 23 | import { describe, expect, it } from "vite-plus/test"; |
| 24 | |
| 25 | import { |
| 26 | deriveActivePlanState, |
| 27 | deriveCanInterruptRunningThread, |
| 28 | deriveTimelineEntriesFromVisibleTurnItems, |
| 29 | deriveTimelineEntriesFromVisibleTurnItemsWithState, |
| 30 | deriveRevertTurnCountByUserMessageId, |
| 31 | derivePhase, |
| 32 | findLatestProposedPlan, |
| 33 | isLatestRunSettled, |
| 34 | selectHandoffImageResources, |
| 35 | selectMessageImageResources, |
| 36 | createMessageAttachmentPreviewProjector, |
| 37 | providerErrorPresentation, |
| 38 | type TimelineEntry, |
| 39 | workEntryIndicatesToolFailure, |
| 40 | workEntryDisplayIndicatesToolFailure, |
| 41 | workEntryIndicatesToolSuccess, |
| 42 | } from "./session-logic"; |
| 43 | import { makeStreamingTimelineFixture, makeThreadProjectionFixture } from "./test-fixtures"; |
| 44 | import type { ChatMessage } from "./types"; |
| 45 | |
| 46 | describe("V2 session presentation", () => { |
| 47 | it("uses run status as the settlement boundary", () => { |
| 48 | const runId = RunId.make("run-1"); |
| 49 | expect( |
| 50 | isLatestRunSettled( |
| 51 | { |
| 52 | runId, |
| 53 | status: "completed", |
| 54 | startedAt: "2026-06-20T00:00:00.000Z", |
| 55 | completedAt: "2026-06-20T00:01:00.000Z", |
| 56 | }, |
| 57 | null, |
| 58 | ), |
| 59 | ).toBe(true); |
| 60 | expect( |
| 61 | isLatestRunSettled( |
| 62 | { runId, status: "running", startedAt: null, completedAt: null }, |
| 63 | { status: "running", activeRunId: runId }, |
| 64 | ), |
| 65 | ).toBe(false); |
| 66 | expect( |
| 67 | isLatestRunSettled( |
| 68 | { runId, status: "queued", startedAt: null, completedAt: null }, |
| 69 | { status: "running", activeRunId: RunId.make("run-active") }, |
| 70 | ), |
| 71 | ).toBe(false); |
| 72 | }); |
| 73 | |
| 74 | it("offers Stop while a run is preparing/starting, not just once it's running (#13392)", () => { |
| 75 | const runtimeWithStatus = ( |
| 76 | status: ThreadRuntimeSummary["status"], |
| 77 | activeRunId: RunId | null = null, |
| 78 | ): ThreadRuntimeSummary => ({ |
| 79 | status, |
| 80 | activeRunId, |
| 81 | providerInstanceId: ProviderInstanceId.make("claude-default"), |
| 82 | providerName: null, |
| 83 | lastError: null, |
| 84 | updatedAt: "2026-09-29T00:00:00.000Z", |
| 85 | }); |
| 86 | const runId = RunId.make("run-stop-while-starting"); |
| 87 | |
| 88 | for (const status of ["preparing", "starting", "running"] as const) { |
| 89 | const runtime = runtimeWithStatus(status, runId); |
| 90 | expect(deriveCanInterruptRunningThread(true, runtime)).toBe(true); |
| 91 | } |
| 92 | |
| 93 | // No active thread: never offer Stop, regardless of run status. |
| 94 | expect(deriveCanInterruptRunningThread(false, runtimeWithStatus("running", runId))).toBe(false); |
| 95 | |
| 96 | // Queued with nothing interruptible: the server rejects interrupting a |
| 97 | // queued run, so Stop stays hidden. |
| 98 | expect(derivePhase(runtimeWithStatus("queued"))).toBe("connecting"); |
| 99 | expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("queued"))).toBe(false); |
| 100 | // Queued behind a run that is still interruptible: Stop targets that run. |
| 101 | expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("queued", runId))).toBe(true); |
| 102 | |
| 103 | // Waiting (e.g. on a subagent) is treated as "running" by derivePhase and |
| 104 | // keeps offering Stop, unchanged from before. |
| 105 | expect(derivePhase(runtimeWithStatus("waiting"))).toBe("running"); |
| 106 | expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("waiting"))).toBe(true); |
| 107 | |
| 108 | // No runtime at all: nothing to interrupt. |
| 109 | expect(deriveCanInterruptRunningThread(true, null)).toBe(false); |
| 110 | }); |
| 111 | |
| 112 | it("labels provider retry progress, delay, recovery, and exhaustion", () => { |
| 113 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 114 | const retryItem = { |
| 115 | id: TurnItemId.make("item-provider-retry"), |
| 116 | threadId: ThreadId.make("thread-provider-retry"), |
| 117 | runId: RunId.make("run-provider-retry"), |
| 118 | nodeId: null, |
| 119 | providerThreadId: null, |
| 120 | providerTurnId: null, |
| 121 | nativeItemRef: null, |
| 122 | parentItemId: null, |
| 123 | ordinal: 1, |
| 124 | status: "running" as const, |
| 125 | title: "Provider retry", |
| 126 | startedAt: now, |
| 127 | completedAt: null, |
| 128 | updatedAt: now, |
| 129 | type: "error" as const, |
| 130 | failure: { |
| 131 | class: "provider_error" as const, |
| 132 | message: "Claude API overloaded.", |
| 133 | code: "api_error_529", |
| 134 | retryable: true, |
| 135 | }, |
| 136 | retry: { |
| 137 | attempt: 2, |
| 138 | maxAttempts: 10, |
| 139 | retryDelayMs: 1_500, |
| 140 | }, |
| 141 | } satisfies Extract<OrchestrationV2TurnItem, { readonly type: "error" }>; |
| 142 | |
| 143 | expect( |
| 144 | providerErrorPresentation({ |
| 145 | ...retryItem, |
| 146 | status: "failed", |
| 147 | failure: { ...retryItem.failure, class: "usage_limit" }, |
| 148 | }), |
| 149 | ).toMatchObject({ label: "Usage limit reached after 2/10 retries" }); |
| 150 | const recoveredLimit = { |
| 151 | ...retryItem, |
| 152 | status: "completed" as const, |
| 153 | completedAt: now, |
| 154 | failure: { ...retryItem.failure, class: "usage_limit" as const }, |
| 155 | }; |
| 156 | const [recoveredEntry] = deriveTimelineEntriesFromVisibleTurnItems({ |
| 157 | visibleTurnItems: [ |
| 158 | { |
| 159 | item: recoveredLimit, |
| 160 | position: 0, |
| 161 | visibility: "local", |
| 162 | sourceThreadId: recoveredLimit.threadId, |
| 163 | sourceItemId: recoveredLimit.id, |
| 164 | }, |
| 165 | ], |
| 166 | optimisticMessages: [], |
| 167 | }); |
| 168 | if (recoveredEntry?.kind !== "work") throw new Error("Expected recovered provider work"); |
| 169 | expect(recoveredEntry.entry.label).toBe("Provider recovered (2/10 retries)"); |
| 170 | expect(recoveredEntry.entry.sourceActivityKind).not.toBe("runtime.warning"); |
| 171 | expect(workEntryDisplayIndicatesToolFailure(recoveredEntry.entry)).toBe(false); |
| 172 | expect(providerErrorPresentation(retryItem)).toEqual({ |
| 173 | label: "Retrying provider (2/10)", |
| 174 | detail: "Claude API overloaded. Retrying in 1.5s.", |
| 175 | }); |
| 176 | expect( |
| 177 | providerErrorPresentation({ |
| 178 | ...retryItem, |
| 179 | status: "completed", |
| 180 | completedAt: now, |
| 181 | }), |
| 182 | ).toMatchObject({ label: "Provider recovered (2/10 retries)" }); |
| 183 | expect( |
| 184 | providerErrorPresentation({ |
| 185 | ...retryItem, |
| 186 | status: "failed", |
| 187 | retry: { ...retryItem.retry, attempt: 10 }, |
| 188 | completedAt: now, |
| 189 | }), |
| 190 | ).toMatchObject({ label: "Provider error after 10/10 retries" }); |
| 191 | }); |
| 192 | |
| 193 | it("selects the latest proposed plan for a run", () => { |
| 194 | const runId = RunId.make("run-1"); |
| 195 | const planId = PlanId.make("plan-1"); |
| 196 | const nodeId = NodeId.make("node-plan"); |
| 197 | const now = DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"); |
| 198 | const baseProjection = makeThreadProjectionFixture(); |
| 199 | const plan = findLatestProposedPlan( |
| 200 | { |
| 201 | ...baseProjection, |
| 202 | plans: [ |
| 203 | { |
| 204 | id: planId, |
| 205 | threadId: baseProjection.thread.id, |
| 206 | nodeId, |
| 207 | kind: "proposed_plan" as const, |
| 208 | markdown: "Plan", |
| 209 | status: "active" as const, |
| 210 | runId, |
| 211 | }, |
| 212 | ], |
| 213 | turnItems: [ |
| 214 | { |
| 215 | id: TurnItemId.make("item-plan"), |
| 216 | threadId: baseProjection.thread.id, |
| 217 | nodeId, |
| 218 | providerThreadId: null, |
| 219 | providerTurnId: null, |
| 220 | nativeItemRef: null, |
| 221 | parentItemId: null, |
| 222 | ordinal: 0, |
| 223 | status: "completed" as const, |
| 224 | title: null, |
| 225 | startedAt: now, |
| 226 | completedAt: now, |
| 227 | updatedAt: now, |
| 228 | type: "proposed_plan" as const, |
| 229 | planId, |
| 230 | markdown: "Plan", |
| 231 | streaming: false, |
| 232 | runId, |
| 233 | }, |
| 234 | ], |
| 235 | updatedAt: now, |
| 236 | }, |
| 237 | runId, |
| 238 | ); |
| 239 | expect(plan?.planMarkdown).toBe("Plan"); |
| 240 | }); |
| 241 | |
| 242 | it("assigns run rollback to the turn-start message instead of a later steer", () => { |
| 243 | const runId = RunId.make("run-steered"); |
| 244 | const turnStartMessageId = MessageId.make("message-turn-start"); |
| 245 | const steerMessageId = MessageId.make("message-steer"); |
| 246 | const assistantMessageId = MessageId.make("message-assistant"); |
| 247 | const messages: ChatMessage[] = [ |
| 248 | { |
| 249 | id: turnStartMessageId, |
| 250 | role: "user", |
| 251 | text: "Start", |
| 252 | runId, |
| 253 | inputIntent: "turn_start", |
| 254 | streaming: false, |
| 255 | createdAt: "2026-06-20T00:00:00.000Z", |
| 256 | updatedAt: "2026-06-20T00:00:00.000Z", |
| 257 | }, |
| 258 | { |
| 259 | id: steerMessageId, |
| 260 | role: "user", |
| 261 | text: "Steer", |
| 262 | runId, |
| 263 | inputIntent: "steer", |
| 264 | streaming: false, |
| 265 | createdAt: "2026-06-20T00:00:01.000Z", |
| 266 | updatedAt: "2026-06-20T00:00:01.000Z", |
| 267 | }, |
| 268 | { |
| 269 | id: assistantMessageId, |
| 270 | role: "assistant", |
| 271 | text: "Done", |
| 272 | runId, |
| 273 | streaming: false, |
| 274 | createdAt: "2026-06-20T00:00:02.000Z", |
| 275 | updatedAt: "2026-06-20T00:00:02.000Z", |
| 276 | }, |
| 277 | ]; |
| 278 | const timelineEntries: TimelineEntry[] = messages.map((message): TimelineEntry => ({ |
| 279 | id: message.id, |
| 280 | kind: "message", |
| 281 | createdAt: message.createdAt, |
| 282 | message, |
| 283 | })); |
| 284 | |
| 285 | const targets = deriveRevertTurnCountByUserMessageId({ |
| 286 | timelineEntries, |
| 287 | checkpoints: [ |
| 288 | { |
| 289 | runId, |
| 290 | checkpointTurnCount: 1, |
| 291 | checkpointRef: "checkpoint-run-1" as never, |
| 292 | status: "ready", |
| 293 | files: [], |
| 294 | assistantMessageId, |
| 295 | completedAt: "2026-06-20T00:00:03.000Z", |
| 296 | }, |
| 297 | ], |
| 298 | }); |
| 299 | |
| 300 | expect([...targets]).toEqual([[turnStartMessageId, 0]]); |
| 301 | expect(targets.has(steerMessageId)).toBe(false); |
| 302 | }); |
| 303 | |
| 304 | it("uses visible turn item order and keeps provider errors in the work log", () => { |
| 305 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 306 | const threadId = ThreadId.make("thread-visible"); |
| 307 | const runId = RunId.make("run-visible"); |
| 308 | const base = (id: string, ordinal: number) => ({ |
| 309 | id: TurnItemId.make(id), |
| 310 | threadId, |
| 311 | runId, |
| 312 | nodeId: null, |
| 313 | providerThreadId: null, |
| 314 | providerTurnId: null, |
| 315 | nativeItemRef: null, |
| 316 | parentItemId: null, |
| 317 | ordinal, |
| 318 | status: "completed" as const, |
| 319 | title: null, |
| 320 | startedAt: now, |
| 321 | completedAt: now, |
| 322 | updatedAt: now, |
| 323 | }); |
| 324 | const userItem = { |
| 325 | ...base("item-user", 0), |
| 326 | type: "user_message" as const, |
| 327 | messageId: MessageId.make("message-user"), |
| 328 | inputIntent: "turn_start" as const, |
| 329 | text: "Start", |
| 330 | attachments: [], |
| 331 | createdBy: "user" as const, |
| 332 | creationSource: "web" as const, |
| 333 | } satisfies OrchestrationV2TurnItem; |
| 334 | const requestItem = { |
| 335 | ...base("item-interrupt-request", 1), |
| 336 | type: "run_interrupt_request" as const, |
| 337 | message: "Stopping", |
| 338 | } satisfies OrchestrationV2TurnItem; |
| 339 | const commandItem = { |
| 340 | ...base("item-command", 2), |
| 341 | type: "command_execution" as const, |
| 342 | input: "sleep 1", |
| 343 | output: "done", |
| 344 | exitCode: 0, |
| 345 | } satisfies OrchestrationV2TurnItem; |
| 346 | const resultItem = { |
| 347 | ...base("item-interrupt-result", 3), |
| 348 | type: "run_interrupt_result" as const, |
| 349 | message: "Stopped", |
| 350 | } satisfies OrchestrationV2TurnItem; |
| 351 | const todoItem = { |
| 352 | ...base("item-todo", 4), |
| 353 | type: "todo_list" as const, |
| 354 | planId: PlanId.make("plan-visible"), |
| 355 | explanation: "Keep task detail in the Tasks panel", |
| 356 | steps: [ |
| 357 | { id: "step-1", text: "First", status: "completed" as const }, |
| 358 | { id: "step-2", text: "Second", status: "pending" as const }, |
| 359 | ], |
| 360 | } satisfies OrchestrationV2TurnItem; |
| 361 | const errorItem = { |
| 362 | ...base("item-error", 5), |
| 363 | status: "failed" as const, |
| 364 | type: "error" as const, |
| 365 | failure: { |
| 366 | class: "validation_error" as const, |
| 367 | message: "Invalid reasoning effort.", |
| 368 | code: "invalid_request", |
| 369 | retryable: false, |
| 370 | }, |
| 371 | } satisfies OrchestrationV2TurnItem; |
| 372 | const threadCreatedItem = { |
| 373 | ...base("item-thread-created", 6), |
| 374 | type: "thread_created" as const, |
| 375 | title: "Follow-up thread", |
| 376 | targetThreadId: ThreadId.make("thread-follow-up"), |
| 377 | targetRunId: RunId.make("run-follow-up"), |
| 378 | targetProviderInstanceId: ProviderInstanceId.make("claude-default"), |
| 379 | targetModel: "claude-sonnet-4-6", |
| 380 | } satisfies OrchestrationV2TurnItem; |
| 381 | const workspacePreparationItem = { |
| 382 | ...base("item-workspace-preparation", 7), |
| 383 | type: "command_execution" as const, |
| 384 | title: "Workspace ready", |
| 385 | input: "Preparing workspace", |
| 386 | output: "Workspace preparation completed.", |
| 387 | exitCode: 0, |
| 388 | } satisfies OrchestrationV2TurnItem; |
| 389 | const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [ |
| 390 | userItem, |
| 391 | requestItem, |
| 392 | commandItem, |
| 393 | resultItem, |
| 394 | todoItem, |
| 395 | errorItem, |
| 396 | threadCreatedItem, |
| 397 | workspacePreparationItem, |
| 398 | ].map((item, position) => ({ |
| 399 | position, |
| 400 | visibility: "local" as const, |
| 401 | sourceThreadId: threadId, |
| 402 | sourceItemId: item.id, |
| 403 | item, |
| 404 | })); |
| 405 | |
| 406 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 407 | visibleTurnItems, |
| 408 | optimisticMessages: [], |
| 409 | }); |
| 410 | |
| 411 | expect(entries.map((entry) => [entry.kind, entry.id])).toEqual([ |
| 412 | ["message", userItem.messageId], |
| 413 | ["event", requestItem.id], |
| 414 | ["work", commandItem.id], |
| 415 | ["event", resultItem.id], |
| 416 | ["work", errorItem.id], |
| 417 | ["work", threadCreatedItem.id], |
| 418 | ]); |
| 419 | const commandEntry = entries[2]; |
| 420 | const userEntry = entries[0]; |
| 421 | expect(userEntry?.kind).toBe("message"); |
| 422 | if (userEntry?.kind === "message") { |
| 423 | expect(userEntry.projectedItem).toBe(visibleTurnItems[0]); |
| 424 | expect(userEntry.message.inputIntent).toBe("turn_start"); |
| 425 | expect(userEntry.message.createdBy).toBe("user"); |
| 426 | expect(userEntry.message.creationSource).toBe("web"); |
| 427 | } |
| 428 | expect(commandEntry?.kind).toBe("work"); |
| 429 | if (commandEntry?.kind === "work") { |
| 430 | expect(commandEntry.entry.projectedItem).toBe(visibleTurnItems[2]); |
| 431 | expect(commandEntry.entry.structuredPayload).toBe(commandItem); |
| 432 | expect(commandEntry.entry.command).toBe(commandItem.input); |
| 433 | expect(commandEntry.entry.detail).toBeUndefined(); |
| 434 | } |
| 435 | const errorEntry = entries[4]; |
| 436 | expect(errorEntry?.kind).toBe("work"); |
| 437 | if (errorEntry?.kind === "work") { |
| 438 | expect(errorEntry.entry.projectedItem).toBe(visibleTurnItems[5]); |
| 439 | expect(errorEntry.entry.label).toBe("Provider error"); |
| 440 | expect(errorEntry.entry.detail).toBe("Invalid reasoning effort."); |
| 441 | expect(errorEntry.entry.tone).toBe("info"); |
| 442 | expect(errorEntry.entry.toolLifecycleStatus).toBe("failed"); |
| 443 | } |
| 444 | const threadCreatedEntry = entries[5]; |
| 445 | expect(threadCreatedEntry?.kind).toBe("work"); |
| 446 | if (threadCreatedEntry?.kind === "work") { |
| 447 | expect(threadCreatedEntry.entry.projectedItem?.item.type).toBe("thread_created"); |
| 448 | } |
| 449 | }); |
| 450 | |
| 451 | it.each(["pending", "running", "completed"] as const)( |
| 452 | "keeps %s task progress available to the composer and out of the timeline", |
| 453 | (stepStatus) => { |
| 454 | const projection = makeThreadProjectionFixture(); |
| 455 | const now = DateTime.makeUnsafe("2026-09-04T00:00:00.000Z"); |
| 456 | const runId = RunId.make("run-tasks"); |
| 457 | const nodeId = NodeId.make("node-tasks"); |
| 458 | const planId = PlanId.make("plan-tasks"); |
| 459 | const steps = [{ id: "step-1", text: "Verify the change", status: stepStatus }]; |
| 460 | const item = { |
| 461 | id: TurnItemId.make("item-tasks"), |
| 462 | threadId: projection.thread.id, |
| 463 | runId, |
| 464 | nodeId, |
| 465 | providerThreadId: null, |
| 466 | providerTurnId: null, |
| 467 | nativeItemRef: null, |
| 468 | parentItemId: null, |
| 469 | ordinal: 0, |
| 470 | status: "completed" as const, |
| 471 | title: null, |
| 472 | startedAt: now, |
| 473 | completedAt: now, |
| 474 | updatedAt: now, |
| 475 | type: "todo_list" as const, |
| 476 | planId, |
| 477 | steps, |
| 478 | } satisfies OrchestrationV2TurnItem; |
| 479 | const plans = [ |
| 480 | { |
| 481 | id: planId, |
| 482 | threadId: projection.thread.id, |
| 483 | runId, |
| 484 | nodeId, |
| 485 | kind: "todo_list" as const, |
| 486 | status: "active" as const, |
| 487 | steps, |
| 488 | }, |
| 489 | ]; |
| 490 | |
| 491 | expect( |
| 492 | deriveTimelineEntriesFromVisibleTurnItems({ |
| 493 | visibleTurnItems: [ |
| 494 | { |
| 495 | position: 0, |
| 496 | visibility: "local", |
| 497 | sourceThreadId: projection.thread.id, |
| 498 | sourceItemId: item.id, |
| 499 | item, |
| 500 | }, |
| 501 | ], |
| 502 | optimisticMessages: [], |
| 503 | plans, |
| 504 | }), |
| 505 | ).toEqual([]); |
| 506 | expect( |
| 507 | deriveActivePlanState({ ...projection, plans, turnItems: [item] }, runId), |
| 508 | ).toMatchObject({ |
| 509 | runId, |
| 510 | steps: [ |
| 511 | { |
| 512 | step: "Verify the change", |
| 513 | status: stepStatus === "running" ? "inProgress" : stepStatus, |
| 514 | }, |
| 515 | ], |
| 516 | }); |
| 517 | }, |
| 518 | ); |
| 519 | |
| 520 | it("preserves independently derived durations for repeated plan-step labels", () => { |
| 521 | const projection = makeThreadProjectionFixture(); |
| 522 | const runId = RunId.make("run-timed-tasks"); |
| 523 | const planId = PlanId.make("plan-timed-tasks"); |
| 524 | const plan = { |
| 525 | id: planId, |
| 526 | threadId: projection.thread.id, |
| 527 | runId, |
| 528 | nodeId: NodeId.make("node-timed-tasks"), |
| 529 | kind: "todo_list" as const, |
| 530 | status: "active" as const, |
| 531 | steps: [ |
| 532 | { id: "verify-a", text: "Verify", status: "completed" as const, durationMs: 3_000 }, |
| 533 | { id: "verify-b", text: "Verify", status: "completed" as const, durationMs: 4_000 }, |
| 534 | { id: "report", text: "Report", status: "pending" as const }, |
| 535 | ], |
| 536 | }; |
| 537 | |
| 538 | expect(deriveActivePlanState({ ...projection, plans: [plan] }, runId)?.steps).toEqual([ |
| 539 | { step: "Verify", status: "completed", durationMs: 3_000 }, |
| 540 | { step: "Verify", status: "completed", durationMs: 4_000 }, |
| 541 | { step: "Report", status: "pending" }, |
| 542 | ]); |
| 543 | }); |
| 544 | |
| 545 | it("keeps failed tool items tool-toned so groups still summarize", () => { |
| 546 | const failedCommand = { |
| 547 | id: TurnItemId.make("item-failed-command"), |
| 548 | threadId: ThreadId.make("thread-1"), |
| 549 | runId: RunId.make("run-1"), |
| 550 | nodeId: null, |
| 551 | providerThreadId: null, |
| 552 | providerTurnId: null, |
| 553 | nativeItemRef: null, |
| 554 | parentItemId: null, |
| 555 | ordinal: 0, |
| 556 | status: "failed" as const, |
| 557 | title: null, |
| 558 | startedAt: null, |
| 559 | completedAt: null, |
| 560 | updatedAt: DateTime.nowUnsafe(), |
| 561 | type: "command_execution" as const, |
| 562 | input: "ssh host true", |
| 563 | output: "connection refused", |
| 564 | exitCode: 255, |
| 565 | } satisfies OrchestrationV2TurnItem; |
| 566 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 567 | visibleTurnItems: [ |
| 568 | { |
| 569 | position: 0, |
| 570 | visibility: "local" as const, |
| 571 | sourceThreadId: ThreadId.make("thread-1"), |
| 572 | sourceItemId: failedCommand.id, |
| 573 | item: failedCommand, |
| 574 | } as never, |
| 575 | ], |
| 576 | optimisticMessages: [], |
| 577 | }); |
| 578 | const entry = entries[0]; |
| 579 | expect(entry?.kind).toBe("work"); |
| 580 | if (entry?.kind === "work") { |
| 581 | // An exit-code failure is still an ordinary tool row: the failed |
| 582 | // lifecycle status carries the marker, and an "error" tone here would |
| 583 | // knock the whole group out of the "Ran N commands" summary. |
| 584 | expect(entry.entry.tone).toBe("tool"); |
| 585 | expect(entry.entry.toolLifecycleStatus).toBe("failed"); |
| 586 | } |
| 587 | }); |
| 588 | |
| 589 | it("waits for a dispatched turn item before adding queued input to the timeline", () => { |
| 590 | const projection = makeThreadProjectionFixture(); |
| 591 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 592 | const runId = RunId.make("run-dispatched-queued"); |
| 593 | const messageId = MessageId.make("message-dispatched-queued"); |
| 594 | const optimisticMessage = { |
| 595 | id: messageId, |
| 596 | role: "user" as const, |
| 597 | text: "Queued input", |
| 598 | runId: null, |
| 599 | inputIntent: "queued_turn" as const, |
| 600 | streaming: false, |
| 601 | createdAt: DateTime.formatIso(now), |
| 602 | updatedAt: DateTime.formatIso(now), |
| 603 | }; |
| 604 | |
| 605 | expect( |
| 606 | deriveTimelineEntriesFromVisibleTurnItems({ |
| 607 | visibleTurnItems: [], |
| 608 | optimisticMessages: [optimisticMessage], |
| 609 | }), |
| 610 | ).toEqual([]); |
| 611 | |
| 612 | const dispatchedItem = { |
| 613 | id: TurnItemId.make("item-dispatched-queued"), |
| 614 | threadId: projection.thread.id, |
| 615 | runId, |
| 616 | nodeId: null, |
| 617 | providerThreadId: null, |
| 618 | providerTurnId: null, |
| 619 | nativeItemRef: null, |
| 620 | parentItemId: null, |
| 621 | ordinal: 200, |
| 622 | status: "completed" as const, |
| 623 | title: null, |
| 624 | startedAt: now, |
| 625 | completedAt: now, |
| 626 | updatedAt: now, |
| 627 | type: "user_message" as const, |
| 628 | messageId, |
| 629 | inputIntent: "turn_start" as const, |
| 630 | text: "Queued input", |
| 631 | attachments: [], |
| 632 | createdBy: "agent" as const, |
| 633 | creationSource: "mcp" as const, |
| 634 | scheduledTaskId: ScheduledTaskId.make("task-queued"), |
| 635 | senderThreadId: ThreadId.make("thread-agent-sender"), |
| 636 | } satisfies OrchestrationV2TurnItem; |
| 637 | const promotedEntries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 638 | visibleTurnItems: [ |
| 639 | { |
| 640 | position: 0, |
| 641 | visibility: "local", |
| 642 | sourceThreadId: projection.thread.id, |
| 643 | sourceItemId: dispatchedItem.id, |
| 644 | item: dispatchedItem, |
| 645 | }, |
| 646 | ], |
| 647 | optimisticMessages: [optimisticMessage], |
| 648 | }); |
| 649 | expect(promotedEntries.map((entry) => entry.id)).toEqual([messageId]); |
| 650 | expect(promotedEntries[0]?.kind).toBe("message"); |
| 651 | if (promotedEntries[0]?.kind === "message") { |
| 652 | expect(promotedEntries[0].message.inputIntent).toBe("turn_start"); |
| 653 | expect(promotedEntries[0].message.scheduledTaskId).toBe("task-queued"); |
| 654 | expect(promotedEntries[0].message.senderThreadId).toBe("thread-agent-sender"); |
| 655 | } |
| 656 | }); |
| 657 | |
| 658 | it("anchors feedback before later committed turns without reordering canonical history", () => { |
| 659 | const threadId = ThreadId.make("thread-feedback-order"); |
| 660 | const messageItem = (input: { |
| 661 | readonly id: string; |
| 662 | readonly role: "user" | "assistant"; |
| 663 | readonly createdAt: string; |
| 664 | readonly ordinal: number; |
| 665 | }): OrchestrationV2TurnItem => { |
| 666 | const timestamp = DateTime.makeUnsafe(input.createdAt); |
| 667 | const common = { |
| 668 | id: TurnItemId.make(`item-${input.id}`), |
| 669 | threadId, |
| 670 | runId: RunId.make(`run-${input.ordinal}`), |
| 671 | nodeId: null, |
| 672 | providerThreadId: null, |
| 673 | providerTurnId: null, |
| 674 | nativeItemRef: null, |
| 675 | parentItemId: null, |
| 676 | ordinal: input.ordinal, |
| 677 | status: "completed" as const, |
| 678 | title: null, |
| 679 | startedAt: timestamp, |
| 680 | completedAt: timestamp, |
| 681 | updatedAt: timestamp, |
| 682 | messageId: MessageId.make(input.id), |
| 683 | text: input.id, |
| 684 | }; |
| 685 | return input.role === "user" |
| 686 | ? { |
| 687 | ...common, |
| 688 | type: "user_message", |
| 689 | inputIntent: "turn_start", |
| 690 | attachments: [], |
| 691 | createdBy: "user", |
| 692 | creationSource: "web", |
| 693 | } |
| 694 | : { ...common, type: "assistant_message", streaming: false }; |
| 695 | }; |
| 696 | const canonicalItems = [ |
| 697 | messageItem({ id: "old-user", role: "user", createdAt: "2026-08-29T00:00:01Z", ordinal: 1 }), |
| 698 | messageItem({ |
| 699 | id: "old-assistant", |
| 700 | role: "assistant", |
| 701 | createdAt: "2026-08-29T00:00:02Z", |
| 702 | ordinal: 2, |
| 703 | }), |
| 704 | messageItem({ |
| 705 | id: "later-user", |
| 706 | role: "user", |
| 707 | createdAt: "2026-08-29T00:00:05Z", |
| 708 | ordinal: 3, |
| 709 | }), |
| 710 | messageItem({ |
| 711 | id: "later-assistant", |
| 712 | role: "assistant", |
| 713 | createdAt: "2026-08-29T00:00:04Z", |
| 714 | ordinal: 4, |
| 715 | }), |
| 716 | ]; |
| 717 | const visibleTurnItems = canonicalItems.map((item, position) => ({ |
| 718 | position, |
| 719 | visibility: "local" as const, |
| 720 | sourceThreadId: threadId, |
| 721 | sourceItemId: item.id, |
| 722 | item, |
| 723 | })); |
| 724 | const feedback = (id: string, role: "user" | "assistant"): ChatMessage => ({ |
| 725 | id: MessageId.make(id), |
| 726 | role, |
| 727 | text: id, |
| 728 | runId: null, |
| 729 | streaming: false, |
| 730 | createdAt: "2026-08-29T00:00:03Z", |
| 731 | updatedAt: "2026-08-29T00:00:03Z", |
| 732 | }); |
| 733 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 734 | visibleTurnItems, |
| 735 | anchoredMessages: [ |
| 736 | feedback("feedback-user", "user"), |
| 737 | feedback("feedback-assistant", "assistant"), |
| 738 | feedback("later-user", "user"), |
| 739 | ], |
| 740 | optimisticMessages: [ |
| 741 | { ...feedback("optimistic-user", "user"), createdAt: "2026-08-29T00:00:00Z" }, |
| 742 | ], |
| 743 | }); |
| 744 | |
| 745 | expect(entries.map((entry) => entry.id)).toEqual([ |
| 746 | "old-user", |
| 747 | "old-assistant", |
| 748 | "feedback-user", |
| 749 | "feedback-assistant", |
| 750 | "later-user", |
| 751 | "later-assistant", |
| 752 | "optimistic-user", |
| 753 | ]); |
| 754 | expect( |
| 755 | entries |
| 756 | .filter((entry) => entry.id.startsWith("feedback-")) |
| 757 | .every((entry) => entry.kind === "message" && entry.projectedItem === undefined), |
| 758 | ).toBe(true); |
| 759 | }); |
| 760 | |
| 761 | it("uses projected plan status and file contents in timeline entries", () => { |
| 762 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 763 | const threadId = ThreadId.make("thread-timeline-artifacts"); |
| 764 | const runId = RunId.make("run-timeline-artifacts"); |
| 765 | const nodeId = NodeId.make("node-timeline-artifacts"); |
| 766 | const planId = PlanId.make("plan-timeline-artifacts"); |
| 767 | const base = { |
| 768 | threadId, |
| 769 | runId, |
| 770 | nodeId, |
| 771 | providerThreadId: null, |
| 772 | providerTurnId: null, |
| 773 | nativeItemRef: null, |
| 774 | parentItemId: null, |
| 775 | status: "completed" as const, |
| 776 | title: null, |
| 777 | startedAt: now, |
| 778 | completedAt: now, |
| 779 | updatedAt: now, |
| 780 | }; |
| 781 | const planItem = { |
| 782 | ...base, |
| 783 | id: TurnItemId.make("item-proposed-plan"), |
| 784 | ordinal: 0, |
| 785 | type: "proposed_plan" as const, |
| 786 | planId, |
| 787 | markdown: "Finished plan", |
| 788 | streaming: false, |
| 789 | } satisfies OrchestrationV2TurnItem; |
| 790 | const fileItem = { |
| 791 | ...base, |
| 792 | id: TurnItemId.make("item-file-change"), |
| 793 | ordinal: 1, |
| 794 | type: "file_change" as const, |
| 795 | fileName: "src/example.ts", |
| 796 | newStr: "export const answer = 42;\n", |
| 797 | } satisfies OrchestrationV2TurnItem; |
| 798 | const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [ |
| 799 | planItem, |
| 800 | fileItem, |
| 801 | ].map((item, position) => ({ |
| 802 | position, |
| 803 | visibility: "local", |
| 804 | sourceThreadId: threadId, |
| 805 | sourceItemId: item.id, |
| 806 | item, |
| 807 | })); |
| 808 | |
| 809 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 810 | visibleTurnItems, |
| 811 | optimisticMessages: [], |
| 812 | plans: [ |
| 813 | { |
| 814 | id: planId, |
| 815 | threadId, |
| 816 | runId, |
| 817 | nodeId, |
| 818 | kind: "proposed_plan", |
| 819 | markdown: "", |
| 820 | status: "completed", |
| 821 | detailInTurnItem: true, |
| 822 | }, |
| 823 | ], |
| 824 | }); |
| 825 | |
| 826 | expect(entries[0]?.kind).toBe("proposed-plan"); |
| 827 | if (entries[0]?.kind === "proposed-plan") { |
| 828 | expect(entries[0].proposedPlan.status).toBe("completed"); |
| 829 | expect(entries[0].proposedPlan.planMarkdown).toBe("Finished plan"); |
| 830 | } |
| 831 | expect(entries[1]?.kind).toBe("work"); |
| 832 | if (entries[1]?.kind === "work") { |
| 833 | expect(entries[1].entry.detail).toBeUndefined(); |
| 834 | expect(entries[1].entry.changedFiles).toEqual([fileItem.fileName]); |
| 835 | } |
| 836 | }); |
| 837 | |
| 838 | it("resolves attempt identity through V2 execution nodes", () => { |
| 839 | const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
| 840 | const threadId = ThreadId.make("thread-attempts"); |
| 841 | const runId = RunId.make("run-steered"); |
| 842 | const supersededRootNodeId = NodeId.make("node-attempt-1-root"); |
| 843 | const supersededChildNodeId = NodeId.make("node-attempt-1-child"); |
| 844 | const activeRootNodeId = NodeId.make("node-attempt-2-root"); |
| 845 | const supersededAttemptId = RunAttemptId.make("attempt-1"); |
| 846 | const activeAttemptId = RunAttemptId.make("attempt-2"); |
| 847 | const providerInstanceId = ProviderInstanceId.make("codex-default"); |
| 848 | const providerThreadId = ProviderThreadId.make("provider-thread-attempts"); |
| 849 | const attempts: ReadonlyArray<OrchestrationV2RunAttempt> = [ |
| 850 | { |
| 851 | id: supersededAttemptId, |
| 852 | runId, |
| 853 | attemptOrdinal: 1, |
| 854 | rootNodeId: supersededRootNodeId, |
| 855 | providerInstanceId, |
| 856 | providerThreadId, |
| 857 | providerTurnId: null, |
| 858 | reason: "initial", |
| 859 | status: "superseded", |
| 860 | startedAt: now, |
| 861 | completedAt: now, |
| 862 | }, |
| 863 | { |
| 864 | id: activeAttemptId, |
| 865 | runId, |
| 866 | attemptOrdinal: 2, |
| 867 | rootNodeId: activeRootNodeId, |
| 868 | providerInstanceId, |
| 869 | providerThreadId, |
| 870 | providerTurnId: null, |
| 871 | reason: "steering_restart", |
| 872 | status: "running", |
| 873 | startedAt: now, |
| 874 | completedAt: null, |
| 875 | }, |
| 876 | ]; |
| 877 | const node = ( |
| 878 | id: OrchestrationV2ExecutionNode["id"], |
| 879 | rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"], |
| 880 | parentNodeId: OrchestrationV2ExecutionNode["parentNodeId"], |
| 881 | ): OrchestrationV2ExecutionNode => ({ |
| 882 | id, |
| 883 | threadId, |
| 884 | runId, |
| 885 | parentNodeId, |
| 886 | rootNodeId, |
| 887 | kind: id === rootNodeId ? "root_turn" : "assistant_message", |
| 888 | status: "running", |
| 889 | countsForRun: true, |
| 890 | providerThreadId, |
| 891 | providerTurnId: null, |
| 892 | nativeItemRef: null, |
| 893 | runtimeRequestId: null, |
| 894 | checkpointScopeId: null, |
| 895 | startedAt: now, |
| 896 | completedAt: null, |
| 897 | }); |
| 898 | const nodes = [ |
| 899 | node(supersededRootNodeId, supersededRootNodeId, null), |
| 900 | node(supersededChildNodeId, supersededRootNodeId, supersededRootNodeId), |
| 901 | node(activeRootNodeId, activeRootNodeId, null), |
| 902 | ]; |
| 903 | const assistantItem = ( |
| 904 | id: string, |
| 905 | messageId: string, |
| 906 | nodeId: NodeId, |
| 907 | text: string, |
| 908 | ordinal: number, |
| 909 | ): OrchestrationV2TurnItem => ({ |
| 910 | id: TurnItemId.make(id), |
| 911 | threadId, |
| 912 | runId, |
| 913 | nodeId, |
| 914 | providerThreadId, |
| 915 | providerTurnId: null, |
| 916 | nativeItemRef: null, |
| 917 | parentItemId: null, |
| 918 | ordinal, |
| 919 | status: "running", |
| 920 | title: null, |
| 921 | startedAt: now, |
| 922 | completedAt: null, |
| 923 | updatedAt: now, |
| 924 | type: "assistant_message", |
| 925 | messageId: MessageId.make(messageId), |
| 926 | text, |
| 927 | streaming: true, |
| 928 | }); |
| 929 | const items = [ |
| 930 | assistantItem( |
| 931 | "item-superseded", |
| 932 | "message-superseded", |
| 933 | supersededChildNodeId, |
| 934 | "Partial old response", |
| 935 | 0, |
| 936 | ), |
| 937 | assistantItem("item-active", "message-active", activeRootNodeId, "Current response", 1), |
| 938 | ]; |
| 939 | const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = items.map( |
| 940 | (item, position) => ({ |
| 941 | position, |
| 942 | visibility: "local", |
| 943 | sourceThreadId: threadId, |
| 944 | sourceItemId: item.id, |
| 945 | item, |
| 946 | }), |
| 947 | ); |
| 948 | |
| 949 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 950 | visibleTurnItems, |
| 951 | optimisticMessages: [], |
| 952 | attempts, |
| 953 | nodes, |
| 954 | }); |
| 955 | |
| 956 | expect(entries.map((entry) => [entry.attempt?.id, entry.attempt?.status])).toEqual([ |
| 957 | [supersededAttemptId, "superseded"], |
| 958 | [activeAttemptId, "running"], |
| 959 | ]); |
| 960 | const input = { visibleTurnItems, optimisticMessages: [], attempts, nodes }; |
| 961 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 962 | const nextInput = { |
| 963 | ...input, |
| 964 | attempts: attempts.map((attempt) => |
| 965 | attempt.id === activeAttemptId ? { ...attempt, status: "superseded" as const } : attempt, |
| 966 | ), |
| 967 | }; |
| 968 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 969 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 970 | expect(next.entries.at(-1)?.attempt?.status).toBe("superseded"); |
| 971 | expect(previous.entries.at(-1)?.attempt?.status).toBe("running"); |
| 972 | }); |
| 973 | }); |
| 974 | |
| 975 | describe("native provider presentation in the v2 timeline", () => { |
| 976 | const timestamp = DateTime.makeUnsafe("2026-09-04T12:00:00.000Z"); |
| 977 | const base = { |
| 978 | id: TurnItemId.make("native-item"), |
| 979 | threadId: ThreadId.make("native-thread"), |
| 980 | runId: RunId.make("native-run"), |
| 981 | nodeId: null, |
| 982 | providerThreadId: null, |
| 983 | providerTurnId: null, |
| 984 | nativeItemRef: null, |
| 985 | parentItemId: null, |
| 986 | ordinal: 0, |
| 987 | status: "completed" as const, |
| 988 | title: null, |
| 989 | startedAt: timestamp, |
| 990 | completedAt: timestamp, |
| 991 | updatedAt: timestamp, |
| 992 | }; |
| 993 | const visible = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({ |
| 994 | position: 0, |
| 995 | visibility: "local", |
| 996 | sourceThreadId: item.threadId, |
| 997 | sourceItemId: item.id, |
| 998 | item, |
| 999 | }); |
| 1000 | |
| 1001 | it("keeps async answers in the question row, including incrementally appended replies", () => { |
| 1002 | const requestId = RuntimeRequestId.make("question"); |
| 1003 | const question: OrchestrationV2TurnItem = { |
| 1004 | ...base, |
| 1005 | type: "user_input_request", |
| 1006 | requestId, |
| 1007 | questions: [], |
| 1008 | questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} }, |
| 1009 | }; |
| 1010 | const reply: OrchestrationV2TurnItem = { |
| 1011 | ...base, |
| 1012 | id: TurnItemId.make("answer"), |
| 1013 | type: "user_message", |
| 1014 | messageId: MessageId.make(`async-answer:${requestId}`), |
| 1015 | inputIntent: "steer", |
| 1016 | text: "Which color?\nBlue", |
| 1017 | createdBy: "user", |
| 1018 | creationSource: "server", |
| 1019 | attachments: [], |
| 1020 | }; |
| 1021 | const input = { visibleTurnItems: [visible(question)], optimisticMessages: [] }; |
| 1022 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1023 | const nextInput = { ...input, visibleTurnItems: [...input.visibleTurnItems, visible(reply)] }; |
| 1024 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1025 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1026 | expect(next.entries).toHaveLength(1); |
| 1027 | const replyFirst = { ...input, visibleTurnItems: [visible(reply)] }; |
| 1028 | const replyProjection = deriveTimelineEntriesFromVisibleTurnItemsWithState(replyFirst); |
| 1029 | const questionAfterReply = { |
| 1030 | ...input, |
| 1031 | visibleTurnItems: [...replyFirst.visibleTurnItems, visible(question)], |
| 1032 | }; |
| 1033 | expect( |
| 1034 | deriveTimelineEntriesFromVisibleTurnItemsWithState(questionAfterReply, replyProjection) |
| 1035 | .entries, |
| 1036 | ).toEqual(deriveTimelineEntriesFromVisibleTurnItems(questionAfterReply)); |
| 1037 | |
| 1038 | expect(next.entries[0]).toMatchObject({ |
| 1039 | kind: "work", |
| 1040 | entry: { questionAnswer: question.questionAnswer }, |
| 1041 | }); |
| 1042 | // A separately paged reply stays visible until its question history is available. |
| 1043 | expect( |
| 1044 | deriveTimelineEntriesFromVisibleTurnItems({ ...input, visibleTurnItems: [visible(reply)] })[0] |
| 1045 | ?.kind, |
| 1046 | ).toBe("message"); |
| 1047 | }); |
| 1048 | |
| 1049 | it("excludes checkpoint-only work from the timeline", () => { |
| 1050 | const message: OrchestrationV2TurnItem = { |
| 1051 | ...base, |
| 1052 | type: "assistant_message", |
| 1053 | messageId: MessageId.make("done"), |
| 1054 | text: "Done", |
| 1055 | streaming: false, |
| 1056 | }; |
| 1057 | const checkpoint: OrchestrationV2TurnItem = { |
| 1058 | ...base, |
| 1059 | id: TurnItemId.make("checkpoint"), |
| 1060 | type: "checkpoint", |
| 1061 | checkpointId: CheckpointId.make("checkpoint"), |
| 1062 | scopeId: CheckpointScopeId.make("scope"), |
| 1063 | files: [], |
| 1064 | }; |
| 1065 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1066 | visibleTurnItems: [visible(message), visible(checkpoint)], |
| 1067 | optimisticMessages: [], |
| 1068 | }); |
| 1069 | expect(entries.map((entry) => entry.kind)).toEqual(["message"]); |
| 1070 | const rows = deriveMessagesTimelineRows({ |
| 1071 | timelineEntries: entries, |
| 1072 | latestRun: { |
| 1073 | runId: base.runId, |
| 1074 | status: "completed", |
| 1075 | startedAt: DateTime.formatIso(timestamp), |
| 1076 | completedAt: DateTime.formatIso(timestamp), |
| 1077 | }, |
| 1078 | isWorking: false, |
| 1079 | activeTurnStartedAt: null, |
| 1080 | turnDiffSummaries: [], |
| 1081 | supportsConversationRollback: false, |
| 1082 | }); |
| 1083 | expect(rows.map((row) => row.kind)).toEqual(["message"]); |
| 1084 | }); |
| 1085 | |
| 1086 | it.each([ |
| 1087 | { outputIndicatesFailure: true }, |
| 1088 | { exitCode: 2 }, |
| 1089 | { output: "bash: foo: command not found" }, |
| 1090 | ])("keeps completed command failures visible without exposing output: %j", (result) => { |
| 1091 | const item = { |
| 1092 | ...base, |
| 1093 | type: "command_execution" as const, |
| 1094 | input: "foo", |
| 1095 | ...result, |
| 1096 | } satisfies OrchestrationV2TurnItem; |
| 1097 | const [entry] = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1098 | visibleTurnItems: [visible(item)], |
| 1099 | optimisticMessages: [], |
| 1100 | }); |
| 1101 | if (entry?.kind !== "work") throw new Error("Expected a command work entry"); |
| 1102 | |
| 1103 | expect(entry.entry.detail).toBeUndefined(); |
| 1104 | expect(entry.entry.command).toBe("foo"); |
| 1105 | expect(entry.entry.toolLifecycleStatus).toBe("completed"); |
| 1106 | expect(workEntryDisplayIndicatesToolFailure(entry.entry)).toBe(true); |
| 1107 | expect(workEntryIndicatesToolSuccess(entry.entry)).toBe(false); |
| 1108 | }); |
| 1109 | |
| 1110 | it("retains Claude Read image previews without tool output", () => { |
| 1111 | const item = { |
| 1112 | ...base, |
| 1113 | type: "dynamic_tool" as const, |
| 1114 | toolName: "Read", |
| 1115 | input: { file_path: "/workspace/reference.png" }, |
| 1116 | viewedImagePath: "/workspace/reference.png", |
| 1117 | } satisfies OrchestrationV2TurnItem; |
| 1118 | const [entry] = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1119 | visibleTurnItems: [visible(item)], |
| 1120 | optimisticMessages: [], |
| 1121 | }); |
| 1122 | expect(entry).toMatchObject({ |
| 1123 | kind: "work", |
| 1124 | entry: { viewedImagePath: "/workspace/reference.png" }, |
| 1125 | }); |
| 1126 | }); |
| 1127 | |
| 1128 | it("labels a read of a bare filename from its structured input", () => { |
| 1129 | const item = { |
| 1130 | ...base, |
| 1131 | type: "dynamic_tool" as const, |
| 1132 | toolName: "Read", |
| 1133 | input: { file_path: "README" }, |
| 1134 | output: "project notes", |
| 1135 | } satisfies OrchestrationV2TurnItem; |
| 1136 | const [entry] = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1137 | visibleTurnItems: [visible(item)], |
| 1138 | optimisticMessages: [], |
| 1139 | }); |
| 1140 | expect(entry).toMatchObject({ kind: "work", entry: { label: "Read README" } }); |
| 1141 | }); |
| 1142 | |
| 1143 | it("keeps browser identity and its source on a completed tool row", () => { |
| 1144 | const item = { |
| 1145 | ...base, |
| 1146 | type: "dynamic_tool" as const, |
| 1147 | toolName: "browser_snapshot", |
| 1148 | input: {}, |
| 1149 | output: {}, |
| 1150 | toolSurface: "browser" as const, |
| 1151 | toolIcon: { _tag: "website" as const, pageUrl: "https://example.com/checkout" }, |
| 1152 | toolSource: { key: "browser-use:browser", name: "Chrome", kind: "browser" as const }, |
| 1153 | } satisfies OrchestrationV2TurnItem; |
| 1154 | const [entry] = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1155 | visibleTurnItems: [visible(item)], |
| 1156 | optimisticMessages: [], |
| 1157 | }); |
| 1158 | expect(entry).toMatchObject({ |
| 1159 | kind: "work", |
| 1160 | entry: { |
| 1161 | toolSurface: "browser", |
| 1162 | toolIcon: item.toolIcon, |
| 1163 | toolSource: item.toolSource, |
| 1164 | toolLifecycleStatus: "completed", |
| 1165 | }, |
| 1166 | }); |
| 1167 | }); |
| 1168 | |
| 1169 | it("shows provider-returned images on an assistant message using the environment asset URL", () => { |
| 1170 | const attachment = { |
| 1171 | type: "image" as const, |
| 1172 | id: "assistant-image", |
| 1173 | name: "screenshot.png", |
| 1174 | mimeType: "image/png", |
| 1175 | sizeBytes: 512, |
| 1176 | }; |
| 1177 | const item = { |
| 1178 | ...base, |
| 1179 | type: "assistant_message" as const, |
| 1180 | messageId: MessageId.make("assistant-native-image"), |
| 1181 | text: "Here is the screenshot.", |
| 1182 | streaming: false, |
| 1183 | attachments: [attachment], |
| 1184 | } satisfies OrchestrationV2TurnItem; |
| 1185 | const [entry] = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1186 | visibleTurnItems: [visible(item)], |
| 1187 | optimisticMessages: [], |
| 1188 | attachmentUrlById: new Map([[attachment.id, "https://remote.example/api/assets/screenshot"]]), |
| 1189 | }); |
| 1190 | expect(entry).toMatchObject({ |
| 1191 | kind: "message", |
| 1192 | message: { |
| 1193 | role: "assistant", |
| 1194 | attachments: [ |
| 1195 | { ...attachment, previewUrl: "https://remote.example/api/assets/screenshot" }, |
| 1196 | ], |
| 1197 | }, |
| 1198 | }); |
| 1199 | }); |
| 1200 | |
| 1201 | it("keeps an idle provider task neutral without a completion mark", () => { |
| 1202 | const entry = { |
| 1203 | id: "idle-tool", |
| 1204 | createdAt: DateTime.formatIso(timestamp), |
| 1205 | label: "Waiting for the next task", |
| 1206 | tone: "tool" as const, |
| 1207 | toolLifecycleStatus: "idle" as const, |
| 1208 | }; |
| 1209 | expect(workEntryIndicatesToolSuccess(entry)).toBe(false); |
| 1210 | expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); |
| 1211 | }); |
| 1212 | }); |
| 1213 | |
| 1214 | describe("HTML renders in the timeline", () => { |
| 1215 | const runId = RunId.make("render-run"); |
| 1216 | const at = (second: number) => |
| 1217 | DateTime.makeUnsafe(`2026-09-04T12:00:${String(second).padStart(2, "0")}.000Z`); |
| 1218 | const base = (id: string, second: number) => ({ |
| 1219 | id: TurnItemId.make(id), |
| 1220 | threadId: ThreadId.make("render-thread"), |
| 1221 | runId, |
| 1222 | nodeId: null, |
| 1223 | providerThreadId: null, |
| 1224 | providerTurnId: null, |
| 1225 | nativeItemRef: null, |
| 1226 | parentItemId: null, |
| 1227 | ordinal: second, |
| 1228 | status: "completed" as const, |
| 1229 | title: null, |
| 1230 | startedAt: at(second), |
| 1231 | completedAt: at(second), |
| 1232 | updatedAt: at(second), |
| 1233 | }); |
| 1234 | const visible = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({ |
| 1235 | position: item.ordinal, |
| 1236 | visibility: "local", |
| 1237 | sourceThreadId: item.threadId, |
| 1238 | sourceItemId: item.id, |
| 1239 | item, |
| 1240 | }); |
| 1241 | const htmlRender = { attachmentId: "render-thread-chart.html", title: "Chart", height: 420 }; |
| 1242 | const renderCall = ( |
| 1243 | status: OrchestrationV2TurnItem["status"], |
| 1244 | output?: unknown, |
| 1245 | ): OrchestrationV2TurnItem => ({ |
| 1246 | ...base("render", 2), |
| 1247 | status, |
| 1248 | type: "dynamic_tool", |
| 1249 | toolName: "mcp__t3-code__html_render", |
| 1250 | input: { title: "Chart", height: 420 }, |
| 1251 | ...(output === undefined ? {} : { output }), |
| 1252 | }); |
| 1253 | const command = (id: string, second: number): OrchestrationV2TurnItem => ({ |
| 1254 | ...base(id, second), |
| 1255 | type: "command_execution", |
| 1256 | input: "vp test run", |
| 1257 | }); |
| 1258 | const turn = (render: OrchestrationV2TurnItem) => { |
| 1259 | const items: OrchestrationV2TurnItem[] = [ |
| 1260 | { |
| 1261 | ...base("prompt", 0), |
| 1262 | type: "user_message", |
| 1263 | messageId: MessageId.make("prompt"), |
| 1264 | inputIntent: "turn_start", |
| 1265 | text: "Chart it", |
| 1266 | createdBy: "user", |
| 1267 | creationSource: "server", |
| 1268 | attachments: [], |
| 1269 | }, |
| 1270 | command("before", 1), |
| 1271 | render, |
| 1272 | command("after", 3), |
| 1273 | { |
| 1274 | ...base("reply", 4), |
| 1275 | type: "assistant_message", |
| 1276 | messageId: MessageId.make("reply"), |
| 1277 | text: "Here it is.", |
| 1278 | streaming: false, |
| 1279 | }, |
| 1280 | ]; |
| 1281 | return deriveTimelineEntriesFromVisibleTurnItems({ |
| 1282 | visibleTurnItems: items.map(visible), |
| 1283 | optimisticMessages: [], |
| 1284 | }); |
| 1285 | }; |
| 1286 | const rowsFor = (entries: TimelineEntry[], expanded: boolean) => |
| 1287 | deriveMessagesTimelineRows({ |
| 1288 | timelineEntries: entries, |
| 1289 | latestRun: { |
| 1290 | runId, |
| 1291 | status: "completed", |
| 1292 | startedAt: DateTime.formatIso(at(0)), |
| 1293 | completedAt: DateTime.formatIso(at(5)), |
| 1294 | }, |
| 1295 | ...(expanded ? { expandedRunIds: new Set([runId]) } : {}), |
| 1296 | isWorking: false, |
| 1297 | activeTurnStartedAt: null, |
| 1298 | turnDiffSummaries: [], |
| 1299 | supportsConversationRollback: false, |
| 1300 | }).map((row) => row.kind); |
| 1301 | |
| 1302 | it("shows a completed render in place, above the reply, through the turn fold", () => { |
| 1303 | const entries = turn( |
| 1304 | renderCall("completed", { structuredContent: { htmlRender }, content: [] }), |
| 1305 | ); |
| 1306 | expect(entries.find((entry) => entry.kind === "html-render")).toMatchObject({ |
| 1307 | id: "render", |
| 1308 | runId, |
| 1309 | htmlRender, |
| 1310 | }); |
| 1311 | expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "html-render", "message"]); |
| 1312 | expect(rowsFor(entries, true)).toEqual([ |
| 1313 | "message", |
| 1314 | "turn-fold", |
| 1315 | "work", |
| 1316 | "html-render", |
| 1317 | "work", |
| 1318 | "message", |
| 1319 | ]); |
| 1320 | }); |
| 1321 | |
| 1322 | it("hosts a captured MCP app in place, from any provider's tool", () => { |
| 1323 | const mcpApp = { |
| 1324 | attachmentId: "render-thread-app-html", |
| 1325 | server: "weather", |
| 1326 | tool: "get_weather", |
| 1327 | resourceUri: "ui://weather/dashboard", |
| 1328 | }; |
| 1329 | const call = renderCall("completed", { t3McpApp: mcpApp, result: { content: [] } }); |
| 1330 | const entries = turn( |
| 1331 | call.type === "dynamic_tool" ? { ...call, toolName: "weather.get_weather" } : call, |
| 1332 | ); |
| 1333 | expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({ |
| 1334 | id: "render", |
| 1335 | itemId: "render", |
| 1336 | mcpApp, |
| 1337 | }); |
| 1338 | expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]); |
| 1339 | }); |
| 1340 | |
| 1341 | it("keeps a render visible when its superseded attempt folds", () => { |
| 1342 | const attempt: OrchestrationV2RunAttempt = { |
| 1343 | id: RunAttemptId.make("attempt-superseded"), |
| 1344 | runId, |
| 1345 | attemptOrdinal: 1, |
| 1346 | rootNodeId: NodeId.make("node-superseded"), |
| 1347 | providerInstanceId: ProviderInstanceId.make("codex-default"), |
| 1348 | providerThreadId: ProviderThreadId.make("provider-thread"), |
| 1349 | providerTurnId: null, |
| 1350 | reason: "initial", |
| 1351 | status: "superseded", |
| 1352 | startedAt: at(0), |
| 1353 | completedAt: at(4), |
| 1354 | }; |
| 1355 | const entries = turn( |
| 1356 | renderCall("completed", { structuredContent: { htmlRender }, content: [] }), |
| 1357 | ).map((entry) => |
| 1358 | entry.kind === "message" && entry.message.role === "user" ? entry : { ...entry, attempt }, |
| 1359 | ); |
| 1360 | expect(rowsFor(entries, true)).toEqual(["message", "turn-fold", "attempt-fold", "html-render"]); |
| 1361 | }); |
| 1362 | |
| 1363 | it.each([ |
| 1364 | ["running", "running", undefined], |
| 1365 | ["failed", "failed", { content: [{ type: "text", text: "Invalid HTML" }], isError: true }], |
| 1366 | ["errored", "completed", { structuredContent: { htmlRender }, isError: true }], |
| 1367 | ] as const)("keeps a %s call in the work log", (_label, status, output) => { |
| 1368 | const entries = turn(renderCall(status, output)); |
| 1369 | expect(entries.some((entry) => entry.kind === "html-render")).toBe(false); |
| 1370 | expect(entries.find((entry) => entry.id === "render")?.kind).toBe("work"); |
| 1371 | }); |
| 1372 | }); |
| 1373 | |
| 1374 | describe("work-log failure policy (#7999/#7893)", () => { |
| 1375 | const toolEntry = (overrides: Record<string, unknown>) => |
| 1376 | ({ |
| 1377 | id: "entry-1", |
| 1378 | createdAt: "2026-08-27T00:00:00.000Z", |
| 1379 | label: "Ran command", |
| 1380 | tone: "tool", |
| 1381 | ...overrides, |
| 1382 | }) as never; |
| 1383 | |
| 1384 | it("flags success-status rows whose output text reports a failure", () => { |
| 1385 | expect( |
| 1386 | workEntryIndicatesToolFailure( |
| 1387 | toolEntry({ toolLifecycleStatus: "completed", detail: "bash: foo: command not found" }), |
| 1388 | ), |
| 1389 | ).toBe(true); |
| 1390 | expect( |
| 1391 | workEntryIndicatesToolFailure( |
| 1392 | toolEntry({ toolLifecycleStatus: "completed", detail: "<exited with exit code 2>" }), |
| 1393 | ), |
| 1394 | ).toBe(true); |
| 1395 | }); |
| 1396 | |
| 1397 | it("keeps the rendered row calm when only the command mentions failure text", () => { |
| 1398 | const entry = toolEntry({ |
| 1399 | toolLifecycleStatus: "completed", |
| 1400 | command: "rg 'command not found' src/", |
| 1401 | }); |
| 1402 | expect(workEntryIndicatesToolFailure(entry)).toBe(true); |
| 1403 | expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); |
| 1404 | }); |
| 1405 | |
| 1406 | it("does not call a clean completed row failed", () => { |
| 1407 | const entry = toolEntry({ toolLifecycleStatus: "completed", detail: "3 files changed" }); |
| 1408 | expect(workEntryIndicatesToolFailure(entry)).toBe(false); |
| 1409 | expect(workEntryIndicatesToolSuccess(entry)).toBe(true); |
| 1410 | }); |
| 1411 | |
| 1412 | it("recovered failure text no longer counts as success", () => { |
| 1413 | const entry = toolEntry({ toolLifecycleStatus: "completed", detail: "ENOENT: no such file" }); |
| 1414 | expect(workEntryIndicatesToolSuccess(entry)).toBe(false); |
| 1415 | }); |
| 1416 | }); |
| 1417 | |
| 1418 | describe("incremental v2 timeline entries", () => { |
| 1419 | it("retains history and attachment previews through decoded streaming updates", () => { |
| 1420 | const fixture = makeStreamingTimelineFixture("Partial"); |
| 1421 | const attachment = { |
| 1422 | type: "image" as const, |
| 1423 | id: "stream-image", |
| 1424 | name: "image.png", |
| 1425 | mimeType: "image/png", |
| 1426 | sizeBytes: 42, |
| 1427 | }; |
| 1428 | const visibleTurnItems = fixture.visibleTurnItems.map((row) => |
| 1429 | row.item.type === "assistant_message" |
| 1430 | ? { ...row, item: { ...row.item, attachments: [attachment] } } |
| 1431 | : row, |
| 1432 | ); |
| 1433 | const input = { |
| 1434 | visibleTurnItems, |
| 1435 | optimisticMessages: [], |
| 1436 | attachmentUrlById: new Map([[attachment.id, "https://server.test/image"]]), |
| 1437 | }; |
| 1438 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1439 | Object.freeze(previous.entries); |
| 1440 | for (const entry of previous.entries) Object.freeze(entry); |
| 1441 | const last = visibleTurnItems.at(-1)!; |
| 1442 | if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); |
| 1443 | const nextItem = { |
| 1444 | ...last, |
| 1445 | item: { |
| 1446 | ...last.item, |
| 1447 | text: "Next token", |
| 1448 | attachments: [{ ...attachment }], |
| 1449 | startedAt: DateTime.makeUnsafe(fixture.time(7)), |
| 1450 | updatedAt: DateTime.makeUnsafe(fixture.time(8)), |
| 1451 | }, |
| 1452 | }; |
| 1453 | const nextInput = { |
| 1454 | ...input, |
| 1455 | visibleTurnItems: [...visibleTurnItems.slice(0, -1), nextItem], |
| 1456 | attachmentUrlById: new Map(input.attachmentUrlById), |
| 1457 | }; |
| 1458 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1459 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1460 | for (const [index, entry] of previous.entries.slice(0, -1).entries()) { |
| 1461 | expect(next.entries[index]).toBe(entry); |
| 1462 | } |
| 1463 | const beforeMessage = previous.entries.at(-1)!; |
| 1464 | const nextMessage = next.entries.at(-1)!; |
| 1465 | if (beforeMessage.kind !== "message" || nextMessage.kind !== "message") { |
| 1466 | throw new Error("Expected assistant entries"); |
| 1467 | } |
| 1468 | expect(nextMessage.message.attachments).toBe(beforeMessage.message.attachments); |
| 1469 | expect(nextMessage.projectedItem).toBe(nextItem); |
| 1470 | expect(nextMessage.message.text).toBe("Next token"); |
| 1471 | expect(beforeMessage.message.text).toBe("Partial"); |
| 1472 | |
| 1473 | const renewedInput = { |
| 1474 | ...nextInput, |
| 1475 | attachmentUrlById: new Map([[attachment.id, "https://renewed.test/image"]]), |
| 1476 | }; |
| 1477 | const renewed = deriveTimelineEntriesFromVisibleTurnItemsWithState(renewedInput, next); |
| 1478 | expect(renewed.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(renewedInput)); |
| 1479 | expect(renewed.entries.at(-1)).toMatchObject({ |
| 1480 | message: { attachments: [{ previewUrl: "https://renewed.test/image" }] }, |
| 1481 | }); |
| 1482 | expect(nextMessage.message.attachments?.[0]).toMatchObject({ |
| 1483 | previewUrl: "https://server.test/image", |
| 1484 | }); |
| 1485 | const restoredInput = { ...renewedInput, attachmentUrlById: new Map<string, string>() }; |
| 1486 | const restored = deriveTimelineEntriesFromVisibleTurnItemsWithState(restoredInput, renewed); |
| 1487 | expect(restored.entries.at(-1)).toMatchObject({ message: { attachments: [attachment] } }); |
| 1488 | const restoredMessage = restored.entries.at(-1)!; |
| 1489 | if (restoredMessage.kind !== "message") throw new Error("Expected assistant entry"); |
| 1490 | expect(restoredMessage.message.attachments?.[0]).not.toHaveProperty("previewUrl"); |
| 1491 | }); |
| 1492 | |
| 1493 | it("appends committed items in canonical order even when their timestamps go backwards", () => { |
| 1494 | const fixture = makeStreamingTimelineFixture("Partial"); |
| 1495 | const input = { visibleTurnItems: fixture.visibleTurnItems, optimisticMessages: [] }; |
| 1496 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1497 | const last = fixture.visibleTurnItems.at(-1)!; |
| 1498 | const appended = { |
| 1499 | ...last, |
| 1500 | position: last.position + 1, |
| 1501 | sourceItemId: TurnItemId.make("late-item"), |
| 1502 | item: { |
| 1503 | ...last.item, |
| 1504 | id: TurnItemId.make("late-item"), |
| 1505 | messageId: MessageId.make("late-message"), |
| 1506 | startedAt: DateTime.makeUnsafe(fixture.time(0)), |
| 1507 | }, |
| 1508 | }; |
| 1509 | const nextInput = { ...input, visibleTurnItems: [...input.visibleTurnItems, appended] }; |
| 1510 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1511 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1512 | expect(next.entries.at(-1)).toMatchObject({ id: "late-message" }); |
| 1513 | for (const [index, entry] of previous.entries.entries()) |
| 1514 | expect(next.entries[index]).toBe(entry); |
| 1515 | }); |
| 1516 | |
| 1517 | it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)( |
| 1518 | "rebuilds entries for a %s change instead of retaining stale v2 metadata", |
| 1519 | (change) => { |
| 1520 | const fixture = makeStreamingTimelineFixture("Partial"); |
| 1521 | const input = { visibleTurnItems: fixture.visibleTurnItems, optimisticMessages: [] }; |
| 1522 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1523 | const last = fixture.visibleTurnItems.at(-1)!; |
| 1524 | if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); |
| 1525 | const changed = |
| 1526 | change === "provenance" |
| 1527 | ? { ...last, sourceThreadId: ThreadId.make("inherited-thread") } |
| 1528 | : { |
| 1529 | ...last, |
| 1530 | item: |
| 1531 | change === "completion" |
| 1532 | ? { ...last.item, streaming: false, status: "completed" as const } |
| 1533 | : change === "ordering" |
| 1534 | ? { ...last.item, startedAt: DateTime.makeUnsafe(fixture.time(0)) } |
| 1535 | : change === "run" |
| 1536 | ? { ...last.item, runId: fixture.historyRunId } |
| 1537 | : { |
| 1538 | ...last.item, |
| 1539 | attachments: [ |
| 1540 | { |
| 1541 | type: "image", |
| 1542 | id: "new-image", |
| 1543 | name: "new.png", |
| 1544 | mimeType: "image/png", |
| 1545 | sizeBytes: 42, |
| 1546 | }, |
| 1547 | ], |
| 1548 | }, |
| 1549 | }; |
| 1550 | const nextInput = { |
| 1551 | ...input, |
| 1552 | visibleTurnItems: [...input.visibleTurnItems.slice(0, -1), changed], |
| 1553 | }; |
| 1554 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1555 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1556 | expect(next.entries.at(-1)).not.toBe(previous.entries.at(-1)); |
| 1557 | }, |
| 1558 | ); |
| 1559 | |
| 1560 | it("updates fallback timestamps when the provider has not supplied a message start time", () => { |
| 1561 | const fixture = makeStreamingTimelineFixture("Partial"); |
| 1562 | const visibleTurnItems = fixture.visibleTurnItems.map((row) => |
| 1563 | row.item.type === "assistant_message" && row.item.streaming |
| 1564 | ? { ...row, item: { ...row.item, startedAt: null } } |
| 1565 | : row, |
| 1566 | ); |
| 1567 | const input = { visibleTurnItems, optimisticMessages: [] }; |
| 1568 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1569 | const nextInput = { |
| 1570 | ...input, |
| 1571 | visibleTurnItems: visibleTurnItems.map((row) => |
| 1572 | row.item.type === "assistant_message" && row.item.streaming |
| 1573 | ? { |
| 1574 | ...row, |
| 1575 | item: { |
| 1576 | ...row.item, |
| 1577 | text: "Next token", |
| 1578 | updatedAt: DateTime.makeUnsafe(fixture.time(8)), |
| 1579 | }, |
| 1580 | } |
| 1581 | : row, |
| 1582 | ), |
| 1583 | }; |
| 1584 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1585 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1586 | expect(next.entries.at(-1)?.createdAt).toBe(fixture.time(8)); |
| 1587 | }); |
| 1588 | |
| 1589 | it("keeps unchanged message previews while tool output rebuilds the work log", () => { |
| 1590 | const fixture = makeStreamingTimelineFixture("Partial"); |
| 1591 | const input = { visibleTurnItems: fixture.visibleTurnItems, optimisticMessages: [] }; |
| 1592 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1593 | const nextInput = { |
| 1594 | ...input, |
| 1595 | visibleTurnItems: input.visibleTurnItems.map((row) => |
| 1596 | row.item.type === "command_execution" |
| 1597 | ? { ...row, item: { ...row.item, output: "Additional output" } } |
| 1598 | : row, |
| 1599 | ), |
| 1600 | }; |
| 1601 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1602 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1603 | for (const [index, entry] of previous.entries.entries()) { |
| 1604 | if (entry.kind === "message") expect(next.entries[index]).toBe(entry); |
| 1605 | } |
| 1606 | }); |
| 1607 | |
| 1608 | it("deduplicates optimistic sends when committed items arrive and retains anchored ordering", () => { |
| 1609 | const fixture = makeStreamingTimelineFixture(); |
| 1610 | const input = { |
| 1611 | visibleTurnItems: fixture.visibleTurnItems.slice(0, 3), |
| 1612 | optimisticMessages: [ |
| 1613 | { |
| 1614 | id: MessageId.make("live-user"), |
| 1615 | runId: null, |
| 1616 | role: "user" as const, |
| 1617 | text: "Continue", |
| 1618 | streaming: false, |
| 1619 | createdAt: fixture.time(5), |
| 1620 | updatedAt: fixture.time(5), |
| 1621 | }, |
| 1622 | ], |
| 1623 | anchoredMessages: [ |
| 1624 | { |
| 1625 | id: MessageId.make("feedback"), |
| 1626 | runId: null, |
| 1627 | role: "assistant" as const, |
| 1628 | text: "Feedback received", |
| 1629 | streaming: false, |
| 1630 | createdAt: fixture.time(4), |
| 1631 | updatedAt: fixture.time(4), |
| 1632 | }, |
| 1633 | ], |
| 1634 | }; |
| 1635 | const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); |
| 1636 | const nextInput = { ...input, visibleTurnItems: fixture.visibleTurnItems }; |
| 1637 | const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); |
| 1638 | expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); |
| 1639 | expect(next.entries.filter((entry) => entry.id === "live-user")).toHaveLength(1); |
| 1640 | expect(next.entries.map((entry) => entry.id)).toEqual([ |
| 1641 | "history-user", |
| 1642 | "history-work", |
| 1643 | "history-assistant", |
| 1644 | "feedback", |
| 1645 | "live-user", |
| 1646 | "live-work", |
| 1647 | "live-assistant", |
| 1648 | ]); |
| 1649 | }); |
| 1650 | }); |
| 1651 | |
| 1652 | describe("image asset requests", () => { |
| 1653 | const image = { |
| 1654 | type: "image" as const, |
| 1655 | id: "image", |
| 1656 | name: "image.png", |
| 1657 | mimeType: "image/png", |
| 1658 | sizeBytes: 42, |
| 1659 | }; |
| 1660 | const message = { |
| 1661 | id: MessageId.make("image-message"), |
| 1662 | role: "user" as const, |
| 1663 | text: "Inspect these images", |
| 1664 | runId: null, |
| 1665 | createdAt: "2026-09-04T00:00:00.000Z", |
| 1666 | updatedAt: "2026-09-04T00:00:00.000Z", |
| 1667 | streaming: false, |
| 1668 | attachments: [image], |
| 1669 | }; |
| 1670 | |
| 1671 | it("requests the whole row's gallery and crops without signing local preview IDs", () => { |
| 1672 | const attachments = Object.freeze([ |
| 1673 | image, |
| 1674 | { ...image, id: "second" }, |
| 1675 | { ...image, id: "crop", name: "preview-annotation-1.png" }, |
| 1676 | { ...image, id: "local", previewUrl: "blob:local" }, |
| 1677 | { ...image, id: "inline", previewUrl: "data:image/png;base64,AA==" }, |
| 1678 | { ...image, id: "provided", previewUrl: "https://preview.test/image" }, |
| 1679 | { ...image, type: "file" as const, id: "file", mimeType: "application/pdf" }, |
| 1680 | { ...image, type: "future", id: "unknown" }, |
| 1681 | image, |
| 1682 | ]); |
| 1683 | |
| 1684 | expect(selectMessageImageResources(attachments)).toEqual([ |
| 1685 | { _tag: "attachment", attachmentId: "image" }, |
| 1686 | { _tag: "attachment", attachmentId: "second" }, |
| 1687 | { _tag: "attachment", attachmentId: "crop" }, |
| 1688 | { _tag: "attachment", attachmentId: "provided" }, |
| 1689 | ]); |
| 1690 | }); |
| 1691 | |
| 1692 | it("requests offscreen handoffs without signing the rest of the loaded history", () => { |
| 1693 | const history = { |
| 1694 | ...message, |
| 1695 | id: MessageId.make("history"), |
| 1696 | attachments: [{ ...image, id: "history-image" }], |
| 1697 | }; |
| 1698 | const offscreen = { |
| 1699 | ...message, |
| 1700 | id: MessageId.make("offscreen"), |
| 1701 | attachments: [image, { ...image, id: "crop", name: "preview-annotation-1.png" }], |
| 1702 | }; |
| 1703 | const empty = { |
| 1704 | ...message, |
| 1705 | id: MessageId.make("empty"), |
| 1706 | attachments: [{ ...image, id: "empty" }], |
| 1707 | }; |
| 1708 | const assistant = { |
| 1709 | ...message, |
| 1710 | id: MessageId.make("assistant"), |
| 1711 | role: "assistant" as const, |
| 1712 | attachments: [{ ...image, id: "assistant-image" }], |
| 1713 | }; |
| 1714 | expect( |
| 1715 | selectHandoffImageResources([history, message, offscreen, empty, assistant], { |
| 1716 | [message.id]: ["blob:message"], |
| 1717 | [offscreen.id]: ["blob:offscreen", "blob:crop"], |
| 1718 | [empty.id]: [], |
| 1719 | [assistant.id]: ["blob:unused"], |
| 1720 | }), |
| 1721 | ).toEqual([ |
| 1722 | { _tag: "attachment", attachmentId: "image" }, |
| 1723 | { _tag: "attachment", attachmentId: "crop" }, |
| 1724 | ]); |
| 1725 | }); |
| 1726 | |
| 1727 | it("does not scan history when no handoff is pending", () => { |
| 1728 | let reads = 0; |
| 1729 | const messages = new Proxy([message], { |
| 1730 | get(target, property, receiver) { |
| 1731 | if (property === "0") reads += 1; |
| 1732 | return Reflect.get(target, property, receiver); |
| 1733 | }, |
| 1734 | }); |
| 1735 | const empty = selectHandoffImageResources(messages, {}); |
| 1736 | expect(reads).toBe(0); |
| 1737 | expect(empty).toHaveLength(0); |
| 1738 | expect(selectHandoffImageResources(undefined, { missing: ["blob:missing"] })).toBe(empty); |
| 1739 | expect(selectMessageImageResources(undefined)).toBe(empty); |
| 1740 | }); |
| 1741 | |
| 1742 | it("hands signed URLs to a mounted row only after the local preview is released", () => { |
| 1743 | const server = createMessageAttachmentPreviewProjector(); |
| 1744 | const handoff = createMessageAttachmentPreviewProjector(); |
| 1745 | const row = createMessageAttachmentPreviewProjector(); |
| 1746 | const pending = handoff( |
| 1747 | server(message, () => undefined), |
| 1748 | () => "blob:pending", |
| 1749 | ); |
| 1750 | expect(selectMessageImageResources(pending.attachments)).toEqual([]); |
| 1751 | expect(selectHandoffImageResources([message], { [message.id]: ["blob:pending"] })).toEqual([ |
| 1752 | { _tag: "attachment", attachmentId: image.id }, |
| 1753 | ]); |
| 1754 | |
| 1755 | const ready = server(message, () => "https://server.test/image"); |
| 1756 | expect(selectMessageImageResources(handoff(ready, () => "blob:pending").attachments)).toEqual( |
| 1757 | [], |
| 1758 | ); |
| 1759 | const released = server(message, () => undefined); |
| 1760 | expect(selectMessageImageResources(released.attachments)).toEqual([ |
| 1761 | { _tag: "attachment", attachmentId: image.id }, |
| 1762 | ]); |
| 1763 | const displayed = row(released, () => "https://server.test/image"); |
| 1764 | expect(displayed).toEqual(ready); |
| 1765 | expect(pending.attachments?.[0]).toMatchObject({ previewUrl: "blob:pending" }); |
| 1766 | expect(row(released, () => "https://server.test/renewed").attachments?.[0]).toMatchObject({ |
| 1767 | previewUrl: "https://server.test/renewed", |
| 1768 | }); |
| 1769 | expect(displayed.attachments?.[0]).toMatchObject({ previewUrl: "https://server.test/image" }); |
| 1770 | expect(row(released, () => undefined)).toBe(message); |
| 1771 | }); |
| 1772 | }); |
| 1773 | |
| 1774 | it("renders automatic completion as a work entry instead of a user bubble", () => { |
| 1775 | const now = DateTime.makeUnsafe("2026-09-09T00:00:00Z"); |
| 1776 | const item = { |
| 1777 | id: TurnItemId.make("wake-item"), |
| 1778 | threadId: ThreadId.make("parent"), |
| 1779 | runId: RunId.make("wake-run"), |
| 1780 | nodeId: null, |
| 1781 | providerThreadId: null, |
| 1782 | providerTurnId: null, |
| 1783 | nativeItemRef: null, |
| 1784 | parentItemId: null, |
| 1785 | ordinal: 0, |
| 1786 | status: "completed" as const, |
| 1787 | title: null, |
| 1788 | startedAt: now, |
| 1789 | completedAt: now, |
| 1790 | updatedAt: now, |
| 1791 | type: "notification" as const, |
| 1792 | source: { kind: "delegated_task" as const, taskIds: [NodeId.make("task-1")] }, |
| 1793 | outcome: "unknown" as const, |
| 1794 | summary: "Delegated task finished", |
| 1795 | }; |
| 1796 | const row = { |
| 1797 | item, |
| 1798 | position: 0, |
| 1799 | visibility: "local" as const, |
| 1800 | sourceThreadId: item.threadId, |
| 1801 | sourceItemId: item.id, |
| 1802 | }; |
| 1803 | const entries = deriveTimelineEntriesFromVisibleTurnItems({ |
| 1804 | optimisticMessages: [], |
| 1805 | visibleTurnItems: [row], |
| 1806 | }); |
| 1807 | expect(entries).toHaveLength(1); |
| 1808 | expect(entries[0]).toMatchObject({ |
| 1809 | kind: "work", |
| 1810 | entry: { label: "Delegated task finished", tone: "info", projectedItem: row }, |
| 1811 | }); |
| 1812 | expect( |
| 1813 | deriveTimelineEntriesFromVisibleTurnItems({ |
| 1814 | optimisticMessages: [], |
| 1815 | visibleTurnItems: [ |
| 1816 | { |
| 1817 | ...row, |
| 1818 | item: { |
| 1819 | ...item, |
| 1820 | type: "user_message", |
| 1821 | messageId: MessageId.make("wake-message"), |
| 1822 | createdBy: "agent" as const, |
| 1823 | creationSource: "server" as const, |
| 1824 | inputIntent: "turn_start" as const, |
| 1825 | attachments: [], |
| 1826 | text: "Delegated task node:task-1 reached a terminal state. Use task_status with taskId node:task-1 to read the result.", |
| 1827 | }, |
| 1828 | }, |
| 1829 | ], |
| 1830 | })[0]?.kind, |
| 1831 | ).toBe("message"); |
| 1832 | }); |