| 1 | import { |
| 2 | ContextHandoffId, |
| 3 | MessageId, |
| 4 | CheckpointId, |
| 5 | CheckpointScopeId, |
| 6 | RuntimeRequestId, |
| 7 | NodeId, |
| 8 | PlanId, |
| 9 | ProviderInstanceId, |
| 10 | ProviderDriverKind, |
| 11 | ProviderThreadId, |
| 12 | ProviderTurnId, |
| 13 | RunId, |
| 14 | RunAttemptId, |
| 15 | ScheduledTaskId, |
| 16 | ThreadId, |
| 17 | TurnItemId, |
| 18 | type OrchestrationV2RunAttempt, |
| 19 | type OrchestrationV2ProjectedTurnItem, |
| 20 | type OrchestrationV2TurnItem, |
| 21 | } from "@t3tools/contracts"; |
| 22 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 23 | import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation"; |
| 24 | import * as DateTime from "effect/DateTime"; |
| 25 | import { describe, expect, it } from "vite-plus/test"; |
| 26 | |
| 27 | import { |
| 28 | workEntryRowLabel, |
| 29 | isContextHandoffActivityGroup, |
| 30 | buildThreadFeed, |
| 31 | deriveThreadFeedPresentation, |
| 32 | threadFeedActivityIsVisible, |
| 33 | threadFeedRunIsUnsettled, |
| 34 | type ThreadFeedActivity, |
| 35 | type ThreadFeedEntry, |
| 36 | togglePendingUserInputOptionSelection, |
| 37 | setPendingUserInputCustomAnswer, |
| 38 | isPendingUserInputOptionSelected, |
| 39 | buildPendingUserInputAnswers, |
| 40 | } from "./threadActivity"; |
| 41 | |
| 42 | const threadId = ThreadId.make("thread-1"); |
| 43 | const sourceThreadId = ThreadId.make("thread-source"); |
| 44 | const runId = RunId.make("run-1"); |
| 45 | |
| 46 | it("keeps historical plan detail accessible from its paged turn item", () => { |
| 47 | const item = { |
| 48 | ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1), |
| 49 | type: "proposed_plan", |
| 50 | planId: "plan-historical", |
| 51 | markdown: "Full historical plan text", |
| 52 | streaming: false, |
| 53 | } as OrchestrationV2TurnItem; |
| 54 | |
| 55 | const entries = buildThreadFeed([projected(item, 0)]); |
| 56 | const activity = entries.flatMap((entry) => |
| 57 | entry.type === "activity-group" ? entry.activities : [], |
| 58 | )[0]; |
| 59 | expect(activity?.detail).toBe("Full historical plan text"); |
| 60 | expect(activity?.getFullDetail()).toContain("Full historical plan text"); |
| 61 | }); |
| 62 | |
| 63 | it("shows only the structured path in expanded mobile read details", () => { |
| 64 | const item: OrchestrationV2TurnItem = { |
| 65 | ...base("read-detail", "2026-06-20T00:00:03.000Z", 2), |
| 66 | type: "dynamic_tool", |
| 67 | toolName: "Read", |
| 68 | title: "Read src/env.ts", |
| 69 | input: { path: "src/env.ts" }, |
| 70 | output: "---\nname: env\n---\nsecret content", |
| 71 | }; |
| 72 | const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) => |
| 73 | entry.type === "activity-group" ? entry.activities : [], |
| 74 | )[0]; |
| 75 | |
| 76 | expect(activity?.getFullDetail()).toBe("src/env.ts"); |
| 77 | expect(activity?.canExpand).toBe(true); |
| 78 | expect(activity?.getCopyText()).not.toContain("secret content"); |
| 79 | expect(activity?.getFullDetail()).not.toContain("sourceThreadId"); |
| 80 | |
| 81 | const withoutPath = buildThreadFeed([ |
| 82 | projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0), |
| 83 | ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0]; |
| 84 | expect(withoutPath?.getFullDetail()).toBeNull(); |
| 85 | expect(withoutPath?.canExpand).toBe(false); |
| 86 | }); |
| 87 | |
| 88 | it("keeps system notices prominent for inline Markdown details", () => { |
| 89 | const item = { |
| 90 | ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), |
| 91 | type: "notification", |
| 92 | source: { kind: "system" }, |
| 93 | outcome: "updated", |
| 94 | summary: "T3 Code restarted and resumed this turn", |
| 95 | detail: "Continue where you left off.", |
| 96 | } as OrchestrationV2TurnItem; |
| 97 | const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => |
| 98 | entry.type === "activity-group" ? entry.activities : [], |
| 99 | ); |
| 100 | |
| 101 | expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); |
| 102 | expect(activity?.prominent).toBe(true); |
| 103 | expect(activity?.canExpand).toBe(false); |
| 104 | expect(activity?.getFullDetail()).toBe("Continue where you left off."); |
| 105 | }); |
| 106 | |
| 107 | it("keeps a runless CI failure and its Mergebot link visible outside completed work", () => { |
| 108 | const item: OrchestrationV2TurnItem = { |
| 109 | ...base("ci-failure", "2026-06-20T00:00:03.000Z", 2), |
| 110 | runId: null, |
| 111 | type: "notification", |
| 112 | source: { kind: "system" }, |
| 113 | outcome: "failed", |
| 114 | summary: "CI failed for PR #42", |
| 115 | detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)", |
| 116 | }; |
| 117 | const feed = buildThreadFeed([projected(command(), 0), projected(item, 1)]); |
| 118 | const presented = deriveThreadFeedPresentation( |
| 119 | feed, |
| 120 | { |
| 121 | runId, |
| 122 | status: "completed", |
| 123 | startedAt: "2026-06-20T00:00:01.000Z", |
| 124 | completedAt: "2026-06-20T00:00:02.000Z", |
| 125 | }, |
| 126 | new Set(), |
| 127 | ); |
| 128 | const activity = presented |
| 129 | .flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])) |
| 130 | .find((entry) => entry.projectedItem.item.id === item.id); |
| 131 | expect(activity?.prominent).toBe(true); |
| 132 | expect(activity?.status).toBe("failure"); |
| 133 | expect(activity?.getFullDetail()).toBe(item.detail); |
| 134 | }); |
| 135 | |
| 136 | it("labels file searches with the adapter title and its search target", () => { |
| 137 | const item: OrchestrationV2TurnItem = { |
| 138 | ...base("file-search", "2026-06-20T00:00:03.000Z", 2), |
| 139 | type: "file_search", |
| 140 | title: "Searched TODO in web", |
| 141 | pattern: "TODO", |
| 142 | }; |
| 143 | const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) => |
| 144 | entry.type === "activity-group" ? entry.activities : [], |
| 145 | )[0]; |
| 146 | |
| 147 | expect(activity?.summary).toBe("Searched TODO in web"); |
| 148 | expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("Searched TODO in web"); |
| 149 | }); |
| 150 | |
| 151 | it("keeps approval prompts rather than presenting them as tool work", () => { |
| 152 | const approval = ( |
| 153 | id: string, |
| 154 | requestKind: "file-read" | "command" | "file-change", |
| 155 | ordinal: number, |
| 156 | ) => |
| 157 | ({ |
| 158 | ...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal), |
| 159 | type: "approval_request", |
| 160 | requestId: RuntimeRequestId.make(`request-${id}`), |
| 161 | requestKind, |
| 162 | prompt: `Allow ${requestKind}?`, |
| 163 | }) satisfies OrchestrationV2TurnItem; |
| 164 | const feed = buildThreadFeed([ |
| 165 | projected(approval("approve-read", "file-read", 1), 0), |
| 166 | projected(approval("approve-command", "command", 2), 1), |
| 167 | projected(approval("approve-edit", "file-change", 3), 2), |
| 168 | ]); |
| 169 | const activities = feed.flatMap((entry) => |
| 170 | entry.type === "activity-group" ? entry.activities : [], |
| 171 | ); |
| 172 | |
| 173 | expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([ |
| 174 | "Allow file-read?", |
| 175 | "Allow command?", |
| 176 | "Allow file-change?", |
| 177 | ]); |
| 178 | expect(activities[0]?.canExpand).toBe(true); |
| 179 | expect( |
| 180 | summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary, |
| 181 | ).not.toMatch(/Ran|changed/); |
| 182 | }); |
| 183 | |
| 184 | function base(id: string, updatedAt: string, ordinal: number) { |
| 185 | const timestamp = DateTime.makeUnsafe(updatedAt); |
| 186 | return { |
| 187 | id: TurnItemId.make(id), |
| 188 | threadId, |
| 189 | runId, |
| 190 | nodeId: null, |
| 191 | providerThreadId: null, |
| 192 | providerTurnId: null, |
| 193 | nativeItemRef: null, |
| 194 | parentItemId: null, |
| 195 | ordinal, |
| 196 | status: "completed" as const, |
| 197 | title: null, |
| 198 | startedAt: timestamp, |
| 199 | completedAt: timestamp, |
| 200 | updatedAt: timestamp, |
| 201 | }; |
| 202 | } |
| 203 | |
| 204 | function projected( |
| 205 | item: OrchestrationV2TurnItem, |
| 206 | position: number, |
| 207 | visibility: OrchestrationV2ProjectedTurnItem["visibility"] = "local", |
| 208 | ): OrchestrationV2ProjectedTurnItem { |
| 209 | return { |
| 210 | position, |
| 211 | visibility, |
| 212 | sourceThreadId: visibility === "local" ? threadId : sourceThreadId, |
| 213 | sourceItemId: item.id, |
| 214 | item, |
| 215 | }; |
| 216 | } |
| 217 | |
| 218 | function userMessage(updatedAt = "2026-06-20T00:00:01.000Z") { |
| 219 | return { |
| 220 | ...base("item-user", updatedAt, 0), |
| 221 | type: "user_message" as const, |
| 222 | messageId: MessageId.make("message-user"), |
| 223 | createdBy: "user" as const, |
| 224 | creationSource: "mobile" as const, |
| 225 | inputIntent: "turn_start" as const, |
| 226 | text: "Run checks", |
| 227 | attachments: [], |
| 228 | }; |
| 229 | } |
| 230 | |
| 231 | function command(updatedAt = "2026-06-20T00:00:02.000Z") { |
| 232 | return { |
| 233 | ...base("item-command", updatedAt, 1), |
| 234 | type: "command_execution" as const, |
| 235 | input: "vp check", |
| 236 | output: "ok", |
| 237 | exitCode: 0, |
| 238 | }; |
| 239 | } |
| 240 | |
| 241 | function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") { |
| 242 | return { |
| 243 | ...base("item-assistant", updatedAt, 2), |
| 244 | type: "assistant_message" as const, |
| 245 | messageId: MessageId.make("message-assistant"), |
| 246 | text: "Done", |
| 247 | streaming: false, |
| 248 | }; |
| 249 | } |
| 250 | |
| 251 | describe("buildThreadFeed", () => { |
| 252 | it("keeps async answers in question history instead of user bubbles", () => { |
| 253 | const requestId = RuntimeRequestId.make("question"); |
| 254 | const question: OrchestrationV2TurnItem = { |
| 255 | ...base("question", "2026-06-20T00:00:01.000Z", 0), |
| 256 | type: "user_input_request", |
| 257 | requestId, |
| 258 | questions: [], |
| 259 | questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} }, |
| 260 | }; |
| 261 | const reply: OrchestrationV2TurnItem = { |
| 262 | ...userMessage(), |
| 263 | id: TurnItemId.make("answer"), |
| 264 | messageId: MessageId.make(`async-answer:${requestId}`), |
| 265 | }; |
| 266 | const feed = buildThreadFeed([projected(question, 0), projected(reply, 1)]); |
| 267 | expect(feed).toHaveLength(1); |
| 268 | expect(feed[0]).toMatchObject({ |
| 269 | type: "activity-group", |
| 270 | activities: [{ workEntry: { questionAnswer: question.questionAnswer } }], |
| 271 | }); |
| 272 | expect(buildThreadFeed([projected(reply, 0)])[0]?.type).toBe("message"); |
| 273 | }); |
| 274 | |
| 275 | it("does not create a work group for a message and checkpoint", () => { |
| 276 | const checkpoint: OrchestrationV2TurnItem = { |
| 277 | ...base("checkpoint", "2026-06-20T00:00:04.000Z", 3), |
| 278 | type: "checkpoint", |
| 279 | checkpointId: CheckpointId.make("checkpoint"), |
| 280 | scopeId: CheckpointScopeId.make("scope"), |
| 281 | files: [], |
| 282 | }; |
| 283 | const feed = buildThreadFeed([projected(assistantMessage(), 0), projected(checkpoint, 1)]); |
| 284 | expect(feed.map((entry) => entry.type)).toEqual(["message"]); |
| 285 | }); |
| 286 | |
| 287 | it("omits cached tool output and patch bodies from expanded and copied activity", () => { |
| 288 | const rawOutput = "RAW_TOOL_OUTPUT"; |
| 289 | const items: OrchestrationV2TurnItem[] = [ |
| 290 | { ...command(), output: rawOutput }, |
| 291 | { |
| 292 | ...base("dynamic-output", "2026-06-20T00:00:03.000Z", 2), |
| 293 | type: "dynamic_tool", |
| 294 | toolName: "example", |
| 295 | input: { query: "keep input" }, |
| 296 | output: { text: rawOutput }, |
| 297 | }, |
| 298 | { |
| 299 | ...base("file-output", "2026-06-20T00:00:04.000Z", 3), |
| 300 | type: "file_change", |
| 301 | fileName: "src/example.ts", |
| 302 | diffStr: rawOutput, |
| 303 | oldStr: rawOutput, |
| 304 | newStr: rawOutput, |
| 305 | }, |
| 306 | ]; |
| 307 | const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap( |
| 308 | (entry) => (entry.type === "activity-group" ? entry.activities : []), |
| 309 | ); |
| 310 | expect(activities).toHaveLength(3); |
| 311 | for (const activity of activities) { |
| 312 | expect(activity.workEntry.detail).toBeUndefined(); |
| 313 | expect(activity.getFullDetail()).not.toContain(rawOutput); |
| 314 | expect(activity.getCopyText()).not.toContain(rawOutput); |
| 315 | } |
| 316 | expect(activities[0]?.detail).toBe("vp check"); |
| 317 | expect(activities[1]?.getFullDetail()).toContain("keep input"); |
| 318 | expect(activities[2]?.detail).toBe("src/example.ts"); |
| 319 | expect(items[0]).toMatchObject({ output: rawOutput }); |
| 320 | }); |
| 321 | |
| 322 | it("expands tool rows only when they have detail or withheld output", () => { |
| 323 | const items: OrchestrationV2TurnItem[] = [ |
| 324 | { ...command(), input: "", outputOmitted: true }, |
| 325 | { |
| 326 | ...base("dynamic-empty", "2026-06-20T00:00:03.000Z", 2), |
| 327 | type: "dynamic_tool", |
| 328 | toolName: "example", |
| 329 | input: {}, |
| 330 | }, |
| 331 | { |
| 332 | ...base("read-omitted", "2026-06-20T00:00:04.000Z", 3), |
| 333 | type: "dynamic_tool", |
| 334 | toolName: "Read", |
| 335 | input: { path: "src/env.ts" }, |
| 336 | outputOmitted: true, |
| 337 | }, |
| 338 | ]; |
| 339 | const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap( |
| 340 | (entry) => (entry.type === "activity-group" ? entry.activities : []), |
| 341 | ); |
| 342 | expect( |
| 343 | activities.map(({ canExpand, fetchesDetail }) => ({ canExpand, fetchesDetail })), |
| 344 | ).toEqual([ |
| 345 | { canExpand: true, fetchesDetail: true }, |
| 346 | { canExpand: false, fetchesDetail: false }, |
| 347 | { canExpand: true, fetchesDetail: true }, |
| 348 | ]); |
| 349 | }); |
| 350 | |
| 351 | it("recognizes automation attribution after projecting a user message", () => { |
| 352 | const feed = buildThreadFeed([ |
| 353 | projected( |
| 354 | { |
| 355 | ...userMessage(), |
| 356 | createdBy: "agent", |
| 357 | creationSource: "server", |
| 358 | scheduledTaskId: ScheduledTaskId.make("daily-audit"), |
| 359 | }, |
| 360 | 0, |
| 361 | ), |
| 362 | ]); |
| 363 | const messageEntry = feed.find((entry) => entry.type === "message"); |
| 364 | |
| 365 | expect(messageEntry).toBeDefined(); |
| 366 | expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ |
| 367 | text: "Run checks", |
| 368 | attribution: "automation", |
| 369 | }); |
| 370 | }); |
| 371 | |
| 372 | it("keeps the sender of an agent message distinct from its timeline source", () => { |
| 373 | const feed = buildThreadFeed([ |
| 374 | projected( |
| 375 | { |
| 376 | ...userMessage(), |
| 377 | createdBy: "agent", |
| 378 | creationSource: "mcp", |
| 379 | senderThreadId: sourceThreadId, |
| 380 | }, |
| 381 | 0, |
| 382 | ), |
| 383 | ]); |
| 384 | const messageEntry = feed.find((entry) => entry.type === "message"); |
| 385 | expect(messageEntry?.message.senderThreadId).toBe(sourceThreadId); |
| 386 | expect(messageEntry?.message.sourceThreadId).toBe(threadId); |
| 387 | }); |
| 388 | |
| 389 | it("anchors feedback before later committed turns", () => { |
| 390 | const laterUser = { |
| 391 | ...userMessage("2026-08-29T00:00:05.000Z"), |
| 392 | id: TurnItemId.make("item-later-user"), |
| 393 | messageId: MessageId.make("message-later-user"), |
| 394 | ordinal: 2, |
| 395 | text: "Later user turn", |
| 396 | }; |
| 397 | const laterAssistant = { |
| 398 | ...assistantMessage("2026-08-29T00:00:04.000Z"), |
| 399 | id: TurnItemId.make("item-later-assistant"), |
| 400 | messageId: MessageId.make("message-later-assistant"), |
| 401 | ordinal: 3, |
| 402 | text: "Later assistant turn", |
| 403 | }; |
| 404 | const localMessage = (id: string, role: "user" | "assistant") => ({ |
| 405 | id: MessageId.make(id), |
| 406 | role, |
| 407 | text: id, |
| 408 | turnId: null, |
| 409 | streaming: false, |
| 410 | createdAt: "2026-08-29T00:00:03.000Z", |
| 411 | updatedAt: "2026-08-29T00:00:03.000Z", |
| 412 | }); |
| 413 | const feed = buildThreadFeed( |
| 414 | [ |
| 415 | projected(userMessage("2026-08-29T00:00:01.000Z"), 0), |
| 416 | projected(laterUser, 1), |
| 417 | projected(laterAssistant, 2), |
| 418 | ], |
| 419 | { |
| 420 | anchoredMessages: [ |
| 421 | localMessage("feedback-user", "user"), |
| 422 | localMessage("feedback-assistant", "assistant"), |
| 423 | localMessage("message-later-user", "user"), |
| 424 | ], |
| 425 | }, |
| 426 | ); |
| 427 | const messages = feed.filter((entry) => entry.type === "message"); |
| 428 | |
| 429 | expect(messages.map((entry) => entry.id)).toEqual([ |
| 430 | "message-user", |
| 431 | "feedback-user", |
| 432 | "feedback-assistant", |
| 433 | "message-later-user", |
| 434 | "message-later-assistant", |
| 435 | ]); |
| 436 | expect( |
| 437 | messages |
| 438 | .filter((entry) => entry.id.startsWith("feedback-")) |
| 439 | .every((entry) => entry.message.projectedItem === undefined), |
| 440 | ).toBe(true); |
| 441 | }); |
| 442 | |
| 443 | it("keeps prominent activity visible while it is running", () => { |
| 444 | expect( |
| 445 | threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }), |
| 446 | ).toBe(true); |
| 447 | expect( |
| 448 | threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }), |
| 449 | ).toBe(false); |
| 450 | }); |
| 451 | |
| 452 | it("keeps provider notices visible outside completed work folds without failure styling", () => { |
| 453 | const message = "Safeguards flagged this message. Switched to Opus 4.8."; |
| 454 | const item = { |
| 455 | ...base("item-system-notice", "2026-06-20T00:00:02.000Z", 1), |
| 456 | type: "system_notice" as const, |
| 457 | message, |
| 458 | }; |
| 459 | const feed = buildThreadFeed([projected(item, 0)]); |
| 460 | const presented = deriveThreadFeedPresentation(feed, null, new Set()); |
| 461 | const activities = presented.flatMap((entry) => |
| 462 | entry.type === "activity-group" ? entry.activities : [], |
| 463 | ); |
| 464 | expect(activities).toHaveLength(1); |
| 465 | expect(activities[0]).toMatchObject({ |
| 466 | summary: message, |
| 467 | detail: message, |
| 468 | prominent: true, |
| 469 | toolLike: false, |
| 470 | status: null, |
| 471 | icon: "warning", |
| 472 | workEntry: { tone: "info", itemType: "system_notice" }, |
| 473 | }); |
| 474 | expect(presented.some((entry) => entry.type === "run-fold")).toBe(false); |
| 475 | }); |
| 476 | |
| 477 | it("presents a usage-limit stop as a warning while preserving its explanation", () => { |
| 478 | const message = "Plan usage limit reached. Try again after reset."; |
| 479 | const entries = buildThreadFeed([ |
| 480 | projected( |
| 481 | { |
| 482 | ...base("item-limit", "2026-06-20T00:00:02.000Z", 1), |
| 483 | type: "error", |
| 484 | status: "failed", |
| 485 | title: "Usage limit reached", |
| 486 | failure: { class: "usage_limit", message, code: "usageLimitExceeded", retryable: null }, |
| 487 | }, |
| 488 | 0, |
| 489 | ), |
| 490 | ]); |
| 491 | const activity = entries.flatMap((entry) => |
| 492 | entry.type === "activity-group" ? entry.activities : [], |
| 493 | )[0]; |
| 494 | expect(activity).toMatchObject({ |
| 495 | summary: "Usage limit reached", |
| 496 | status: "neutral", |
| 497 | icon: "warning", |
| 498 | }); |
| 499 | expect(activity?.getFullDetail()).toContain(message); |
| 500 | }); |
| 501 | |
| 502 | it.each(["transport_error", "usage_limit"] as const)( |
| 503 | "presents %s retries and clears warning markers on recovery", |
| 504 | (failureClass) => { |
| 505 | const retryBase = { |
| 506 | ...base("item-provider-retry", "2026-06-20T00:00:02.000Z", 1), |
| 507 | type: "error" as const, |
| 508 | failure: { |
| 509 | class: failureClass, |
| 510 | message: "The response stream disconnected.", |
| 511 | code: "responseStreamDisconnected", |
| 512 | retryable: true, |
| 513 | }, |
| 514 | retry: { |
| 515 | attempt: 2, |
| 516 | maxAttempts: 5, |
| 517 | retryDelayMs: null, |
| 518 | }, |
| 519 | }; |
| 520 | const runningFeed = buildThreadFeed([ |
| 521 | projected( |
| 522 | { |
| 523 | ...retryBase, |
| 524 | status: "running", |
| 525 | title: "Provider retry", |
| 526 | completedAt: null, |
| 527 | }, |
| 528 | 0, |
| 529 | ), |
| 530 | ]); |
| 531 | const recoveredFeed = buildThreadFeed([ |
| 532 | projected( |
| 533 | { |
| 534 | ...retryBase, |
| 535 | status: "completed", |
| 536 | title: "Provider recovered", |
| 537 | }, |
| 538 | 0, |
| 539 | ), |
| 540 | ]); |
| 541 | if (failureClass === "usage_limit") { |
| 542 | const recoveredActivity = recoveredFeed.flatMap((entry) => |
| 543 | entry.type === "activity-group" ? entry.activities : [], |
| 544 | )[0]; |
| 545 | expect(recoveredActivity).toMatchObject({ status: "success", icon: "check" }); |
| 546 | } |
| 547 | const failedFeed = buildThreadFeed([ |
| 548 | projected( |
| 549 | { |
| 550 | ...retryBase, |
| 551 | status: "failed", |
| 552 | title: "Provider retry failed", |
| 553 | }, |
| 554 | 0, |
| 555 | ), |
| 556 | projected(command("2026-06-20T00:00:03.000Z"), 1), |
| 557 | ]); |
| 558 | const runningActivity = runningFeed.find((entry) => entry.type === "activity-group") |
| 559 | ?.activities[0]; |
| 560 | const recoveredActivity = recoveredFeed.find((entry) => entry.type === "activity-group") |
| 561 | ?.activities[0]; |
| 562 | if (runningActivity === undefined || recoveredActivity === undefined) { |
| 563 | throw new Error("Expected provider retry work-log activities."); |
| 564 | } |
| 565 | |
| 566 | expect(runningActivity).toMatchObject({ |
| 567 | summary: "Provider retry", |
| 568 | status: "neutral", |
| 569 | toolLike: false, |
| 570 | }); |
| 571 | expect(threadFeedActivityIsVisible(runningActivity)).toBe(true); |
| 572 | expect(recoveredActivity).toMatchObject({ |
| 573 | summary: "Provider recovered", |
| 574 | status: "success", |
| 575 | toolLike: false, |
| 576 | }); |
| 577 | const failedPresentation = deriveThreadFeedPresentation( |
| 578 | failedFeed, |
| 579 | { runId, status: "running", startedAt: null, completedAt: null }, |
| 580 | new Set(), |
| 581 | ); |
| 582 | expect(failedPresentation.map((entry) => entry.type)).toEqual([ |
| 583 | "activity-group", |
| 584 | "activity-group", |
| 585 | ]); |
| 586 | expect( |
| 587 | failedPresentation[0]?.type === "activity-group" |
| 588 | ? failedPresentation[0].activities[0]?.summary |
| 589 | : null, |
| 590 | ).toBe("Provider retry failed"); |
| 591 | }, |
| 592 | ); |
| 593 | |
| 594 | it.each(["pending", "running", "completed"] as const)( |
| 595 | "omits %s task progress without hiding adjacent conversation items", |
| 596 | (stepStatus) => { |
| 597 | const todoItem = { |
| 598 | ...base("item-tasks", "2026-06-20T00:00:02.500Z", 2), |
| 599 | type: "todo_list" as const, |
| 600 | planId: PlanId.make("plan-tasks"), |
| 601 | steps: [{ id: "step-1", text: "Verify the change", status: stepStatus }], |
| 602 | } satisfies OrchestrationV2TurnItem; |
| 603 | const user = projected(userMessage(), 0); |
| 604 | const tool = projected(command(), 1); |
| 605 | const assistant = projected(assistantMessage(), 3); |
| 606 | |
| 607 | expect(buildThreadFeed([user, tool, projected(todoItem, 2), assistant])).toEqual( |
| 608 | buildThreadFeed([user, tool, assistant]), |
| 609 | ); |
| 610 | }, |
| 611 | ); |
| 612 | |
| 613 | it("hides synthetic workspace preparation activity", () => { |
| 614 | const workspacePreparation = projected( |
| 615 | { |
| 616 | ...command(), |
| 617 | title: "Workspace ready", |
| 618 | input: "Preparing workspace", |
| 619 | output: "Workspace preparation completed.", |
| 620 | }, |
| 621 | 0, |
| 622 | ); |
| 623 | |
| 624 | expect(buildThreadFeed([workspacePreparation])).toEqual([]); |
| 625 | }); |
| 626 | |
| 627 | it("does not treat a queued-only run as live feed activity", () => { |
| 628 | expect( |
| 629 | threadFeedRunIsUnsettled({ |
| 630 | runId, |
| 631 | status: "queued", |
| 632 | startedAt: null, |
| 633 | completedAt: null, |
| 634 | }), |
| 635 | ).toBe(false); |
| 636 | expect( |
| 637 | threadFeedRunIsUnsettled({ |
| 638 | runId, |
| 639 | status: "running", |
| 640 | startedAt: "2026-06-20T00:00:01.000Z", |
| 641 | completedAt: null, |
| 642 | }), |
| 643 | ).toBe(true); |
| 644 | expect( |
| 645 | threadFeedRunIsUnsettled({ |
| 646 | runId, |
| 647 | status: "completed", |
| 648 | startedAt: "2026-06-20T00:00:01.000Z", |
| 649 | completedAt: null, |
| 650 | }), |
| 651 | ).toBe(true); |
| 652 | expect( |
| 653 | threadFeedRunIsUnsettled({ |
| 654 | runId, |
| 655 | status: "waiting", |
| 656 | startedAt: "2026-06-20T00:00:01.000Z", |
| 657 | completedAt: null, |
| 658 | }), |
| 659 | ).toBe(true); |
| 660 | }); |
| 661 | |
| 662 | it("adds queued input only after dispatch creates its turn item", () => { |
| 663 | const dispatchedRunId = RunId.make("run-dispatched-queued"); |
| 664 | const dispatchedMessageId = MessageId.make("message-dispatched-queued"); |
| 665 | expect(buildThreadFeed([])).toEqual([]); |
| 666 | |
| 667 | const promotedEntries = buildThreadFeed([ |
| 668 | projected( |
| 669 | { |
| 670 | ...userMessage(), |
| 671 | id: TurnItemId.make("item-dispatched-queued"), |
| 672 | runId: dispatchedRunId, |
| 673 | messageId: dispatchedMessageId, |
| 674 | inputIntent: "turn_start", |
| 675 | }, |
| 676 | 0, |
| 677 | ), |
| 678 | ]); |
| 679 | expect(promotedEntries.map((entry) => entry.id)).toEqual([dispatchedMessageId]); |
| 680 | expect( |
| 681 | promotedEntries[0]?.type === "message" ? promotedEntries[0].message.inputIntent : undefined, |
| 682 | ).toBe("turn_start"); |
| 683 | }); |
| 684 | |
| 685 | it("hides the interruption request and keeps the terminal result", () => { |
| 686 | const request = projected( |
| 687 | { |
| 688 | ...base("item-interrupt-request", "2026-06-20T00:00:02.000Z", 1), |
| 689 | type: "run_interrupt_request", |
| 690 | message: "Interrupt requested", |
| 691 | }, |
| 692 | 0, |
| 693 | ); |
| 694 | const result = projected( |
| 695 | { |
| 696 | ...base("item-interrupt-result", "2026-06-20T00:00:03.000Z", 2), |
| 697 | type: "run_interrupt_result", |
| 698 | message: "Run interrupted before provider start", |
| 699 | }, |
| 700 | 1, |
| 701 | ); |
| 702 | |
| 703 | const activities = buildThreadFeed([request, result]).flatMap((entry) => |
| 704 | entry.type === "activity-group" ? entry.activities : [], |
| 705 | ); |
| 706 | |
| 707 | expect(activities).toHaveLength(1); |
| 708 | expect(activities[0]?.summary).toBe("Run interrupted"); |
| 709 | expect(activities[0]?.detail).toBe("Run interrupted before provider start"); |
| 710 | expect( |
| 711 | deriveThreadFeedPresentation( |
| 712 | buildThreadFeed([request, result]), |
| 713 | { |
| 714 | runId, |
| 715 | status: "interrupted", |
| 716 | startedAt: "2026-06-20T00:00:01.000Z", |
| 717 | completedAt: "2026-06-20T00:00:03.000Z", |
| 718 | }, |
| 719 | new Set(), |
| 720 | ).some((entry) => entry.type === "run-fold"), |
| 721 | ).toBe(false); |
| 722 | }); |
| 723 | |
| 724 | it("preserves authoritative V2 order instead of sorting reconstructed collections", () => { |
| 725 | const rows = [ |
| 726 | projected(userMessage("2026-06-20T00:00:03.000Z"), 0), |
| 727 | projected(command("2026-06-20T00:00:01.000Z"), 1), |
| 728 | projected(assistantMessage("2026-06-20T00:00:02.000Z"), 2), |
| 729 | ]; |
| 730 | |
| 731 | const feed = buildThreadFeed(rows); |
| 732 | expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]); |
| 733 | expect(feed.map((entry) => entry.id)).toEqual([ |
| 734 | "message-user", |
| 735 | "local:thread-1:item-command", |
| 736 | "message-assistant", |
| 737 | ]); |
| 738 | const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0]; |
| 739 | expect(activity?.projectedItem).toBe(rows[1]); |
| 740 | expect(activity?.getFullDetail()).toContain('"input": "vp check"'); |
| 741 | }); |
| 742 | |
| 743 | it("keeps adjacent work from different V2 attempts in separate groups", () => { |
| 744 | const firstRootNodeId = NodeId.make("node-attempt-1"); |
| 745 | const secondRootNodeId = NodeId.make("node-attempt-2"); |
| 746 | const firstCommand = { ...command(), nodeId: firstRootNodeId }; |
| 747 | const secondCommand = { |
| 748 | ...command("2026-06-20T00:00:03.000Z"), |
| 749 | id: TurnItemId.make("item-command-retry"), |
| 750 | ordinal: 2, |
| 751 | nodeId: secondRootNodeId, |
| 752 | }; |
| 753 | const attempts = [ |
| 754 | { |
| 755 | id: RunAttemptId.make("attempt-1"), |
| 756 | runId, |
| 757 | attemptOrdinal: 1, |
| 758 | rootNodeId: firstRootNodeId, |
| 759 | providerInstanceId: ProviderInstanceId.make("provider-instance-1"), |
| 760 | providerThreadId: ProviderThreadId.make("provider-thread-1"), |
| 761 | providerTurnId: null, |
| 762 | reason: "initial", |
| 763 | status: "completed", |
| 764 | startedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"), |
| 765 | completedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"), |
| 766 | }, |
| 767 | { |
| 768 | id: RunAttemptId.make("attempt-2"), |
| 769 | runId, |
| 770 | attemptOrdinal: 2, |
| 771 | rootNodeId: secondRootNodeId, |
| 772 | providerInstanceId: ProviderInstanceId.make("provider-instance-1"), |
| 773 | providerThreadId: ProviderThreadId.make("provider-thread-1"), |
| 774 | providerTurnId: null, |
| 775 | reason: "retry", |
| 776 | status: "completed", |
| 777 | startedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"), |
| 778 | completedAt: DateTime.makeUnsafe("2026-06-20T00:00:03.000Z"), |
| 779 | }, |
| 780 | ] satisfies ReadonlyArray<OrchestrationV2RunAttempt>; |
| 781 | |
| 782 | const feed = buildThreadFeed([projected(firstCommand, 0), projected(secondCommand, 1)], { |
| 783 | attempts, |
| 784 | }); |
| 785 | |
| 786 | expect(feed).toHaveLength(2); |
| 787 | expect( |
| 788 | feed.map((entry) => |
| 789 | entry.type === "activity-group" ? entry.activities[0]?.attemptId : null, |
| 790 | ), |
| 791 | ).toEqual(["attempt-1", "attempt-2"]); |
| 792 | }); |
| 793 | |
| 794 | it("retains inherited and synthetic rows with their original projected identity", () => { |
| 795 | const inherited = projected(command(), 0, "inherited"); |
| 796 | const { providerThreadId: _providerThreadId, ...forkBase } = base( |
| 797 | "item-fork", |
| 798 | "2026-06-20T00:00:03.000Z", |
| 799 | 2, |
| 800 | ); |
| 801 | const synthetic = projected( |
| 802 | { |
| 803 | ...forkBase, |
| 804 | type: "fork", |
| 805 | source: { type: "run", threadId: sourceThreadId, runId }, |
| 806 | targetThreadId: threadId, |
| 807 | }, |
| 808 | 1, |
| 809 | "synthetic", |
| 810 | ); |
| 811 | |
| 812 | const feed = buildThreadFeed([inherited, synthetic]); |
| 813 | const activities = feed.flatMap((entry) => |
| 814 | entry.type === "activity-group" ? entry.activities : [], |
| 815 | ); |
| 816 | expect(activities.map((activity) => activity.projectedItem)).toEqual([inherited, synthetic]); |
| 817 | expect(activities.map((activity) => activity.projectedItem.visibility)).toEqual([ |
| 818 | "inherited", |
| 819 | "synthetic", |
| 820 | ]); |
| 821 | expect(activities.at(-1)?.prominent).toBe(true); |
| 822 | }); |
| 823 | |
| 824 | it("keeps orchestration relationship cards visible when a completed run is folded", () => { |
| 825 | const { providerThreadId: _providerThreadId, ...forkBase } = base( |
| 826 | "item-fork", |
| 827 | "2026-06-20T00:00:02.500Z", |
| 828 | 2, |
| 829 | ); |
| 830 | const feed = buildThreadFeed([ |
| 831 | projected(userMessage(), 0), |
| 832 | projected(command(), 1), |
| 833 | projected( |
| 834 | { |
| 835 | ...forkBase, |
| 836 | type: "fork", |
| 837 | source: { type: "run", threadId, runId }, |
| 838 | targetThreadId: sourceThreadId, |
| 839 | }, |
| 840 | 2, |
| 841 | ), |
| 842 | projected(assistantMessage(), 3), |
| 843 | ]); |
| 844 | |
| 845 | const collapsed = deriveThreadFeedPresentation( |
| 846 | feed, |
| 847 | { |
| 848 | runId, |
| 849 | status: "completed", |
| 850 | startedAt: "2026-06-20T00:00:01.000Z", |
| 851 | completedAt: "2026-06-20T00:00:03.000Z", |
| 852 | }, |
| 853 | new Set(), |
| 854 | ); |
| 855 | |
| 856 | expect( |
| 857 | collapsed.some( |
| 858 | (entry) => |
| 859 | entry.type === "activity-group" && |
| 860 | entry.activities.some((activity) => activity.projectedItem.item.type === "fork"), |
| 861 | ), |
| 862 | ).toBe(true); |
| 863 | expect( |
| 864 | collapsed.some( |
| 865 | (entry) => |
| 866 | entry.type === "activity-group" && |
| 867 | entry.activities.some( |
| 868 | (activity) => activity.projectedItem.item.type === "command_execution", |
| 869 | ), |
| 870 | ), |
| 871 | ).toBe(false); |
| 872 | }); |
| 873 | |
| 874 | it("keeps opening and final assistant messages around the first hidden work", () => { |
| 875 | const opening = { |
| 876 | ...assistantMessage("2026-06-20T00:00:01.500Z"), |
| 877 | id: TurnItemId.make("item-opening"), |
| 878 | messageId: MessageId.make("message-opening"), |
| 879 | text: "I will check the deployment configuration.", |
| 880 | }; |
| 881 | const middle = { |
| 882 | ...assistantMessage("2026-06-20T00:00:02.500Z"), |
| 883 | id: TurnItemId.make("item-middle"), |
| 884 | messageId: MessageId.make("message-middle"), |
| 885 | text: "The configuration is valid; checking the build next.", |
| 886 | }; |
| 887 | const feed = buildThreadFeed([ |
| 888 | projected(userMessage(), 0), |
| 889 | projected(opening, 1), |
| 890 | projected(command(), 2), |
| 891 | projected(middle, 3), |
| 892 | projected(assistantMessage(), 4), |
| 893 | ]); |
| 894 | const latestRun = { |
| 895 | runId, |
| 896 | status: "completed" as const, |
| 897 | startedAt: "2026-06-20T00:00:01.000Z", |
| 898 | completedAt: "2026-06-20T00:00:03.000Z", |
| 899 | }; |
| 900 | |
| 901 | const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); |
| 902 | expect(collapsed.map((entry) => entry.id)).toEqual([ |
| 903 | "message-user", |
| 904 | "message-opening", |
| 905 | "run-fold:run-1", |
| 906 | "message-assistant", |
| 907 | ]); |
| 908 | expect(collapsed[1]).toMatchObject({ message: { text: opening.text } }); |
| 909 | expect(collapsed[2]).toMatchObject({ |
| 910 | type: "run-fold", |
| 911 | createdAt: "2026-06-20T00:00:02.000Z", |
| 912 | label: "Worked for 2.0s", |
| 913 | }); |
| 914 | |
| 915 | const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); |
| 916 | expect(expanded.map((entry) => entry.type)).toEqual([ |
| 917 | "message", |
| 918 | "message", |
| 919 | "run-fold", |
| 920 | "work-toggle", |
| 921 | "message", |
| 922 | "message", |
| 923 | ]); |
| 924 | expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } }); |
| 925 | }); |
| 926 | |
| 927 | it("does not fold a response that only has opening and final messages", () => { |
| 928 | const feed = buildThreadFeed([ |
| 929 | projected(userMessage(), 0), |
| 930 | projected( |
| 931 | { |
| 932 | ...assistantMessage("2026-06-20T00:00:02.000Z"), |
| 933 | id: TurnItemId.make("item-opening"), |
| 934 | messageId: MessageId.make("message-opening"), |
| 935 | text: "The result is ready.", |
| 936 | }, |
| 937 | 1, |
| 938 | ), |
| 939 | projected(assistantMessage(), 2), |
| 940 | ]); |
| 941 | |
| 942 | const presented = deriveThreadFeedPresentation(feed, null, new Set()); |
| 943 | expect(presented.map((entry) => entry.id)).toEqual([ |
| 944 | "message-user", |
| 945 | "message-opening", |
| 946 | "message-assistant", |
| 947 | ]); |
| 948 | }); |
| 949 | |
| 950 | it("folds subagents while keeping created-thread and fork cards visible", () => { |
| 951 | const { providerThreadId: _providerThreadId, ...forkBase } = base( |
| 952 | "item-fork", |
| 953 | "2026-06-20T00:00:02.000Z", |
| 954 | 2, |
| 955 | ); |
| 956 | const resourceItems = [ |
| 957 | { |
| 958 | ...base("item-subagent", "2026-06-20T00:00:01.500Z", 1), |
| 959 | type: "subagent", |
| 960 | subagentId: NodeId.make("child-agent"), |
| 961 | origin: "app_owned", |
| 962 | driver: ProviderDriverKind.make("codex"), |
| 963 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 964 | childThreadId: sourceThreadId, |
| 965 | prompt: "Inspect the deployment configuration", |
| 966 | result: "Configuration is valid", |
| 967 | }, |
| 968 | { |
| 969 | ...forkBase, |
| 970 | type: "fork", |
| 971 | source: { type: "run", threadId, runId }, |
| 972 | targetThreadId: sourceThreadId, |
| 973 | }, |
| 974 | { |
| 975 | ...base("item-created-thread", "2026-06-20T00:00:04.000Z", 4), |
| 976 | type: "thread_created", |
| 977 | targetThreadId: sourceThreadId, |
| 978 | targetRunId: null, |
| 979 | targetProviderInstanceId: ProviderInstanceId.make("codex"), |
| 980 | targetModel: "gpt-5.4", |
| 981 | }, |
| 982 | ] satisfies ReadonlyArray<OrchestrationV2TurnItem>; |
| 983 | const projectedResources = [ |
| 984 | projected(resourceItems[0]!, 1), |
| 985 | projected(resourceItems[1]!, 2), |
| 986 | projected(resourceItems[2]!, 4), |
| 987 | ]; |
| 988 | const feed = buildThreadFeed([ |
| 989 | projected(userMessage(), 0), |
| 990 | projectedResources[0]!, |
| 991 | projectedResources[1]!, |
| 992 | projected(command("2026-06-20T00:00:03.000Z"), 3), |
| 993 | projectedResources[2]!, |
| 994 | projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5), |
| 995 | ]); |
| 996 | |
| 997 | const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); |
| 998 | expect(collapsed.map((entry) => entry.type)).toEqual([ |
| 999 | "message", |
| 1000 | "run-fold", |
| 1001 | "activity-group", |
| 1002 | "activity-group", |
| 1003 | "message", |
| 1004 | ]); |
| 1005 | expect(collapsed[1]).toMatchObject({ |
| 1006 | type: "run-fold", |
| 1007 | createdAt: "2026-06-20T00:00:01.500Z", |
| 1008 | }); |
| 1009 | expect( |
| 1010 | collapsed.flatMap((entry) => |
| 1011 | entry.type === "activity-group" |
| 1012 | ? entry.activities.map((activity) => activity.projectedItem) |
| 1013 | : [], |
| 1014 | ), |
| 1015 | ).toEqual(projectedResources.slice(1)); |
| 1016 | const expanded = deriveThreadFeedPresentation(feed, null, new Set([runId])); |
| 1017 | expect( |
| 1018 | expanded.some( |
| 1019 | (entry) => |
| 1020 | entry.type === "activity-group" && |
| 1021 | entry.activities.some((activity) => activity.projectedItem.item.type === "subagent"), |
| 1022 | ), |
| 1023 | ).toBe(true); |
| 1024 | }); |
| 1025 | |
| 1026 | it("keeps a settled run's still-running subagents visible while its other work folds", () => { |
| 1027 | const subagent = ( |
| 1028 | id: string, |
| 1029 | updatedAt: string, |
| 1030 | ordinal: number, |
| 1031 | status: OrchestrationV2TurnItem["status"], |
| 1032 | ): OrchestrationV2TurnItem => ({ |
| 1033 | ...base(id, updatedAt, ordinal), |
| 1034 | providerTurnId: ProviderTurnId.make("provider-turn-1"), |
| 1035 | status, |
| 1036 | completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt), |
| 1037 | type: "subagent", |
| 1038 | subagentId: NodeId.make(id), |
| 1039 | origin: "app_owned", |
| 1040 | driver: ProviderDriverKind.make("codex"), |
| 1041 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1042 | childThreadId: sourceThreadId, |
| 1043 | prompt: `Inspect ${id}`, |
| 1044 | result: null, |
| 1045 | }); |
| 1046 | const latestRun = { |
| 1047 | runId, |
| 1048 | status: "completed" as const, |
| 1049 | startedAt: "2026-06-20T00:00:01.000Z", |
| 1050 | completedAt: "2026-06-20T00:00:05.000Z", |
| 1051 | }; |
| 1052 | const present = (children: ReadonlyArray<OrchestrationV2TurnItem>) => |
| 1053 | deriveThreadFeedPresentation( |
| 1054 | buildThreadFeed([ |
| 1055 | projected(userMessage(), 0), |
| 1056 | projected(command("2026-06-20T00:00:02.000Z"), 1), |
| 1057 | ...children.map((child, index) => projected(child, index + 2)), |
| 1058 | projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5), |
| 1059 | ]), |
| 1060 | latestRun, |
| 1061 | new Set(), |
| 1062 | ); |
| 1063 | const visibleSubagentIds = (presented: ReadonlyArray<ThreadFeedEntry>) => |
| 1064 | presented.flatMap((entry) => |
| 1065 | entry.type === "activity-group" |
| 1066 | ? entry.activities.flatMap((activity) => |
| 1067 | activity.projectedItem.item.type === "subagent" |
| 1068 | ? [activity.projectedItem.item.id] |
| 1069 | : [], |
| 1070 | ) |
| 1071 | : [], |
| 1072 | ); |
| 1073 | |
| 1074 | const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]); |
| 1075 | expect(live.map((entry) => entry.type)).toEqual([ |
| 1076 | "message", |
| 1077 | "run-fold", |
| 1078 | "activity-group", |
| 1079 | "message", |
| 1080 | ]); |
| 1081 | expect(visibleSubagentIds(live)).toEqual(["item-live"]); |
| 1082 | |
| 1083 | // A launch batch stays whole while any member is live. |
| 1084 | const mixed = present([ |
| 1085 | subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"), |
| 1086 | subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"), |
| 1087 | ]); |
| 1088 | expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]); |
| 1089 | |
| 1090 | const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]); |
| 1091 | expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); |
| 1092 | }); |
| 1093 | |
| 1094 | it("folds settled V2 run work while keeping the terminal assistant message visible", () => { |
| 1095 | const feed = buildThreadFeed([ |
| 1096 | projected(userMessage(), 0), |
| 1097 | projected(command(), 1), |
| 1098 | projected(assistantMessage(), 2), |
| 1099 | ]); |
| 1100 | const latestRun = { |
| 1101 | runId, |
| 1102 | status: "completed" as const, |
| 1103 | startedAt: "2026-06-20T00:00:01.000Z", |
| 1104 | completedAt: "2026-06-20T00:00:03.000Z", |
| 1105 | }; |
| 1106 | |
| 1107 | const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); |
| 1108 | expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); |
| 1109 | |
| 1110 | const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); |
| 1111 | expect(expanded.map((entry) => entry.type)).toEqual([ |
| 1112 | "message", |
| 1113 | "run-fold", |
| 1114 | "work-toggle", |
| 1115 | "message", |
| 1116 | ]); |
| 1117 | }); |
| 1118 | |
| 1119 | it("keeps an active run expanded and detects failures from completed command output", () => { |
| 1120 | const failedCommand: OrchestrationV2TurnItem = { |
| 1121 | ...command(), |
| 1122 | output: "sh: missing-command: command not found", |
| 1123 | }; |
| 1124 | const feed = buildThreadFeed([projected(userMessage(), 0), projected(failedCommand, 1)]); |
| 1125 | const presented = deriveThreadFeedPresentation( |
| 1126 | feed, |
| 1127 | { |
| 1128 | runId, |
| 1129 | status: "running", |
| 1130 | startedAt: "2026-06-20T00:00:01.000Z", |
| 1131 | completedAt: null, |
| 1132 | }, |
| 1133 | new Set(), |
| 1134 | ); |
| 1135 | |
| 1136 | expect(presented.some((entry) => entry.type === "run-fold")).toBe(false); |
| 1137 | expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ |
| 1138 | summary: "vp check", |
| 1139 | hiddenCount: 1, |
| 1140 | hasFailure: true, |
| 1141 | live: false, |
| 1142 | }); |
| 1143 | }); |
| 1144 | |
| 1145 | it("folds each run of a provider-native subagent thread like a normal turn", () => { |
| 1146 | // A Claude subagent's child thread, as projected: no runs, and a user |
| 1147 | // prompt for the launch and for a SendMessage resume. |
| 1148 | const runless = <T extends OrchestrationV2TurnItem>(item: T, id: string, ordinal: number) => ({ |
| 1149 | ...item, |
| 1150 | id: TurnItemId.make(id), |
| 1151 | runId: null, |
| 1152 | ordinal, |
| 1153 | }); |
| 1154 | const prompt = (id: string, ordinal: number, at: string) => |
| 1155 | runless( |
| 1156 | { ...userMessage(at), messageId: MessageId.make(id), creationSource: "provider" as const }, |
| 1157 | id, |
| 1158 | ordinal, |
| 1159 | ); |
| 1160 | const answer = (id: string, ordinal: number, at: string) => |
| 1161 | runless({ ...assistantMessage(at), messageId: MessageId.make(id) }, id, ordinal); |
| 1162 | const { exitCode: _exitCode, ...completedCommand } = command("2026-06-20T00:01:17.000Z"); |
| 1163 | const feed = (resumeRunning: boolean) => |
| 1164 | buildThreadFeed( |
| 1165 | [ |
| 1166 | prompt("launch", 1, "2026-06-20T00:00:00.000Z"), |
| 1167 | runless(command("2026-06-20T00:00:04.000Z"), "launch-ls", 2), |
| 1168 | answer("launch-answer", 3, "2026-06-20T00:00:08.000Z"), |
| 1169 | prompt("resume", 4, "2026-06-20T00:01:12.000Z"), |
| 1170 | resumeRunning |
| 1171 | ? runless( |
| 1172 | { ...completedCommand, status: "running", completedAt: null, output: "" }, |
| 1173 | "resume-ls", |
| 1174 | 5, |
| 1175 | ) |
| 1176 | : runless(command("2026-06-20T00:01:17.000Z"), "resume-ls", 5), |
| 1177 | ...(resumeRunning ? [] : [answer("resume-answer", 6, "2026-06-20T00:01:20.000Z")]), |
| 1178 | ].map((item, position) => projected(item, position)), |
| 1179 | ); |
| 1180 | const shape = (entries: ReadonlyArray<ThreadFeedEntry>) => |
| 1181 | entries.map((entry) => |
| 1182 | entry.type === "run-fold" |
| 1183 | ? `fold:${entry.label}` |
| 1184 | : entry.type === "message" |
| 1185 | ? `${entry.message.role}:${entry.message.id}` |
| 1186 | : entry.type, |
| 1187 | ); |
| 1188 | |
| 1189 | const settled = deriveThreadFeedPresentation(feed(false), null, new Set()); |
| 1190 | expect(shape(settled)).toEqual([ |
| 1191 | "user:launch", |
| 1192 | "fold:Worked for 8.0s", |
| 1193 | "assistant:launch-answer", |
| 1194 | "user:resume", |
| 1195 | "fold:Worked for 8.0s", |
| 1196 | "assistant:resume-answer", |
| 1197 | ]); |
| 1198 | const launchFold = settled.find((entry) => entry.type === "run-fold"); |
| 1199 | if (launchFold?.type !== "run-fold") throw new Error("Expected the launch fold"); |
| 1200 | expect( |
| 1201 | shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))), |
| 1202 | ).toEqual([ |
| 1203 | "user:launch", |
| 1204 | "fold:Worked for 8.0s", |
| 1205 | "work-toggle", |
| 1206 | "assistant:launch-answer", |
| 1207 | "user:resume", |
| 1208 | "fold:Worked for 8.0s", |
| 1209 | "assistant:resume-answer", |
| 1210 | ]); |
| 1211 | |
| 1212 | // While the resume runs, only the settled launch folds. |
| 1213 | expect( |
| 1214 | shape( |
| 1215 | deriveThreadFeedPresentation( |
| 1216 | feed(true), |
| 1217 | null, |
| 1218 | new Set(), |
| 1219 | new Set(), |
| 1220 | "2026-06-20T00:01:12.000Z", |
| 1221 | true, |
| 1222 | ), |
| 1223 | ), |
| 1224 | ).toEqual([ |
| 1225 | "user:launch", |
| 1226 | "fold:Worked for 8.0s", |
| 1227 | "assistant:launch-answer", |
| 1228 | "user:resume", |
| 1229 | "work-toggle", |
| 1230 | ]); |
| 1231 | }); |
| 1232 | |
| 1233 | it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { |
| 1234 | const imported = <T extends OrchestrationV2TurnItem>(item: T, id: string) => ({ |
| 1235 | ...item, |
| 1236 | id: TurnItemId.make(id), |
| 1237 | runId: null, |
| 1238 | }); |
| 1239 | const presented = (start: OrchestrationV2TurnItem) => |
| 1240 | deriveThreadFeedPresentation( |
| 1241 | buildThreadFeed( |
| 1242 | [ |
| 1243 | imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"), |
| 1244 | imported( |
| 1245 | { |
| 1246 | ...assistantMessage("2026-06-20T00:00:02.000Z"), |
| 1247 | messageId: MessageId.make("update"), |
| 1248 | }, |
| 1249 | "imported-update", |
| 1250 | ), |
| 1251 | imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"), |
| 1252 | imported( |
| 1253 | { |
| 1254 | ...assistantMessage("2026-06-20T00:00:08.000Z"), |
| 1255 | messageId: MessageId.make("answer"), |
| 1256 | }, |
| 1257 | "imported-answer", |
| 1258 | ), |
| 1259 | start, |
| 1260 | ].map((item, position) => projected(item, position)), |
| 1261 | ), |
| 1262 | { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null }, |
| 1263 | new Set(), |
| 1264 | new Set(), |
| 1265 | "2026-06-20T00:01:00.000Z", |
| 1266 | ) |
| 1267 | .slice(0, 4) |
| 1268 | .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); |
| 1269 | |
| 1270 | // A sent prompt and an automatic wake both start V2 work below the import. |
| 1271 | expect( |
| 1272 | presented({ |
| 1273 | ...userMessage("2026-06-20T00:01:00.000Z"), |
| 1274 | id: TurnItemId.make("new-prompt"), |
| 1275 | messageId: MessageId.make("new-prompt"), |
| 1276 | }), |
| 1277 | ).toEqual(["user", "assistant", "run-fold", "assistant"]); |
| 1278 | expect( |
| 1279 | presented({ |
| 1280 | ...base("wake", "2026-06-20T00:01:00.000Z", 4), |
| 1281 | type: "notification", |
| 1282 | source: { kind: "background_task" }, |
| 1283 | outcome: "completed", |
| 1284 | summary: "Background task finished", |
| 1285 | }), |
| 1286 | ).toEqual(["user", "assistant", "run-fold", "assistant"]); |
| 1287 | }); |
| 1288 | |
| 1289 | it("keeps a provider-native subagent's runless tool call live while it works", () => { |
| 1290 | const startedAt = "2026-06-20T00:00:01.000Z"; |
| 1291 | const { exitCode: _exitCode, ...completedCommand } = command(); |
| 1292 | const runningCommand: OrchestrationV2TurnItem = { |
| 1293 | ...completedCommand, |
| 1294 | runId: null, |
| 1295 | status: "running", |
| 1296 | completedAt: null, |
| 1297 | output: "", |
| 1298 | }; |
| 1299 | const feed = buildThreadFeed([ |
| 1300 | projected({ ...userMessage(), runId: null }, 0), |
| 1301 | projected(runningCommand, 1), |
| 1302 | ]); |
| 1303 | |
| 1304 | const presented = deriveThreadFeedPresentation( |
| 1305 | feed, |
| 1306 | null, |
| 1307 | new Set(), |
| 1308 | new Set(), |
| 1309 | startedAt, |
| 1310 | true, |
| 1311 | ); |
| 1312 | expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ |
| 1313 | summary: "Running vp", |
| 1314 | live: true, |
| 1315 | shimmer: true, |
| 1316 | }); |
| 1317 | expect(presented.some((entry) => entry.type === "thinking")).toBe(false); |
| 1318 | }); |
| 1319 | |
| 1320 | it("keeps a runless tail folded while a normal thread waits for its sent run", () => { |
| 1321 | // Right after a send the local clock runs before the server creates the |
| 1322 | // run, and the latest run may still be queued: neither is runless work, |
| 1323 | // so the settled tail must not reopen and shift the feed. |
| 1324 | const startedAt = "2026-06-20T00:00:05.000Z"; |
| 1325 | const feed = buildThreadFeed([ |
| 1326 | projected({ ...userMessage(), runId: null }, 0), |
| 1327 | projected({ ...command(), runId: null }, 1), |
| 1328 | ]); |
| 1329 | for (const latestRun of [ |
| 1330 | null, |
| 1331 | { runId, status: "queued" as const, startedAt: null, completedAt: null }, |
| 1332 | ]) { |
| 1333 | const presented = deriveThreadFeedPresentation( |
| 1334 | feed, |
| 1335 | latestRun, |
| 1336 | new Set(), |
| 1337 | new Set(), |
| 1338 | startedAt, |
| 1339 | ); |
| 1340 | expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); |
| 1341 | } |
| 1342 | }); |
| 1343 | |
| 1344 | it("waits for workspace preparation before showing provider activity", () => { |
| 1345 | const startedAt = "2026-04-01T00:00:01.000Z"; |
| 1346 | const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null }; |
| 1347 | expect(deriveThreadFeedPresentation([], run, new Set(), new Set(), startedAt)).toEqual([]); |
| 1348 | expect( |
| 1349 | deriveThreadFeedPresentation( |
| 1350 | [], |
| 1351 | { ...run, status: "running", startedAt }, |
| 1352 | new Set(), |
| 1353 | new Set(), |
| 1354 | startedAt, |
| 1355 | ), |
| 1356 | ).toEqual([{ type: "thinking", id: "live-activity-row", createdAt: startedAt, runId }]); |
| 1357 | }); |
| 1358 | |
| 1359 | it("uses a stable Thinking row while work has started without a projected item", () => { |
| 1360 | const startedAt = "2026-04-01T00:00:01.000Z"; |
| 1361 | const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); |
| 1362 | |
| 1363 | expect(presented).toEqual([ |
| 1364 | { type: "thinking", id: "live-activity-row", createdAt: startedAt, runId: null }, |
| 1365 | ]); |
| 1366 | expect(deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt)[0]).toBe( |
| 1367 | presented[0], |
| 1368 | ); |
| 1369 | }); |
| 1370 | |
| 1371 | it("keeps expanded work in one group with stable row identities", () => { |
| 1372 | const activity = ( |
| 1373 | id: string, |
| 1374 | createdAt: string, |
| 1375 | status: ThreadFeedActivity["status"] = "success", |
| 1376 | ): ThreadFeedActivity => ({ |
| 1377 | id, |
| 1378 | createdAt, |
| 1379 | runId: null, |
| 1380 | attemptId: null, |
| 1381 | summary: `Tool ${id}`, |
| 1382 | detail: null, |
| 1383 | canExpand: false, |
| 1384 | fetchesDetail: false, |
| 1385 | getFullDetail: () => null, |
| 1386 | getCopyText: () => id, |
| 1387 | icon: "command", |
| 1388 | logo: null, |
| 1389 | toolLike: true, |
| 1390 | prominent: false, |
| 1391 | status, |
| 1392 | lifecycleStatus: status === "neutral" ? "inProgress" : "completed", |
| 1393 | workEntry: { |
| 1394 | id, |
| 1395 | createdAt, |
| 1396 | label: `Tool ${id}`, |
| 1397 | tone: "tool", |
| 1398 | command: "vp check", |
| 1399 | itemType: "command_execution", |
| 1400 | toolLifecycleStatus: status === "neutral" ? "inProgress" : "completed", |
| 1401 | }, |
| 1402 | projectedItem: projected(command(createdAt), 0), |
| 1403 | }); |
| 1404 | const feed: ThreadFeedEntry[] = [ |
| 1405 | { |
| 1406 | type: "activity-group", |
| 1407 | id: "work-group-1", |
| 1408 | createdAt: "2026-04-01T00:00:01.000Z", |
| 1409 | runId: null, |
| 1410 | activities: [ |
| 1411 | activity("activity-neutral", "2026-04-01T00:00:01.000Z", "neutral"), |
| 1412 | activity("activity-1", "2026-04-01T00:00:02.000Z"), |
| 1413 | activity("activity-2", "2026-04-01T00:00:03.000Z"), |
| 1414 | activity("activity-3", "2026-04-01T00:00:04.000Z"), |
| 1415 | ], |
| 1416 | }, |
| 1417 | ]; |
| 1418 | |
| 1419 | const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); |
| 1420 | expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-neutral"]); |
| 1421 | expect(collapsed[0]).toMatchObject({ |
| 1422 | type: "work-toggle", |
| 1423 | groupId: "work-group:activity-neutral", |
| 1424 | hiddenCount: 3, |
| 1425 | expanded: false, |
| 1426 | summary: "Ran 3 commands", |
| 1427 | }); |
| 1428 | |
| 1429 | const expanded = deriveThreadFeedPresentation( |
| 1430 | feed, |
| 1431 | null, |
| 1432 | new Set(), |
| 1433 | new Set(["work-group:activity-neutral"]), |
| 1434 | ); |
| 1435 | expect(expanded.map((entry) => entry.id)).toEqual([ |
| 1436 | "work-toggle:work-group:activity-neutral", |
| 1437 | "work-details:work-group:activity-neutral", |
| 1438 | ]); |
| 1439 | expect(expanded[0]).toMatchObject({ |
| 1440 | type: "work-toggle", |
| 1441 | expanded: true, |
| 1442 | }); |
| 1443 | expect(expanded[1]).toMatchObject({ |
| 1444 | type: "activity-group", |
| 1445 | activities: [ |
| 1446 | { id: "activity-1", groupedToolDetail: true, live: false }, |
| 1447 | { id: "activity-2", groupedToolDetail: true, live: false }, |
| 1448 | { id: "activity-3", groupedToolDetail: true, live: false }, |
| 1449 | ], |
| 1450 | }); |
| 1451 | }); |
| 1452 | |
| 1453 | it("retains Claude Read image previews without tool output", () => { |
| 1454 | const item = { |
| 1455 | ...base("image-read", "2026-06-20T00:00:04.000Z", 3), |
| 1456 | type: "dynamic_tool" as const, |
| 1457 | toolName: "Read", |
| 1458 | input: { file_path: "/workspace/reference.png" }, |
| 1459 | viewedImagePath: "/workspace/reference.png", |
| 1460 | } satisfies OrchestrationV2TurnItem; |
| 1461 | const feed = buildThreadFeed([projected(item, 0)]); |
| 1462 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; |
| 1463 | expect(activity?.workEntry.viewedImagePath).toBe("/workspace/reference.png"); |
| 1464 | }); |
| 1465 | |
| 1466 | it("pretty prints T3 MCP dynamic tool activities and attaches the product logo", () => { |
| 1467 | const toolItem: OrchestrationV2TurnItem = { |
| 1468 | ...base("item-t3-tool", "2026-06-20T00:00:04.000Z", 3), |
| 1469 | type: "dynamic_tool", |
| 1470 | toolName: "mcp__t3-code__t3_thread_read", |
| 1471 | input: { threadId: "thread-child" }, |
| 1472 | output: { messages: [] }, |
| 1473 | }; |
| 1474 | |
| 1475 | const feed = buildThreadFeed([projected(toolItem, 0)]); |
| 1476 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; |
| 1477 | |
| 1478 | expect(activity?.summary).toBe("Read a T3 thread"); |
| 1479 | expect(activity?.logo).toBe("t3-code"); |
| 1480 | expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread"); |
| 1481 | }); |
| 1482 | |
| 1483 | it.each(["cua_repl.js", "node_repl.js", "js"])( |
| 1484 | "uses the %s action title in the mobile feed", |
| 1485 | (toolName) => { |
| 1486 | const item: OrchestrationV2TurnItem = { |
| 1487 | ...base("cua", "2026-09-23T20:20:00.000Z", 1), |
| 1488 | type: "dynamic_tool", |
| 1489 | toolName, |
| 1490 | input: { |
| 1491 | code: "await game.getAXStateAndScreenshot();", |
| 1492 | title: "Inspect Saga music screen", |
| 1493 | }, |
| 1494 | }; |
| 1495 | const feed = buildThreadFeed([projected(item, 0)]); |
| 1496 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; |
| 1497 | expect(activity?.summary).toBe("Inspect Saga music screen"); |
| 1498 | }, |
| 1499 | ); |
| 1500 | |
| 1501 | it.each([ |
| 1502 | [ |
| 1503 | "failed", |
| 1504 | "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", |
| 1505 | "Restrict the breakpoint to Git actions", |
| 1506 | "computer", |
| 1507 | ], |
| 1508 | [ |
| 1509 | "completed", |
| 1510 | "await fetch('http://127.0.0.1/json/list');", |
| 1511 | "Discover the live Electron debugging endpoint", |
| 1512 | "code", |
| 1513 | ], |
| 1514 | ] as const)("recovers the retained js title and %s call icon", (status, code, title, icon) => { |
| 1515 | const item: OrchestrationV2TurnItem = { |
| 1516 | ...base("retained-js", "2026-09-23T20:20:00.000Z", 1), |
| 1517 | type: "dynamic_tool", |
| 1518 | status, |
| 1519 | title: "js", |
| 1520 | toolName: "node_repl.js", |
| 1521 | toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" }, |
| 1522 | input: { code, title }, |
| 1523 | }; |
| 1524 | const feed = buildThreadFeed([projected(item, 0)]); |
| 1525 | const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; |
| 1526 | expect(activity?.summary).toBe(title); |
| 1527 | expect(activity?.icon).toBe(icon); |
| 1528 | expect(activity?.workEntry.toolSource?.kind).toBe( |
| 1529 | icon === "computer" ? "computer" : "integration", |
| 1530 | ); |
| 1531 | }); |
| 1532 | |
| 1533 | it("uses canonical T3 orchestration summaries in compact work groups", () => { |
| 1534 | const rows = [ |
| 1535 | projected(command("2026-06-20T00:00:01.000Z"), 0), |
| 1536 | ...["mcp__t3-code__t3_thread_send", "t3_code.t3_thread_send", "t3_thread_send"].map( |
| 1537 | (toolName, index) => |
| 1538 | projected( |
| 1539 | { |
| 1540 | ...base(`item-send-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 2), |
| 1541 | type: "dynamic_tool" as const, |
| 1542 | toolName, |
| 1543 | input: { threadId: `thread-${index}`, message: "Continue" }, |
| 1544 | output: { threadId: `thread-${index}`, messageId: `message-${index}` }, |
| 1545 | }, |
| 1546 | index + 1, |
| 1547 | ), |
| 1548 | ), |
| 1549 | projected( |
| 1550 | { |
| 1551 | ...command("2026-06-20T00:00:06.000Z"), |
| 1552 | id: TurnItemId.make("item-command-2"), |
| 1553 | ordinal: 6, |
| 1554 | }, |
| 1555 | 4, |
| 1556 | ), |
| 1557 | ]; |
| 1558 | |
| 1559 | const presented = deriveThreadFeedPresentation( |
| 1560 | buildThreadFeed(rows), |
| 1561 | { runId, status: "running", startedAt: null, completedAt: null }, |
| 1562 | new Set(), |
| 1563 | ); |
| 1564 | |
| 1565 | expect(presented).toMatchObject([ |
| 1566 | { |
| 1567 | type: "work-toggle", |
| 1568 | summary: "Ran 2 commands and sent messages to 3 threads", |
| 1569 | hiddenCount: 5, |
| 1570 | hasFailure: false, |
| 1571 | }, |
| 1572 | ]); |
| 1573 | }); |
| 1574 | |
| 1575 | it("presents project calls and summarizes successful clones through the mobile feed", () => { |
| 1576 | const items: OrchestrationV2TurnItem[] = [ |
| 1577 | { |
| 1578 | ...base("list", "2026-09-19T00:00:01.000Z", 1), |
| 1579 | type: "dynamic_tool", |
| 1580 | title: "Custom provider title", |
| 1581 | toolName: "T3-code.t3_project_list", |
| 1582 | input: {}, |
| 1583 | output: { projects: [] }, |
| 1584 | }, |
| 1585 | { |
| 1586 | ...base("clone", "2026-09-19T00:00:02.000Z", 2), |
| 1587 | type: "dynamic_tool", |
| 1588 | title: "Custom provider title", |
| 1589 | toolName: "mcp__t3_code__t3_project_clone", |
| 1590 | input: {}, |
| 1591 | output: { cwd: "/tmp/repo" }, |
| 1592 | }, |
| 1593 | { |
| 1594 | ...base("failed-clone", "2026-09-19T00:00:03.000Z", 3), |
| 1595 | type: "dynamic_tool", |
| 1596 | title: "Custom provider title", |
| 1597 | toolName: "t3_project_clone", |
| 1598 | input: {}, |
| 1599 | output: { isError: true }, |
| 1600 | }, |
| 1601 | ]; |
| 1602 | const feed = buildThreadFeed(items.map((item, position) => projected(item, position))); |
| 1603 | const activities = feed.flatMap((entry) => |
| 1604 | entry.type === "activity-group" ? entry.activities : [], |
| 1605 | ); |
| 1606 | expect(workEntryRowLabel(activities[0]!.workEntry)).toBe("Listed projects"); |
| 1607 | expect(workEntryRowLabel(activities[1]!.workEntry)).toBe("Cloned a repository"); |
| 1608 | expect(workEntryRowLabel(activities[2]!.workEntry)).toBe("Failed to clone a repository"); |
| 1609 | expect(activities.every((activity) => activity.logo === "t3-code")).toBe(true); |
| 1610 | const presented = deriveThreadFeedPresentation( |
| 1611 | feed, |
| 1612 | { runId, status: "running", startedAt: null, completedAt: null }, |
| 1613 | new Set(), |
| 1614 | ); |
| 1615 | expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ |
| 1616 | summary: "Listed projects 1 time and cloned 1 repository", |
| 1617 | hasFailure: true, |
| 1618 | }); |
| 1619 | }); |
| 1620 | }); |
| 1621 | |
| 1622 | describe("retained v2 feed presentation", () => { |
| 1623 | it("retains unchanged rows while the assistant streams", () => { |
| 1624 | const rows = [ |
| 1625 | projected(userMessage(), 0), |
| 1626 | projected(command(), 1), |
| 1627 | projected({ ...assistantMessage(), streaming: true }, 2), |
| 1628 | ]; |
| 1629 | const latestRun = { |
| 1630 | runId, |
| 1631 | status: "running" as const, |
| 1632 | startedAt: "2026-06-20T00:00:01.000Z", |
| 1633 | completedAt: null, |
| 1634 | }; |
| 1635 | const before = buildThreadFeed(rows); |
| 1636 | const beforePresentation = deriveThreadFeedPresentation( |
| 1637 | before, |
| 1638 | latestRun, |
| 1639 | new Set(), |
| 1640 | new Set(), |
| 1641 | latestRun.startedAt, |
| 1642 | ); |
| 1643 | const after = buildThreadFeed([ |
| 1644 | rows[0]!, |
| 1645 | rows[1]!, |
| 1646 | projected( |
| 1647 | { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true }, |
| 1648 | 2, |
| 1649 | ), |
| 1650 | ]); |
| 1651 | const afterPresentation = deriveThreadFeedPresentation( |
| 1652 | after, |
| 1653 | latestRun, |
| 1654 | new Set(), |
| 1655 | new Set(), |
| 1656 | latestRun.startedAt, |
| 1657 | ); |
| 1658 | expect(after[0]).toBe(before[0]); |
| 1659 | expect(after[1]).toBe(before[1]); |
| 1660 | expect(after[2]).not.toBe(before[2]); |
| 1661 | expect(afterPresentation[0]).toBe(beforePresentation[0]); |
| 1662 | expect(afterPresentation[1]).toBe(beforePresentation[1]); |
| 1663 | }); |
| 1664 | |
| 1665 | it.each(["running", "completed", "interrupted"] as const)( |
| 1666 | "uses the compaction row as the live activity only while %s", |
| 1667 | (status) => { |
| 1668 | const compact = projected( |
| 1669 | { |
| 1670 | ...base("compacted", "2026-06-20T00:00:02.000Z", 1), |
| 1671 | type: "compaction", |
| 1672 | status, |
| 1673 | driver: null, |
| 1674 | beforeTokenCount: 899_000, |
| 1675 | ...(status === "completed" ? { afterTokenCount: 19_000 } : {}), |
| 1676 | }, |
| 1677 | 1, |
| 1678 | ); |
| 1679 | const latestRun = { |
| 1680 | runId, |
| 1681 | status: "running" as const, |
| 1682 | startedAt: "2026-06-20T00:00:01.000Z", |
| 1683 | completedAt: null, |
| 1684 | }; |
| 1685 | const rows = deriveThreadFeedPresentation( |
| 1686 | buildThreadFeed([projected(userMessage(), 0), compact]), |
| 1687 | latestRun, |
| 1688 | new Set(), |
| 1689 | new Set(), |
| 1690 | latestRun.startedAt, |
| 1691 | ); |
| 1692 | expect(rows.some((row) => row.type === "thinking")).toBe(status !== "running"); |
| 1693 | expect(rows.find((row) => row.type === "activity-group")).toMatchObject({ |
| 1694 | activities: [ |
| 1695 | { |
| 1696 | summary: |
| 1697 | status === "running" |
| 1698 | ? "Compacting context" |
| 1699 | : status === "completed" |
| 1700 | ? "Context compacted 899K → 19K tokens" |
| 1701 | : "Context compaction stopped", |
| 1702 | }, |
| 1703 | ], |
| 1704 | }); |
| 1705 | }, |
| 1706 | ); |
| 1707 | |
| 1708 | it.each(["running", "completed", "failed"] as const)( |
| 1709 | "keeps a %s handoff separate from commands and visible through folds", |
| 1710 | (status) => { |
| 1711 | const handoff = projected( |
| 1712 | { |
| 1713 | ...base("handoff", "2026-06-20T00:00:02.000Z", 1), |
| 1714 | type: "handoff", |
| 1715 | status, |
| 1716 | contextHandoffId: ContextHandoffId.make("handoff"), |
| 1717 | fromProviderThreadIds: [], |
| 1718 | toProviderThreadId: ProviderThreadId.make("target"), |
| 1719 | fromProviderInstanceIds: [ProviderInstanceId.make("codex")], |
| 1720 | toProviderInstanceId: ProviderInstanceId.make("claudeAgent"), |
| 1721 | strategy: "full_thread_summary", |
| 1722 | summary: "Private full conversation summary", |
| 1723 | }, |
| 1724 | 1, |
| 1725 | ); |
| 1726 | const feed = buildThreadFeed([ |
| 1727 | projected(userMessage(), 0), |
| 1728 | handoff, |
| 1729 | projected(command("2026-06-20T00:00:03.000Z"), 2), |
| 1730 | projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3), |
| 1731 | ]); |
| 1732 | for (const expanded of [new Set<RunId>(), new Set([runId])]) { |
| 1733 | const rows = deriveThreadFeedPresentation(feed, null, expanded); |
| 1734 | const divider = rows.filter( |
| 1735 | (entry) => entry.type === "activity-group" && isContextHandoffActivityGroup(entry), |
| 1736 | ); |
| 1737 | expect(divider).toHaveLength(1); |
| 1738 | expect(divider[0]).toMatchObject({ activities: [{ projectedItem: handoff }] }); |
| 1739 | } |
| 1740 | const alone = deriveThreadFeedPresentation( |
| 1741 | buildThreadFeed([projected(userMessage(), 0), handoff]), |
| 1742 | null, |
| 1743 | new Set(), |
| 1744 | ); |
| 1745 | expect(alone.map((entry) => entry.type)).toEqual(["message", "activity-group"]); |
| 1746 | }, |
| 1747 | ); |
| 1748 | |
| 1749 | it("keeps a standalone compaction visible and folds it with other completed work", () => { |
| 1750 | const compact = projected( |
| 1751 | { |
| 1752 | ...base("compacted", "2026-06-20T00:00:02.000Z", 1), |
| 1753 | type: "compaction", |
| 1754 | driver: null, |
| 1755 | summary: "Shorter context", |
| 1756 | }, |
| 1757 | 1, |
| 1758 | ); |
| 1759 | const latestRun = { |
| 1760 | runId, |
| 1761 | status: "completed" as const, |
| 1762 | startedAt: "2026-06-20T00:00:01.000Z", |
| 1763 | completedAt: "2026-06-20T00:00:04.000Z", |
| 1764 | }; |
| 1765 | const onlyCompaction = deriveThreadFeedPresentation( |
| 1766 | buildThreadFeed([projected(userMessage(), 0), compact]), |
| 1767 | latestRun, |
| 1768 | new Set(), |
| 1769 | ); |
| 1770 | expect(onlyCompaction.map((entry) => entry.type)).toEqual(["message", "activity-group"]); |
| 1771 | const feed = buildThreadFeed([ |
| 1772 | projected(userMessage(), 0), |
| 1773 | compact, |
| 1774 | projected(command("2026-06-20T00:00:03.000Z"), 2), |
| 1775 | projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3), |
| 1776 | ]); |
| 1777 | expect( |
| 1778 | deriveThreadFeedPresentation(feed, latestRun, new Set()).map((entry) => entry.type), |
| 1779 | ).toEqual(["message", "run-fold", "message"]); |
| 1780 | const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); |
| 1781 | expect( |
| 1782 | expanded.find( |
| 1783 | (entry) => |
| 1784 | entry.type === "activity-group" && |
| 1785 | entry.activities[0]?.projectedItem.item.type === "compaction", |
| 1786 | ), |
| 1787 | ).toMatchObject({ activities: [{ summary: "Context compacted" }] }); |
| 1788 | }); |
| 1789 | |
| 1790 | it("retains assistant image attachments from the wire", () => { |
| 1791 | const image = { |
| 1792 | type: "image" as const, |
| 1793 | id: "assistant-image", |
| 1794 | name: "result.png", |
| 1795 | mimeType: "image/png", |
| 1796 | sizeBytes: 100, |
| 1797 | }; |
| 1798 | const feed = buildThreadFeed([ |
| 1799 | projected({ ...assistantMessage(), text: "", attachments: [image] }, 0), |
| 1800 | ]); |
| 1801 | expect(feed).toMatchObject([ |
| 1802 | { type: "message", message: { role: "assistant", attachments: [image] } }, |
| 1803 | ]); |
| 1804 | }); |
| 1805 | |
| 1806 | it("keeps native application icons and source identity in collapsed and expanded work", () => { |
| 1807 | const icon = { |
| 1808 | _tag: "native-app" as const, |
| 1809 | app: { _tag: "app-id" as const, appId: "com.example.Editor" }, |
| 1810 | }; |
| 1811 | const source = { |
| 1812 | key: "native-app:com.example.editor", |
| 1813 | name: "Editor", |
| 1814 | kind: "computer" as const, |
| 1815 | icon, |
| 1816 | }; |
| 1817 | const rows = [0, 1].map((index) => |
| 1818 | projected( |
| 1819 | { |
| 1820 | ...base(`native-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 1), |
| 1821 | type: "dynamic_tool" as const, |
| 1822 | toolName: "computer.click", |
| 1823 | input: { x: index, y: 1 }, |
| 1824 | output: null, |
| 1825 | toolSurface: "computer" as const, |
| 1826 | toolIcon: icon, |
| 1827 | toolSource: source, |
| 1828 | }, |
| 1829 | index, |
| 1830 | ), |
| 1831 | ); |
| 1832 | const feed = buildThreadFeed(rows); |
| 1833 | const latestRun = { runId, status: "running" as const, startedAt: null, completedAt: null }; |
| 1834 | const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); |
| 1835 | const toggle = collapsed[0]; |
| 1836 | if (toggle?.type !== "work-toggle") throw new Error("Expected a collapsed work group"); |
| 1837 | const presented = deriveThreadFeedPresentation( |
| 1838 | feed, |
| 1839 | latestRun, |
| 1840 | new Set(), |
| 1841 | new Set([toggle.groupId]), |
| 1842 | ); |
| 1843 | expect(presented[0]).toMatchObject({ |
| 1844 | type: "work-toggle", |
| 1845 | summary: "Used Editor", |
| 1846 | toolSurface: "computer", |
| 1847 | toolIcon: icon, |
| 1848 | }); |
| 1849 | expect(presented[1]).toMatchObject({ |
| 1850 | type: "activity-group", |
| 1851 | activities: [ |
| 1852 | { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } }, |
| 1853 | { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } }, |
| 1854 | ], |
| 1855 | }); |
| 1856 | }); |
| 1857 | |
| 1858 | it.each([ |
| 1859 | ["failed", "Failed to click in the preview browser", true], |
| 1860 | ["cancelled", "Stopped clicking in the preview browser", false], |
| 1861 | ] as const)( |
| 1862 | "keeps %s calls terminal while the parent run remains live", |
| 1863 | (status, summary, hasFailure) => { |
| 1864 | const feed = buildThreadFeed([ |
| 1865 | projected( |
| 1866 | { |
| 1867 | ...base("preview-click", "2026-06-20T00:00:02.000Z", 1), |
| 1868 | type: "dynamic_tool", |
| 1869 | status, |
| 1870 | toolName: "mcp__t3-code__preview_click", |
| 1871 | input: { element: "button" }, |
| 1872 | output: null, |
| 1873 | }, |
| 1874 | 0, |
| 1875 | ), |
| 1876 | ]); |
| 1877 | const rows = deriveThreadFeedPresentation( |
| 1878 | feed, |
| 1879 | { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null }, |
| 1880 | new Set(), |
| 1881 | new Set(), |
| 1882 | "2026-06-20T00:00:01.000Z", |
| 1883 | ); |
| 1884 | expect(rows[0]).toMatchObject({ type: "work-toggle", summary, hasFailure, shimmer: false }); |
| 1885 | }, |
| 1886 | ); |
| 1887 | |
| 1888 | it.each([ |
| 1889 | { envelope: "direct", output: { taskId: "a" } }, |
| 1890 | { envelope: "structured", output: { structuredContent: { taskId: "a" } } }, |
| 1891 | { envelope: "text", output: { content: [{ type: "text", text: '{"taskId":"a"}' }] } }, |
| 1892 | ])( |
| 1893 | "folds matched $envelope delegations without hiding pending, failed or unmatched calls", |
| 1894 | ({ output }) => { |
| 1895 | const agent = ( |
| 1896 | id: string, |
| 1897 | index: number, |
| 1898 | origin = "app_owned" as "app_owned" | "provider_native", |
| 1899 | ) => |
| 1900 | projected( |
| 1901 | { |
| 1902 | ...base(id, "2026-06-20T00:00:01.000Z", index), |
| 1903 | type: "subagent", |
| 1904 | subagentId: NodeId.make(id), |
| 1905 | origin, |
| 1906 | driver: ProviderDriverKind.make("codex"), |
| 1907 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1908 | childThreadId: ThreadId.make(`child-${id}`), |
| 1909 | prompt: "Identical task", |
| 1910 | result: "Done", |
| 1911 | }, |
| 1912 | index, |
| 1913 | ); |
| 1914 | const delegation = ( |
| 1915 | id: string, |
| 1916 | index: number, |
| 1917 | overrides: Partial<Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>> = {}, |
| 1918 | ) => |
| 1919 | projected( |
| 1920 | { |
| 1921 | ...base(id, "2026-06-20T00:00:02.000Z", index), |
| 1922 | type: "dynamic_tool", |
| 1923 | toolName: "t3-code.delegate_task", |
| 1924 | input: { task: "Identical task" }, |
| 1925 | output, |
| 1926 | ...overrides, |
| 1927 | }, |
| 1928 | index, |
| 1929 | ); |
| 1930 | const feed = buildThreadFeed([ |
| 1931 | agent("a", 1), |
| 1932 | delegation("matched", 2), |
| 1933 | agent("b", 3), |
| 1934 | delegation("pending", 4, { status: "running", output: null }), |
| 1935 | delegation("unmatched", 5, { output: { taskId: "missing" } }), |
| 1936 | delegation("failed", 6, { status: "failed" }), |
| 1937 | delegation("error-output", 7, { output: { taskId: "a", isError: true } }), |
| 1938 | delegation("other-run", 8, { runId: RunId.make("other-run") }), |
| 1939 | agent("native", 9, "provider_native"), |
| 1940 | delegation("native-delegation", 10, { output: { taskId: "native" } }), |
| 1941 | ]); |
| 1942 | const groups = feed.flatMap((entry) => |
| 1943 | entry.type === "activity-group" |
| 1944 | ? [entry.activities.map((activity) => activity.projectedItem.item.id)] |
| 1945 | : [], |
| 1946 | ); |
| 1947 | expect(groups[0]).toEqual(["a", "b"]); |
| 1948 | expect(groups.flat()).toEqual([ |
| 1949 | "a", |
| 1950 | "b", |
| 1951 | "pending", |
| 1952 | "unmatched", |
| 1953 | "failed", |
| 1954 | "error-output", |
| 1955 | "other-run", |
| 1956 | "native", |
| 1957 | "native-delegation", |
| 1958 | ]); |
| 1959 | const presented = deriveThreadFeedPresentation( |
| 1960 | feed, |
| 1961 | null, |
| 1962 | new Set([runId, RunId.make("other-run")]), |
| 1963 | ); |
| 1964 | expect( |
| 1965 | presented.find( |
| 1966 | (entry) => |
| 1967 | entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.id === "a", |
| 1968 | )?.continuesWorkLog, |
| 1969 | ).toBeUndefined(); |
| 1970 | }, |
| 1971 | ); |
| 1972 | |
| 1973 | it("keeps subagents from different provider turns in separate cards", () => { |
| 1974 | const agent = (id: string, index: number) => |
| 1975 | projected( |
| 1976 | { |
| 1977 | ...base(id, "2026-06-20T00:00:01.000Z", index), |
| 1978 | type: "subagent", |
| 1979 | subagentId: NodeId.make(id), |
| 1980 | origin: "provider_native", |
| 1981 | driver: ProviderDriverKind.make("codex"), |
| 1982 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1983 | providerTurnId: ProviderTurnId.make(id), |
| 1984 | childThreadId: null, |
| 1985 | prompt: "Task", |
| 1986 | result: null, |
| 1987 | }, |
| 1988 | index, |
| 1989 | ); |
| 1990 | expect( |
| 1991 | buildThreadFeed([agent("a", 1), agent("b", 2)]).flatMap((entry) => |
| 1992 | entry.type === "activity-group" |
| 1993 | ? [entry.activities.map((activity) => activity.projectedItem.item.id)] |
| 1994 | : [], |
| 1995 | ), |
| 1996 | ).toEqual([["a"], ["b"]]); |
| 1997 | }); |
| 1998 | |
| 1999 | it("groups only adjacent subagents in the same run, keeping their child links", () => { |
| 2000 | const agent = (id: string, index: number, agentRunId = runId) => |
| 2001 | projected( |
| 2002 | { |
| 2003 | ...base(id, `2026-06-20T00:00:0${index}.000Z`, index), |
| 2004 | type: "subagent", |
| 2005 | runId: agentRunId, |
| 2006 | subagentId: NodeId.make(id), |
| 2007 | origin: "app_owned", |
| 2008 | driver: ProviderDriverKind.make("codex"), |
| 2009 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 2010 | childThreadId: ThreadId.make(`child-${id}`), |
| 2011 | prompt: "Solve the puzzle", |
| 2012 | result: "Done", |
| 2013 | }, |
| 2014 | index, |
| 2015 | ); |
| 2016 | const feed = buildThreadFeed([ |
| 2017 | agent("a", 1), |
| 2018 | agent("b", 2), |
| 2019 | projected(command("2026-06-20T00:00:03.000Z"), 3), |
| 2020 | agent("c", 4), |
| 2021 | agent("d", 5, RunId.make("other-run")), |
| 2022 | ]); |
| 2023 | expect( |
| 2024 | feed.flatMap((entry) => |
| 2025 | entry.type === "activity-group" |
| 2026 | ? [entry.activities.map((activity) => activity.projectedItem.item.id)] |
| 2027 | : [], |
| 2028 | ), |
| 2029 | ).toEqual([["a", "b"], ["item-command"], ["c"], ["d"]]); |
| 2030 | const presented = deriveThreadFeedPresentation( |
| 2031 | feed, |
| 2032 | null, |
| 2033 | new Set([runId, RunId.make("other-run")]), |
| 2034 | ); |
| 2035 | const groups = presented.flatMap((entry) => |
| 2036 | entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.type === "subagent" |
| 2037 | ? [entry] |
| 2038 | : [], |
| 2039 | ); |
| 2040 | expect(groups.map((entry) => entry.activities.length)).toEqual([2, 1, 1]); |
| 2041 | expect(groups[0]?.activities.map((activity) => activity.projectedItem.item)).toMatchObject([ |
| 2042 | { childThreadId: "child-a" }, |
| 2043 | { childThreadId: "child-b" }, |
| 2044 | ]); |
| 2045 | }); |
| 2046 | |
| 2047 | it("shows an idle native subagent without claiming completion", () => { |
| 2048 | const rows = buildThreadFeed([ |
| 2049 | projected( |
| 2050 | { |
| 2051 | ...base("native-agent", "2026-06-20T00:00:02.000Z", 1), |
| 2052 | type: "subagent", |
| 2053 | status: "idle", |
| 2054 | subagentId: NodeId.make("native-agent"), |
| 2055 | origin: "provider_native", |
| 2056 | driver: ProviderDriverKind.make("antigravity"), |
| 2057 | providerInstanceId: ProviderInstanceId.make("antigravity"), |
| 2058 | childThreadId: null, |
| 2059 | title: "Search", |
| 2060 | prompt: "Find relevant files", |
| 2061 | result: null, |
| 2062 | }, |
| 2063 | 0, |
| 2064 | ), |
| 2065 | ]); |
| 2066 | expect(rows[0]).toMatchObject({ |
| 2067 | type: "activity-group", |
| 2068 | activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: false }], |
| 2069 | }); |
| 2070 | expect(deriveThreadFeedPresentation(rows, null, new Set([runId]))).toMatchObject([ |
| 2071 | { type: "run-fold", expanded: true }, |
| 2072 | { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] }, |
| 2073 | ]); |
| 2074 | }); |
| 2075 | }); |
| 2076 | |
| 2077 | const singleSelectQuestion = { |
| 2078 | id: "runtime", |
| 2079 | header: "Runtime", |
| 2080 | question: "Which runtime should be used?", |
| 2081 | options: [ |
| 2082 | { label: "Go", description: "One binary" }, |
| 2083 | { label: "Node.js", description: "Reuse TypeScript" }, |
| 2084 | ], |
| 2085 | multiSelect: false, |
| 2086 | } as const; |
| 2087 | |
| 2088 | const multiSelectQuestion = { |
| 2089 | id: "scope", |
| 2090 | header: "Scope", |
| 2091 | question: "Which data should be collected?", |
| 2092 | options: [ |
| 2093 | { label: "Orders", description: "Receipts" }, |
| 2094 | { label: "Listings", description: "Inventory" }, |
| 2095 | ], |
| 2096 | multiSelect: true, |
| 2097 | } as const; |
| 2098 | |
| 2099 | describe("subagent continued by a later turn", () => { |
| 2100 | // Stored shape from #15891: resuming a subagent moves its item to the |
| 2101 | // resuming run, but the item keeps the ordinal from the turn that started it. |
| 2102 | const run2 = RunId.make("run-2"); |
| 2103 | const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`; |
| 2104 | const item = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ |
| 2105 | ...base(id, at(second), ordinal), |
| 2106 | runId: itemRunId, |
| 2107 | }); |
| 2108 | const user = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ |
| 2109 | ...item(id, itemRunId, ordinal, second), |
| 2110 | type: "user_message" as const, |
| 2111 | messageId: MessageId.make(id), |
| 2112 | createdBy: "user" as const, |
| 2113 | creationSource: "mobile" as const, |
| 2114 | inputIntent: "turn_start" as const, |
| 2115 | text: `Prompt ${id}`, |
| 2116 | attachments: [], |
| 2117 | }); |
| 2118 | const assistant = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ |
| 2119 | ...item(id, itemRunId, ordinal, second), |
| 2120 | type: "assistant_message" as const, |
| 2121 | messageId: MessageId.make(id), |
| 2122 | text: `Answer ${id}`, |
| 2123 | streaming: false, |
| 2124 | }); |
| 2125 | const reasoning = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({ |
| 2126 | ...item(id, itemRunId, ordinal, second), |
| 2127 | type: "reasoning" as const, |
| 2128 | text: "Thinking", |
| 2129 | streaming: false, |
| 2130 | }); |
| 2131 | const items: OrchestrationV2TurnItem[] = [ |
| 2132 | user("prompt-1", runId, 1_000_001, 0), |
| 2133 | reasoning("reasoning-1", runId, 1_000_002, 1), |
| 2134 | { |
| 2135 | ...item("subagent", run2, 1_000_003, 2), |
| 2136 | type: "subagent", |
| 2137 | subagentId: NodeId.make("subagent"), |
| 2138 | origin: "provider_native", |
| 2139 | driver: ProviderDriverKind.make("claudeAgent"), |
| 2140 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), |
| 2141 | childThreadId: null, |
| 2142 | prompt: "Research the bug", |
| 2143 | result: "Found it", |
| 2144 | }, |
| 2145 | assistant("answer-1", runId, 1_000_004, 3), |
| 2146 | user("prompt-2", run2, 2_000_001, 30), |
| 2147 | reasoning("reasoning-2", run2, 2_000_002, 31), |
| 2148 | assistant("answer-2", run2, 2_000_003, 32), |
| 2149 | { |
| 2150 | ...item("send-message", run2, 2_000_004, 33), |
| 2151 | type: "dynamic_tool", |
| 2152 | toolName: "SendMessage", |
| 2153 | input: { to: "subagent" }, |
| 2154 | output: "Sent", |
| 2155 | }, |
| 2156 | assistant("closing-2", run2, 2_000_005, 41), |
| 2157 | ]; |
| 2158 | |
| 2159 | it("keeps each turn's fold between its own prompt and answer", () => { |
| 2160 | const presented = deriveThreadFeedPresentation( |
| 2161 | buildThreadFeed(items.map((entry, index) => projected(entry, index))), |
| 2162 | { runId: run2, status: "completed", startedAt: at(30), completedAt: at(41) }, |
| 2163 | new Set(), |
| 2164 | ); |
| 2165 | expect(presented.map((entry) => entry.id)).toEqual([ |
| 2166 | "prompt-1", |
| 2167 | `run-fold:${runId}`, |
| 2168 | "answer-1", |
| 2169 | "prompt-2", |
| 2170 | `run-fold:${run2}`, |
| 2171 | "answer-2", |
| 2172 | "closing-2", |
| 2173 | ]); |
| 2174 | }); |
| 2175 | |
| 2176 | it("keeps a wake run's work open when the user steers it", () => { |
| 2177 | const wakeRun = RunId.make("wake-run"); |
| 2178 | const wakeItems: OrchestrationV2TurnItem[] = [ |
| 2179 | user("prompt-1", runId, 1_000_001, 0), |
| 2180 | reasoning("reasoning-1", runId, 1_000_002, 1), |
| 2181 | assistant("answer-1", runId, 1_000_003, 3), |
| 2182 | { |
| 2183 | ...item("wake", wakeRun, 2_000_001, 30), |
| 2184 | type: "notification", |
| 2185 | source: { kind: "background_task" }, |
| 2186 | outcome: "completed", |
| 2187 | summary: "Background task finished", |
| 2188 | }, |
| 2189 | { |
| 2190 | ...item("wake-command", wakeRun, 2_000_002, 31), |
| 2191 | type: "command_execution", |
| 2192 | input: "vp test", |
| 2193 | output: "ok", |
| 2194 | exitCode: 0, |
| 2195 | }, |
| 2196 | { ...user("steer", wakeRun, 2_000_003, 32), inputIntent: "steer" }, |
| 2197 | ]; |
| 2198 | const presented = deriveThreadFeedPresentation( |
| 2199 | buildThreadFeed(wakeItems.map((entry, index) => projected(entry, index))), |
| 2200 | { runId: wakeRun, status: "running", startedAt: at(30), completedAt: null }, |
| 2201 | new Set(), |
| 2202 | new Set(), |
| 2203 | at(30), |
| 2204 | ); |
| 2205 | expect(presented.map((entry) => entry.id)).toEqual([ |
| 2206 | "prompt-1", |
| 2207 | `run-fold:${runId}`, |
| 2208 | "answer-1", |
| 2209 | "local:thread-1:wake", |
| 2210 | "work-toggle:work-group:local:thread-1:wake-command", |
| 2211 | "steer", |
| 2212 | "live-activity-row", |
| 2213 | ]); |
| 2214 | }); |
| 2215 | }); |
| 2216 | |
| 2217 | describe("pending user input answers", () => { |
| 2218 | it("preserves exact editor text, including a deliberately cleared answer", () => { |
| 2219 | const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; |
| 2220 | expect( |
| 2221 | buildPendingUserInputAnswers([question], { |
| 2222 | runtime: { customAnswer: question.initialAnswer }, |
| 2223 | }), |
| 2224 | ).toEqual({ runtime: question.initialAnswer }); |
| 2225 | expect( |
| 2226 | buildPendingUserInputAnswers([question], { |
| 2227 | runtime: { customAnswer: " Edited message\n\n" }, |
| 2228 | }), |
| 2229 | ).toEqual({ runtime: " Edited message\n\n" }); |
| 2230 | expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ |
| 2231 | runtime: "", |
| 2232 | }); |
| 2233 | expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ |
| 2234 | runtime: " \n", |
| 2235 | }); |
| 2236 | expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( |
| 2237 | { customAnswer: "" }, |
| 2238 | ); |
| 2239 | }); |
| 2240 | |
| 2241 | it("replaces single-select options and toggles multi-select options", () => { |
| 2242 | expect( |
| 2243 | togglePendingUserInputOptionSelection( |
| 2244 | singleSelectQuestion, |
| 2245 | { selectedOptionValues: ["Go"] }, |
| 2246 | "Node.js", |
| 2247 | ), |
| 2248 | ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] }); |
| 2249 | |
| 2250 | const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); |
| 2251 | const ordersAndListings = togglePendingUserInputOptionSelection( |
| 2252 | multiSelectQuestion, |
| 2253 | orders, |
| 2254 | "Listings", |
| 2255 | ); |
| 2256 | expect(ordersAndListings).toEqual({ |
| 2257 | customAnswer: "", |
| 2258 | selectedOptionValues: ["Orders", "Listings"], |
| 2259 | }); |
| 2260 | expect( |
| 2261 | togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), |
| 2262 | ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] }); |
| 2263 | |
| 2264 | const paddedOrders = togglePendingUserInputOptionSelection( |
| 2265 | multiSelectQuestion, |
| 2266 | undefined, |
| 2267 | " Orders ", |
| 2268 | ); |
| 2269 | expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); |
| 2270 | expect( |
| 2271 | togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), |
| 2272 | ).toEqual({ customAnswer: "" }); |
| 2273 | }); |
| 2274 | |
| 2275 | it("builds array answers for multi-select questions", () => { |
| 2276 | expect( |
| 2277 | buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { |
| 2278 | runtime: { selectedOptionValues: ["Go"] }, |
| 2279 | scope: { selectedOptionValues: ["Orders", "Listings"] }, |
| 2280 | }), |
| 2281 | ).toEqual({ |
| 2282 | runtime: "Go", |
| 2283 | scope: ["Orders", "Listings"], |
| 2284 | }); |
| 2285 | }); |
| 2286 | |
| 2287 | it("clears selected options while a custom answer is active", () => { |
| 2288 | expect( |
| 2289 | setPendingUserInputCustomAnswer( |
| 2290 | multiSelectQuestion, |
| 2291 | { selectedOptionValues: ["Orders", "Listings"] }, |
| 2292 | "Orders first", |
| 2293 | ), |
| 2294 | ).toEqual({ customAnswer: "Orders first" }); |
| 2295 | }); |
| 2296 | |
| 2297 | it("matches selected chips against normalized option labels", () => { |
| 2298 | expect( |
| 2299 | isPendingUserInputOptionSelected( |
| 2300 | multiSelectQuestion, |
| 2301 | { selectedOptionValues: ["Orders"] }, |
| 2302 | " Orders ", |
| 2303 | ), |
| 2304 | ).toBe(true); |
| 2305 | expect( |
| 2306 | isPendingUserInputOptionSelected( |
| 2307 | multiSelectQuestion, |
| 2308 | { selectedOptionValues: ["Orders"], customAnswer: "Orders first" }, |
| 2309 | " Orders ", |
| 2310 | ), |
| 2311 | ).toBe(false); |
| 2312 | }); |
| 2313 | }); |
| 2314 | |
| 2315 | describe("provider question values", () => { |
| 2316 | const question = { |
| 2317 | ...singleSelectQuestion, |
| 2318 | allowCustomAnswer: false, |
| 2319 | options: [ |
| 2320 | { label: "Same label", value: " exact first ", description: "First" }, |
| 2321 | { label: "Same label", value: "second", description: "Second" }, |
| 2322 | ], |
| 2323 | } as const; |
| 2324 | |
| 2325 | it("submits raw option values and distinguishes duplicate labels", () => { |
| 2326 | const first = togglePendingUserInputOptionSelection(question, undefined, " exact first "); |
| 2327 | expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true); |
| 2328 | expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false); |
| 2329 | expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({ |
| 2330 | runtime: " exact first ", |
| 2331 | }); |
| 2332 | expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first); |
| 2333 | }); |
| 2334 | |
| 2335 | it("rejects arbitrary text when the provider only accepts offered options", () => { |
| 2336 | expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({}); |
| 2337 | expect( |
| 2338 | buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }), |
| 2339 | ).toBeNull(); |
| 2340 | expect( |
| 2341 | buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }), |
| 2342 | ).toBeNull(); |
| 2343 | expect( |
| 2344 | buildPendingUserInputAnswers([question], { |
| 2345 | runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, |
| 2346 | }), |
| 2347 | ).toEqual({ runtime: "second" }); |
| 2348 | }); |
| 2349 | }); |
| 2350 | |
| 2351 | it("accepts ready attachment-only answers while preserving selected options", () => { |
| 2352 | const question = { |
| 2353 | id: "q", |
| 2354 | header: "Spec", |
| 2355 | question: "Provide a specification", |
| 2356 | options: [{ label: "Yes", description: "Approve" }], |
| 2357 | multiSelect: false, |
| 2358 | }; |
| 2359 | expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({ |
| 2360 | q: "", |
| 2361 | }); |
| 2362 | expect( |
| 2363 | buildPendingUserInputAnswers([question], { |
| 2364 | q: { attachmentCount: 1, selectedOptionValues: ["Yes"] }, |
| 2365 | }), |
| 2366 | ).toEqual({ q: "Yes" }); |
| 2367 | expect( |
| 2368 | buildPendingUserInputAnswers([question], { |
| 2369 | q: { attachmentCount: 1, attachmentsBlocked: true }, |
| 2370 | }), |
| 2371 | ).toBeNull(); |
| 2372 | expect( |
| 2373 | buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], { |
| 2374 | q: { attachmentCount: 1 }, |
| 2375 | }), |
| 2376 | ).toBeNull(); |
| 2377 | }); |
| 2378 | |
| 2379 | it("makes attachment-only question answers expandable in the mobile feed", () => { |
| 2380 | const answer = { |
| 2381 | requestId: RuntimeRequestId.make("question-request"), |
| 2382 | answers: { q: "" }, |
| 2383 | questionTextById: { q: "Attach the specification" }, |
| 2384 | attachmentsByQuestionId: { |
| 2385 | q: [ |
| 2386 | { |
| 2387 | type: "file" as const, |
| 2388 | id: "question-file", |
| 2389 | name: "spec.txt", |
| 2390 | mimeType: "text/plain", |
| 2391 | sizeBytes: 4, |
| 2392 | }, |
| 2393 | ], |
| 2394 | }, |
| 2395 | }; |
| 2396 | const [group] = buildThreadFeed([ |
| 2397 | projected( |
| 2398 | { |
| 2399 | ...base("answer-history", "2026-09-08T00:00:00.000Z", 0), |
| 2400 | type: "user_input_request", |
| 2401 | requestId: answer.requestId, |
| 2402 | questions: [], |
| 2403 | questionAnswer: answer, |
| 2404 | }, |
| 2405 | 0, |
| 2406 | ), |
| 2407 | ]); |
| 2408 | expect(group?.type).toBe("activity-group"); |
| 2409 | if (group?.type !== "activity-group") return; |
| 2410 | expect(group.activities[0]).toMatchObject({ |
| 2411 | canExpand: true, |
| 2412 | workEntry: { questionAnswer: answer }, |
| 2413 | }); |
| 2414 | expect(group.activities[0]?.getFullDetail()).toContain("spec.txt"); |
| 2415 | }); |
| 2416 | |
| 2417 | it("renders automatic completion as a neutral activity while retaining its details", () => { |
| 2418 | const item = { |
| 2419 | ...base("notification", "2026-06-20T00:00:01.000Z", 0), |
| 2420 | type: "notification" as const, |
| 2421 | source: { kind: "monitor" as const }, |
| 2422 | outcome: "updated" as const, |
| 2423 | summary: "Monitor reported an update", |
| 2424 | detail: "Build checks changed", |
| 2425 | }; |
| 2426 | const feed = buildThreadFeed([ |
| 2427 | projected(item, 0), |
| 2428 | projected(command(), 1), |
| 2429 | projected(assistantMessage(), 2), |
| 2430 | ]); |
| 2431 | expect(feed[0]?.type).toBe("activity-group"); |
| 2432 | if (feed[0]?.type !== "activity-group") throw new Error("Expected notification activity"); |
| 2433 | const activity = feed[0].activities[0]!; |
| 2434 | expect(activity.summary).toBe("Monitor reported an update"); |
| 2435 | expect(activity.detail).toBeNull(); |
| 2436 | expect(activity.status).toBeNull(); |
| 2437 | expect(activity.getFullDetail()).toContain(item.detail); |
| 2438 | const presented = deriveThreadFeedPresentation( |
| 2439 | feed, |
| 2440 | { |
| 2441 | runId, |
| 2442 | status: "completed", |
| 2443 | startedAt: "2026-06-20T00:00:01.000Z", |
| 2444 | completedAt: "2026-06-20T00:00:03.000Z", |
| 2445 | }, |
| 2446 | new Set(), |
| 2447 | ); |
| 2448 | expect( |
| 2449 | presented.some( |
| 2450 | (entry) => |
| 2451 | entry.type === "activity-group" && |
| 2452 | entry.activities.some((activity) => activity.summary === "Monitor reported an update"), |
| 2453 | ), |
| 2454 | ).toBe(true); |
| 2455 | expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message"); |
| 2456 | }); |
| 2457 | |
| 2458 | it("uses a compact reasoning preview and a short expanded heading", () => { |
| 2459 | const entry = { |
| 2460 | id: "thought", |
| 2461 | label: "Thinking", |
| 2462 | createdAt: "2026-09-17T12:00:00Z", |
| 2463 | itemType: "reasoning" as const, |
| 2464 | tone: "thinking" as const, |
| 2465 | detail: "Check **ordering**.\nThen run the test.", |
| 2466 | toolLifecycleStatus: "inProgress" as const, |
| 2467 | }; |
| 2468 | expect(workEntryRowLabel(entry)).toBe("Check **ordering**. Then run the test."); |
| 2469 | expect(workEntryRowLabel(entry, true)).toBe("Thinking"); |
| 2470 | expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought"); |
| 2471 | }); |
| 2472 | |
| 2473 | it("keeps search output in expanded details rather than the compact label", () => { |
| 2474 | const entry = { |
| 2475 | id: "search", |
| 2476 | label: "Grep", |
| 2477 | toolTitle: "Grep", |
| 2478 | createdAt: "2026-09-17T12:00:00Z", |
| 2479 | itemType: "dynamic_tool" as const, |
| 2480 | tone: "tool" as const, |
| 2481 | detail: "---\nfile body", |
| 2482 | toolData: {}, |
| 2483 | }; |
| 2484 | expect(workEntryRowLabel(entry)).toBe("Grep"); |
| 2485 | expect(workEntryRowLabel(entry, true)).toBe("---\nfile body"); |
| 2486 | }); |
| 2487 | |
| 2488 | it.each(["First paragraph.\n\nSecond paragraph.", ""])( |
| 2489 | "previews live reasoning text %j", |
| 2490 | (text) => { |
| 2491 | const thought: OrchestrationV2TurnItem = { |
| 2492 | ...base("live-thought", "2026-06-20T00:00:02.000Z", 1), |
| 2493 | type: "reasoning", |
| 2494 | status: "running", |
| 2495 | completedAt: null, |
| 2496 | streaming: true, |
| 2497 | text, |
| 2498 | }; |
| 2499 | const feed = buildThreadFeed([projected(userMessage(), 0), projected(thought, 1)]); |
| 2500 | const rows = deriveThreadFeedPresentation( |
| 2501 | feed, |
| 2502 | { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null }, |
| 2503 | new Set(), |
| 2504 | new Set(), |
| 2505 | "2026-06-20T00:00:01.000Z", |
| 2506 | ); |
| 2507 | if (text) { |
| 2508 | expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ |
| 2509 | summary: "Thinking", |
| 2510 | thought: "First paragraph.", |
| 2511 | live: true, |
| 2512 | }); |
| 2513 | } else { |
| 2514 | expect( |
| 2515 | rows.some( |
| 2516 | (row) => |
| 2517 | row.type === "thinking" || (row.type === "work-toggle" && row.summary === "Thinking"), |
| 2518 | ), |
| 2519 | ).toBe(true); |
| 2520 | } |
| 2521 | }, |
| 2522 | ); |
| 2523 | |
| 2524 | it("keeps the latest thought under the live tool status", () => { |
| 2525 | const at = "2026-06-20T00:00:02.000Z"; |
| 2526 | const thought: OrchestrationV2TurnItem = { |
| 2527 | ...base("found-thought", at, 1), |
| 2528 | type: "reasoning", |
| 2529 | status: "completed", |
| 2530 | streaming: false, |
| 2531 | text: "Found the cause: no commits yet. Checking the UI next.", |
| 2532 | }; |
| 2533 | const tool = { ...command(at), status: "running" as const, completedAt: null }; |
| 2534 | const rows = deriveThreadFeedPresentation( |
| 2535 | buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), |
| 2536 | { runId, status: "running", startedAt: at, completedAt: null }, |
| 2537 | new Set(), |
| 2538 | new Set(), |
| 2539 | at, |
| 2540 | ); |
| 2541 | expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ |
| 2542 | thought: "Found the cause: no commits yet.", |
| 2543 | live: true, |
| 2544 | }); |
| 2545 | expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ |
| 2546 | summary: "Thinking", |
| 2547 | }); |
| 2548 | }); |
| 2549 | |
| 2550 | it("stops stranded thinking after a steer and follows the next thought or tool", () => { |
| 2551 | const at = "2026-06-20T00:00:02.000Z"; |
| 2552 | const thought = (id: string): OrchestrationV2TurnItem => ({ |
| 2553 | ...base(id, at, 1), |
| 2554 | type: "reasoning", |
| 2555 | status: "running", |
| 2556 | completedAt: null, |
| 2557 | streaming: true, |
| 2558 | text: id, |
| 2559 | }); |
| 2560 | const first = thought("first-thought"); |
| 2561 | const next = thought("next-thought"); |
| 2562 | const steer = { ...userMessage(at), inputIntent: "steer" as const }; |
| 2563 | const tool = { ...command(at), status: "running" as const, completedAt: null }; |
| 2564 | const rows = (items: ReadonlyArray<OrchestrationV2TurnItem>, expanded = new Set<string>()) => |
| 2565 | deriveThreadFeedPresentation( |
| 2566 | buildThreadFeed(items.map((item, position) => projected(item, position))), |
| 2567 | { runId, status: "running", startedAt: at, completedAt: null }, |
| 2568 | new Set(), |
| 2569 | expanded, |
| 2570 | at, |
| 2571 | ); |
| 2572 | expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({ |
| 2573 | summary: "Thinking", |
| 2574 | thought: "first-thought", |
| 2575 | live: true, |
| 2576 | shimmer: true, |
| 2577 | }); |
| 2578 | const afterSteer = rows([first, steer]); |
| 2579 | expect(afterSteer.find((row) => row.type === "work-toggle")).toMatchObject({ |
| 2580 | live: false, |
| 2581 | shimmer: false, |
| 2582 | }); |
| 2583 | expect(afterSteer.at(-1)?.type).toBe("thinking"); |
| 2584 | const header = afterSteer.find((row) => row.type === "work-toggle"); |
| 2585 | if (header?.type !== "work-toggle") throw new Error("Expected thought toggle"); |
| 2586 | const expanded = rows([first, steer], new Set([header.groupId])); |
| 2587 | expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({ summary: "Thought" }); |
| 2588 | expect(expanded.find((row) => row.type === "activity-group")).toMatchObject({ |
| 2589 | activities: [{ lifecycleStatus: "completed", workEntry: { toolLifecycleStatus: "completed" } }], |
| 2590 | }); |
| 2591 | for (const items of [ |
| 2592 | [first, steer, next], |
| 2593 | [first, next], |
| 2594 | [first, steer, next, tool], |
| 2595 | ]) { |
| 2596 | const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer); |
| 2597 | expect(live).toHaveLength(1); |
| 2598 | expect(live[0]).toMatchObject( |
| 2599 | items.at(-1)!.type === "reasoning" |
| 2600 | ? { summary: "Thinking", thought: "next-thought" } |
| 2601 | : { summary: "Running vp" }, |
| 2602 | ); |
| 2603 | } |
| 2604 | expect(first.status).toBe("running"); |
| 2605 | }); |
| 2606 | |
| 2607 | it("previews a settled thought in its collapsed header and labels its expanded header", () => { |
| 2608 | const thought: OrchestrationV2TurnItem = { |
| 2609 | ...base("thought-preview", "2026-06-20T00:00:02.000Z", 1), |
| 2610 | type: "reasoning", |
| 2611 | streaming: false, |
| 2612 | text: "First paragraph.\n\nSecond paragraph.", |
| 2613 | }; |
| 2614 | const feed = buildThreadFeed([ |
| 2615 | projected(userMessage(), 0), |
| 2616 | projected(thought, 1), |
| 2617 | projected(assistantMessage(), 2), |
| 2618 | ]); |
| 2619 | const run = { |
| 2620 | runId, |
| 2621 | status: "completed" as const, |
| 2622 | startedAt: "2026-06-20T00:00:01.000Z", |
| 2623 | completedAt: "2026-06-20T00:00:03.000Z", |
| 2624 | }; |
| 2625 | const collapsed = deriveThreadFeedPresentation(feed, run, new Set([runId])); |
| 2626 | const header = collapsed.find((row) => row.type === "work-toggle"); |
| 2627 | expect(header).toMatchObject({ summary: "First paragraph. Second paragraph." }); |
| 2628 | if (header?.type !== "work-toggle") throw new Error("Expected thought toggle"); |
| 2629 | const expanded = deriveThreadFeedPresentation( |
| 2630 | feed, |
| 2631 | run, |
| 2632 | new Set([runId]), |
| 2633 | new Set([header.groupId]), |
| 2634 | ); |
| 2635 | expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({ |
| 2636 | summary: "Thought", |
| 2637 | continuesWorkLog: true, |
| 2638 | }); |
| 2639 | const detail = expanded.find((row) => row.type === "activity-group"); |
| 2640 | expect(detail?.continuesWorkLog).toBeUndefined(); |
| 2641 | if (detail?.type !== "activity-group") throw new Error("Expected full thought"); |
| 2642 | expect(detail.activities[0]?.detail).toBe(thought.text); |
| 2643 | }); |
| 2644 | |
| 2645 | it.each(["provider_error", "usage_limit"] as const)( |
| 2646 | "keeps a historical %s failure and preceding work visible without disclosures", |
| 2647 | (failureClass) => { |
| 2648 | const at = "2026-06-20T00:00:03.000Z"; |
| 2649 | const error: OrchestrationV2TurnItem = { |
| 2650 | ...base("failure", at, 2), |
| 2651 | type: "error", |
| 2652 | status: "failed", |
| 2653 | failure: { |
| 2654 | class: failureClass, |
| 2655 | message: "The provider stopped this turn.\nRetry later.", |
| 2656 | code: null, |
| 2657 | retryable: true, |
| 2658 | }, |
| 2659 | }; |
| 2660 | const command: OrchestrationV2TurnItem = { |
| 2661 | ...base("command", "2026-06-20T00:00:02.000Z", 1), |
| 2662 | type: "command_execution", |
| 2663 | input: "pwd", |
| 2664 | output: "", |
| 2665 | exitCode: 0, |
| 2666 | }; |
| 2667 | const sourceFeed = buildThreadFeed([ |
| 2668 | projected(userMessage(), 0), |
| 2669 | projected(command, 1), |
| 2670 | projected(error, 2), |
| 2671 | ]); |
| 2672 | const feed = deriveThreadFeedPresentation( |
| 2673 | sourceFeed, |
| 2674 | { runId: RunId.make("newer-run"), status: "completed", startedAt: at, completedAt: at }, |
| 2675 | new Set(), |
| 2676 | ); |
| 2677 | const whileWorking = deriveThreadFeedPresentation( |
| 2678 | sourceFeed, |
| 2679 | { runId: RunId.make("newer-run"), status: "running", startedAt: at, completedAt: null }, |
| 2680 | new Set(), |
| 2681 | ); |
| 2682 | for (const entry of feed) { |
| 2683 | expect(whileWorking.find((row) => row.id === entry.id)).toBe(entry); |
| 2684 | } |
| 2685 | expect(feed.some((entry) => entry.type === "run-fold" || entry.type === "work-toggle")).toBe( |
| 2686 | false, |
| 2687 | ); |
| 2688 | const activities = feed.flatMap((entry) => |
| 2689 | entry.type === "activity-group" ? entry.activities : [], |
| 2690 | ); |
| 2691 | expect(activities.map((activity) => activity.projectedItem.item.id)).toEqual([ |
| 2692 | "command", |
| 2693 | "failure", |
| 2694 | ]); |
| 2695 | expect(activities.at(-1)).toMatchObject({ |
| 2696 | detail: error.failure.message, |
| 2697 | createdAt: at, |
| 2698 | canExpand: false, |
| 2699 | prominent: true, |
| 2700 | }); |
| 2701 | }, |
| 2702 | ); |
| 2703 | |
| 2704 | describe("html renders", () => { |
| 2705 | const page = { attachmentId: "attachment-page", title: "Revenue", height: 320 }; |
| 2706 | const renderCall = ( |
| 2707 | overrides: Partial<Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>> = {}, |
| 2708 | ): OrchestrationV2TurnItem => ({ |
| 2709 | ...base("item-render", "2026-06-20T00:00:02.500Z", 2), |
| 2710 | type: "dynamic_tool", |
| 2711 | toolName: "mcp__t3-code__html_render", |
| 2712 | input: { title: page.title }, |
| 2713 | output: { htmlRender: page }, |
| 2714 | ...overrides, |
| 2715 | }); |
| 2716 | const laterCommand = { |
| 2717 | ...command("2026-06-20T00:00:02.800Z"), |
| 2718 | id: TurnItemId.make("item-command-later"), |
| 2719 | ordinal: 3, |
| 2720 | }; |
| 2721 | const feed = () => |
| 2722 | buildThreadFeed([ |
| 2723 | projected(userMessage(), 0), |
| 2724 | projected(command(), 1), |
| 2725 | projected(renderCall(), 2), |
| 2726 | projected(laterCommand, 3), |
| 2727 | projected(assistantMessage("2026-06-20T00:00:04.000Z"), 4), |
| 2728 | ]); |
| 2729 | const latestRun = { |
| 2730 | runId, |
| 2731 | status: "completed" as const, |
| 2732 | startedAt: "2026-06-20T00:00:01.000Z", |
| 2733 | completedAt: "2026-06-20T00:00:04.000Z", |
| 2734 | }; |
| 2735 | |
| 2736 | it("shows a completed render in place, outside the work log", () => { |
| 2737 | const expanded = deriveThreadFeedPresentation(feed(), latestRun, new Set([runId])); |
| 2738 | expect(expanded.map((entry) => entry.type)).toEqual([ |
| 2739 | "message", |
| 2740 | "run-fold", |
| 2741 | "work-toggle", |
| 2742 | "html-render", |
| 2743 | "work-toggle", |
| 2744 | "message", |
| 2745 | ]); |
| 2746 | expect(expanded[3]).toMatchObject({ type: "html-render", render: page, runId }); |
| 2747 | expect(expanded[2]?.continuesWorkLog).toBeUndefined(); |
| 2748 | }); |
| 2749 | |
| 2750 | it("keeps a render visible and in order when its run folds", () => { |
| 2751 | const collapsed = deriveThreadFeedPresentation(feed(), latestRun, new Set()); |
| 2752 | expect(collapsed.map((entry) => entry.type)).toEqual([ |
| 2753 | "message", |
| 2754 | "run-fold", |
| 2755 | "html-render", |
| 2756 | "message", |
| 2757 | ]); |
| 2758 | }); |
| 2759 | |
| 2760 | it("leaves running, failed and errored renders in the work log", () => { |
| 2761 | for (const call of [ |
| 2762 | renderCall({ status: "running", output: null }), |
| 2763 | renderCall({ status: "failed" }), |
| 2764 | renderCall({ output: { isError: true, htmlRender: page } }), |
| 2765 | ]) { |
| 2766 | const entries = buildThreadFeed([projected(call, 0)]); |
| 2767 | expect(entries.map((entry) => entry.type)).toEqual(["activity-group"]); |
| 2768 | } |
| 2769 | }); |
| 2770 | }); |
| 2771 | |
| 2772 | describe("MCP apps", () => { |
| 2773 | const app = { |
| 2774 | attachmentId: "attachment-app", |
| 2775 | server: "weather", |
| 2776 | tool: "get_weather", |
| 2777 | resourceUri: "ui://weather/dashboard", |
| 2778 | }; |
| 2779 | const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({ |
| 2780 | ...base("item-app", "2026-06-20T00:00:02.500Z", 2), |
| 2781 | status, |
| 2782 | type: "dynamic_tool", |
| 2783 | toolName: "weather.get_weather", |
| 2784 | input: { city: "Oslo" }, |
| 2785 | output: { t3McpApp: app, result: { content: [] } }, |
| 2786 | }); |
| 2787 | const feed = (status: OrchestrationV2TurnItem["status"]) => |
| 2788 | buildThreadFeed([ |
| 2789 | projected(userMessage(), 0), |
| 2790 | projected(appCall(status), 1), |
| 2791 | projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2), |
| 2792 | ]); |
| 2793 | |
| 2794 | it("hosts a completed app in place of its tool row, owned by its source item", () => { |
| 2795 | const entry = feed("completed").find((candidate) => candidate.type === "mcp-app"); |
| 2796 | expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId }); |
| 2797 | }); |
| 2798 | |
| 2799 | it("keeps a running app call an ordinary work row", () => { |
| 2800 | expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false); |
| 2801 | }); |
| 2802 | }); |