Unified · Split
| @@ -342,11 +342,17 @@ export function projectActivityPayload( · 341 unmodified lines | ||
| 342 | 342 | return activity; |
| 343 | 343 | } |
| 344 | 344 | |
| 345 | + const itemStatus = asRecord(data.item)?.status; | |
| 346 | + const projectedPayload = | |
| 347 | + payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") | |
| 348 | + ? { ...payload, status: itemStatus } | |
| 349 | + : payload; | |
| 350 | + | |
| 345 | 351 | if (payload.itemType === "mcp_tool_call") { |
| 346 | 352 | return { |
| 347 | 353 | ...activity, |
| 348 | 354 | payload: { |
| 349 | − ...payload, | |
| 355 | + ...projectedPayload, | |
| 350 | 356 | data: projectMcpToolCallData(data), |
| 351 | 357 | }, |
| 352 | 358 | }; |
| @@ -384,7 +390,7 @@ export function projectActivityPayload( · 31 unmodified lines | ||
| 384 | 390 | return { |
| 385 | 391 | ...activity, |
| 386 | 392 | payload: { |
| 387 | − ...payload, | |
| 393 | + ...projectedPayload, | |
| 388 | 394 | data: projectedData, |
| 389 | 395 | }, |
| 390 | 396 | }; |
| @@ -614,6 +614,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 613 unmodified lines | ||
| 614 | 614 | }), |
| 615 | 615 | ); |
| 616 | 616 | |
| 617 | + it.effect("preserves failed and declined outcomes on completed tool items", () => | |
| 618 | + Effect.gen(function* () { | |
| 619 | + const { adapter, runtime } = yield* startLifecycleRuntime(); | |
| 620 | + const items = [ | |
| 621 | + { | |
| 622 | + type: "commandExecution", | |
| 623 | + id: "failed-command", | |
| 624 | + command: "vp test run", | |
| 625 | + commandActions: [], | |
| 626 | + cwd: "/tmp", | |
| 627 | + exitCode: 1, | |
| 628 | + status: "failed", | |
| 629 | + }, | |
| 630 | + { | |
| 631 | + type: "mcpToolCall", | |
| 632 | + id: "failed-mcp", | |
| 633 | + server: "simulator", | |
| 634 | + tool: "build", | |
| 635 | + arguments: {}, | |
| 636 | + error: { message: "Build failed" }, | |
| 637 | + status: "failed", | |
| 638 | + }, | |
| 639 | + { | |
| 640 | + type: "fileChange", | |
| 641 | + id: "declined-change", | |
| 642 | + changes: [], | |
| 643 | + status: "declined", | |
| 644 | + }, | |
| 645 | + ] as const; | |
| 646 | + | |
| 647 | + for (const item of items) { | |
| 648 | + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | |
| 649 | + | |
| 650 | + yield* runtime.emit({ | |
| 651 | + id: asEventId(`evt-${item.id}`), | |
| 652 | + kind: "notification", | |
| 653 | + provider: ProviderDriverKind.make("codex"), | |
| 654 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 655 | + method: "item/completed", | |
| 656 | + threadId: asThreadId("thread-1"), | |
| 657 | + turnId: asTurnId("turn-1"), | |
| 658 | + itemId: asItemId(item.id), | |
| 659 | + payload: { | |
| 660 | + completedAtMs: 1_778_000_000_000, | |
| 661 | + threadId: "thread-1", | |
| 662 | + turnId: "turn-1", | |
| 663 | + item, | |
| 664 | + }, | |
| 665 | + }); | |
| 666 | + | |
| 667 | + const firstEvent = yield* Fiber.join(firstEventFiber); | |
| 668 | + NodeAssert.equal(firstEvent._tag, "Some"); | |
| 669 | + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { | |
| 670 | + return; | |
| 671 | + } | |
| 672 | + NodeAssert.equal(firstEvent.value.payload.status, item.status); | |
| 673 | + } | |
| 674 | + }), | |
| 675 | + ); | |
| 676 | + | |
| 617 | 677 | it.effect("maps completed plan items to canonical proposed-plan completion events", () => |
| 618 | 678 | Effect.gen(function* () { |
| 619 | 679 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
| @@ -480,7 +480,9 @@ function mapItemLifecycle( · 479 unmodified lines | ||
| 480 | 480 | lifecycle === "item.started" |
| 481 | 481 | ? "inProgress" |
| 482 | 482 | : lifecycle === "item.completed" |
| 483 | − ? "completed" | |
| 483 | + ? "status" in item && (item.status === "failed" || item.status === "declined") | |
| 484 | + ? item.status | |
| 485 | + : "completed" | |
| 484 | 486 | : undefined; |
| 485 | 487 | |
| 486 | 488 | return { |
| @@ -220,6 +220,40 @@ describe("projectActivityPayload", () => { · 219 unmodified lines | ||
| 220 | 220 | } |
| 221 | 221 | }); |
| 222 | 222 | |
| 223 | + it("preserves failed stored tool outcomes for web and mobile clients", () => { | |
| 224 | + const activities = [ | |
| 225 | + makeActivity("failed-command", "command_execution", { | |
| 226 | + item: { | |
| 227 | + command: "vp test run", | |
| 228 | + exitCode: 1, | |
| 229 | + status: "failed", | |
| 230 | + }, | |
| 231 | + }), | |
| 232 | + makeActivity("failed-mcp", "mcp_tool_call", { | |
| 233 | + item: { | |
| 234 | + server: "simulator", | |
| 235 | + tool: "build", | |
| 236 | + arguments: {}, | |
| 237 | + status: "failed", | |
| 238 | + }, | |
| 239 | + }), | |
| 240 | + ]; | |
| 241 | + | |
| 242 | + for (const activity of activities) { | |
| 243 | + const projected = projectActivityPayload(activity); | |
| 244 | + expect(projected.payload).toMatchObject({ status: "failed" }); | |
| 245 | + | |
| 246 | + const [webEntry] = deriveWorkLogEntries([projected]); | |
| 247 | + expect(webEntry?.toolLifecycleStatus).toBe("failed"); | |
| 248 | + | |
| 249 | + const [mobileGroup] = buildThreadFeed(makeThread([projected])); | |
| 250 | + expect(mobileGroup).toMatchObject({ type: "activity-group" }); | |
| 251 | + if (mobileGroup?.type === "activity-group") { | |
| 252 | + expect(mobileGroup.activities[0]?.status).toBe("failure"); | |
| 253 | + } | |
| 254 | + } | |
| 255 | + }); | |
| 256 | + | |
| 223 | 257 | it("projects snapshot and event transports without mutating their sources", () => { |
| 224 | 258 | const activity = fixtures[0]!; |
| 225 | 259 | const thread = makeThread([activity]); |
| @@ -1374,6 +1374,91 @@ describe("deriveMessagesTimelineRows", () => { · 1373 unmodified lines | ||
| 1374 | 1374 | expanded: true, |
| 1375 | 1375 | }); |
| 1376 | 1376 | }); |
| 1377 | + | |
| 1378 | + it.each([ | |
| 1379 | + ["recovered", ["failed", "completed"], false], | |
| 1380 | + ["ending in failure", ["completed", "failed"], true], | |
| 1381 | + ["failed", ["failed", "failed"], true], | |
| 1382 | + ] as const)("uses the final call for %s tool groups", (_, statuses, hasFailure) => { | |
| 1383 | + const timelineEntries = statuses.map((status, index) => ({ | |
| 1384 | + id: `work-entry-${index}`, | |
| 1385 | + kind: "work" as const, | |
| 1386 | + createdAt: `2026-01-01T00:00:0${index}Z`, | |
| 1387 | + entry: { | |
| 1388 | + id: `work-${index}`, | |
| 1389 | + createdAt: `2026-01-01T00:00:0${index}Z`, | |
| 1390 | + label: "Ran command", | |
| 1391 | + tone: "tool" as const, | |
| 1392 | + itemType: "command_execution" as const, | |
| 1393 | + toolLifecycleStatus: status, | |
| 1394 | + }, | |
| 1395 | + })); | |
| 1396 | + | |
| 1397 | + const rows = deriveMessagesTimelineRows({ | |
| 1398 | + timelineEntries, | |
| 1399 | + isWorking: false, | |
| 1400 | + activeTurnStartedAt: null, | |
| 1401 | + turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1402 | + revertTurnCountByUserMessageId: new Map(), | |
| 1403 | + }); | |
| 1404 | + | |
| 1405 | + expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ | |
| 1406 | + hiddenCount: 2, | |
| 1407 | + hasFailure, | |
| 1408 | + }); | |
| 1409 | + }); | |
| 1410 | + | |
| 1411 | + it("keeps a failure visible when other hidden entries succeeded", () => { | |
| 1412 | + const rows = deriveMessagesTimelineRows({ | |
| 1413 | + timelineEntries: [ | |
| 1414 | + { | |
| 1415 | + id: "failed-work-entry", | |
| 1416 | + kind: "work", | |
| 1417 | + createdAt: "2026-01-01T00:00:01Z", | |
| 1418 | + entry: { | |
| 1419 | + id: "failed-work", | |
| 1420 | + createdAt: "2026-01-01T00:00:01Z", | |
| 1421 | + label: "Ran command", | |
| 1422 | + tone: "tool", | |
| 1423 | + toolLifecycleStatus: "failed", | |
| 1424 | + }, | |
| 1425 | + }, | |
| 1426 | + { | |
| 1427 | + id: "completed-work-entry", | |
| 1428 | + kind: "work", | |
| 1429 | + createdAt: "2026-01-01T00:00:02Z", | |
| 1430 | + entry: { | |
| 1431 | + id: "completed-work", | |
| 1432 | + createdAt: "2026-01-01T00:00:02Z", | |
| 1433 | + label: "Ran command", | |
| 1434 | + tone: "tool", | |
| 1435 | + toolLifecycleStatus: "completed", | |
| 1436 | + }, | |
| 1437 | + }, | |
| 1438 | + { | |
| 1439 | + id: "visible-info-entry", | |
| 1440 | + kind: "work", | |
| 1441 | + createdAt: "2026-01-01T00:00:03Z", | |
| 1442 | + entry: { | |
| 1443 | + id: "visible-info", | |
| 1444 | + createdAt: "2026-01-01T00:00:03Z", | |
| 1445 | + label: "Status updated", | |
| 1446 | + tone: "info", | |
| 1447 | + }, | |
| 1448 | + }, | |
| 1449 | + ], | |
| 1450 | + isWorking: false, | |
| 1451 | + activeTurnStartedAt: null, | |
| 1452 | + turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1453 | + revertTurnCountByUserMessageId: new Map(), | |
| 1454 | + }); | |
| 1455 | + | |
| 1456 | + expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ | |
| 1457 | + hiddenCount: 2, | |
| 1458 | + summary: null, | |
| 1459 | + hasFailure: true, | |
| 1460 | + }); | |
| 1461 | + }); | |
| 1377 | 1462 | }); |
| 1378 | 1463 | |
| 1379 | 1464 | describe("computeStableMessagesTimelineRows", () => { |
| @@ -894,9 +894,7 @@ export function deriveMessagesTimelineRows(input: { · 893 unmodified lines | ||
| 894 | 894 | onlyToolEntries: true, |
| 895 | 895 | summary: summarizeToolGroup(visibleGroupedEntries), |
| 896 | 896 | summaryKind, |
| 897 | − hasFailure: visibleGroupedEntries.some((entry) => | |
| 898 | − workEntryDisplayIndicatesToolFailure(entry), | |
| 899 | − ), | |
| 897 | + hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!), | |
| 900 | 898 | }); |
| 901 | 899 | if (expanded) { |
| 902 | 900 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| @@ -813,6 +813,45 @@ describe("MessagesTimeline", () => { · 812 unmodified lines | ||
| 813 | 813 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); |
| 814 | 814 | }); |
| 815 | 815 | |
| 816 | + it("keeps mixed-success tool groups neutral", () => { | |
| 817 | + const markup = renderToStaticMarkup( | |
| 818 | + <MessagesTimeline | |
| 819 | + {...buildProps()} | |
| 820 | + timelineEntries={[ | |
| 821 | + { | |
| 822 | + id: "entry-failed", | |
| 823 | + kind: "work", | |
| 824 | + createdAt: "2026-03-17T19:12:28.000Z", | |
| 825 | + entry: { | |
| 826 | + id: "work-failed", | |
| 827 | + createdAt: "2026-03-17T19:12:28.000Z", | |
| 828 | + label: "Run search", | |
| 829 | + tone: "tool", | |
| 830 | + itemType: "command_execution", | |
| 831 | + toolLifecycleStatus: "failed", | |
| 832 | + }, | |
| 833 | + }, | |
| 834 | + { | |
| 835 | + id: "entry-completed", | |
| 836 | + kind: "work", | |
| 837 | + createdAt: "2026-03-17T19:12:29.000Z", | |
| 838 | + entry: { | |
| 839 | + id: "work-completed", | |
| 840 | + createdAt: "2026-03-17T19:12:29.000Z", | |
| 841 | + label: "Run tests", | |
| 842 | + tone: "tool", | |
| 843 | + itemType: "command_execution", | |
| 844 | + toolLifecycleStatus: "completed", | |
| 845 | + }, | |
| 846 | + }, | |
| 847 | + ]} | |
| 848 | + />, | |
| 849 | + ); | |
| 850 | + | |
| 851 | + expect(markup).toContain("Ran 2 commands"); | |
| 852 | + expect(markup).not.toContain('aria-label="Tool call failed"'); | |
| 853 | + }); | |
| 854 | + | |
| 816 | 855 | it("shows the animated one-line label for a live tool group", () => { |
| 817 | 856 | const turnId = TurnId.make("turn-live"); |
| 818 | 857 | const markup = renderToStaticMarkup( |