| author | |
| committer | |
| log | ce91284f832fc71bf46ff2a8ffc290e20db4e83d |
| tree | 8cdd9695444da2e0770d14e8b938e730530538c6 |
| parent | 11f051373e79b38fa16f3ec1af825f5164907c1b |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 230 insertions(+), 6 deletions(-)
apps/server/src/orchestration/ActivityPayloadProjection.ts+8-2| ... | @@ -342,11 +342,17 @@ export function projectActivityPayload( | ... | @@ -342,11 +342,17 @@ export function projectActivityPayload( |
| 342 | return activity; | 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 | if (payload.itemType === "mcp_tool_call") { | 351 | if (payload.itemType === "mcp_tool_call") { |
| 346 | return { | 352 | return { |
| 347 | ...activity, | 353 | ...activity, |
| 348 | payload: { | 354 | payload: { |
| 349 | ...payload, | 355 | ...projectedPayload, |
| 350 | data: projectMcpToolCallData(data), | 356 | data: projectMcpToolCallData(data), |
| 351 | }, | 357 | }, |
| 352 | }; | 358 | }; |
| ... | @@ -384,7 +390,7 @@ export function projectActivityPayload( | ... | @@ -384,7 +390,7 @@ export function projectActivityPayload( |
| 384 | return { | 390 | return { |
| 385 | ...activity, | 391 | ...activity, |
| 386 | payload: { | 392 | payload: { |
| 387 | ...payload, | 393 | ...projectedPayload, |
| 388 | data: projectedData, | 394 | data: projectedData, |
| 389 | }, | 395 | }, |
| 390 | }; | 396 | }; |
apps/server/src/provider/Layers/CodexAdapter.test.ts+60| ... | @@ -614,6 +614,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -614,6 +614,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 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 | it.effect("maps completed plan items to canonical proposed-plan completion events", () => | 677 | it.effect("maps completed plan items to canonical proposed-plan completion events", () => |
| 618 | Effect.gen(function* () { | 678 | Effect.gen(function* () { |
| 619 | const { adapter, runtime } = yield* startLifecycleRuntime(); | 679 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+3-1| ... | @@ -480,7 +480,9 @@ function mapItemLifecycle( | ... | @@ -480,7 +480,9 @@ function mapItemLifecycle( |
| 480 | lifecycle === "item.started" | 480 | lifecycle === "item.started" |
| 481 | ? "inProgress" | 481 | ? "inProgress" |
| 482 | : lifecycle === "item.completed" | 482 | : lifecycle === "item.completed" |
| 483 | ? "completed" | 483 | ? "status" in item && (item.status === "failed" || item.status === "declined") |
| 484 | ? item.status | ||
| 485 | : "completed" | ||
| 484 | : undefined; | 486 | : undefined; |
| 485 | 487 | ||
| 486 | return { | 488 | return { |
apps/server/test/ActivityPayloadProjection.test.ts+34| ... | @@ -220,6 +220,40 @@ describe("projectActivityPayload", () => { | ... | @@ -220,6 +220,40 @@ describe("projectActivityPayload", () => { |
| 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 | it("projects snapshot and event transports without mutating their sources", () => { | 257 | it("projects snapshot and event transports without mutating their sources", () => { |
| 224 | const activity = fixtures[0]!; | 258 | const activity = fixtures[0]!; |
| 225 | const thread = makeThread([activity]); | 259 | const thread = makeThread([activity]); |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+85| ... | @@ -1374,6 +1374,91 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1374,6 +1374,91 @@ describe("deriveMessagesTimelineRows", () => { |
| 1374 | expanded: true, | 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 | describe("computeStableMessagesTimelineRows", () => { | 1464 | describe("computeStableMessagesTimelineRows", () => { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-3| ... | @@ -894,9 +894,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -894,9 +894,7 @@ export function deriveMessagesTimelineRows(input: { |
| 894 | onlyToolEntries: true, | 894 | onlyToolEntries: true, |
| 895 | summary: summarizeToolGroup(visibleGroupedEntries), | 895 | summary: summarizeToolGroup(visibleGroupedEntries), |
| 896 | summaryKind, | 896 | summaryKind, |
| 897 | hasFailure: visibleGroupedEntries.some((entry) => | 897 | hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!), |
| 898 | workEntryDisplayIndicatesToolFailure(entry), | ||
| 899 | ), | ||
| 900 | }); | 898 | }); |
| 901 | if (expanded) { | 899 | if (expanded) { |
| 902 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | 900 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+39| ... | @@ -813,6 +813,45 @@ describe("MessagesTimeline", () => { | ... | @@ -813,6 +813,45 @@ describe("MessagesTimeline", () => { |
| 813 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); | 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 | it("shows the animated one-line label for a live tool group", () => { | 855 | it("shows the animated one-line label for a live tool group", () => { |
| 817 | const turnId = TurnId.make("turn-live"); | 856 | const turnId = TurnId.make("turn-live"); |
| 818 | const markup = renderToStaticMarkup( | 857 | const markup = renderToStaticMarkup( |