History

fix(web): stop marking mixed tool runs as failed (#7893)

Theo Browne committed 2 months agov0.0.34-nightly.20260822.1159

Commit ce912841 parent 11f05137 files · +230 −6Browse files

Unified · Split

7 files
  1. apps/server/src/orchestration/ActivityPayloadProjection.ts +8 −2
  2. apps/server/src/provider/Layers/CodexAdapter.test.ts +60 −0
  3. apps/server/src/provider/Layers/CodexAdapter.ts +3 −1
  4. apps/server/test/ActivityPayloadProjection.test.ts +34 −0
  5. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +85 −0
  6. apps/web/src/components/chat/MessagesTimeline.logic.ts +1 −3
  7. apps/web/src/components/chat/MessagesTimeline.test.tsx +39 −0
apps/server/src/orchestration/ActivityPayloadProjection.ts+8−2
@@ -342,11 +342,17 @@ export function projectActivityPayload( · 341 unmodified lines
342342 return activity;
343343 }
344344
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+
345351 if (payload.itemType === "mcp_tool_call") {
346352 return {
347353 ...activity,
348354 payload: {
349− ...payload,
355+ ...projectedPayload,
350356 data: projectMcpToolCallData(data),
351357 },
352358 };
@@ -384,7 +390,7 @@ export function projectActivityPayload( · 31 unmodified lines
384390 return {
385391 ...activity,
386392 payload: {
387− ...payload,
393+ ...projectedPayload,
388394 data: projectedData,
389395 },
390396 };
apps/server/src/provider/Layers/CodexAdapter.test.ts+60−0
@@ -614,6 +614,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 613 unmodified lines
614614 }),
615615 );
616616
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+
617677 it.effect("maps completed plan items to canonical proposed-plan completion events", () =>
618678 Effect.gen(function* () {
619679 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+3−1
@@ -480,7 +480,9 @@ function mapItemLifecycle( · 479 unmodified lines
480480 lifecycle === "item.started"
481481 ? "inProgress"
482482 : lifecycle === "item.completed"
483− ? "completed"
483+ ? "status" in item && (item.status === "failed" || item.status === "declined")
484+ ? item.status
485+ : "completed"
484486 : undefined;
485487
486488 return {
apps/server/test/ActivityPayloadProjection.test.ts+34−0
@@ -220,6 +220,40 @@ describe("projectActivityPayload", () => { · 219 unmodified lines
220220 }
221221 });
222222
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+
223257 it("projects snapshot and event transports without mutating their sources", () => {
224258 const activity = fixtures[0]!;
225259 const thread = makeThread([activity]);
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+85−0
@@ -1374,6 +1374,91 @@ describe("deriveMessagesTimelineRows", () => { · 1373 unmodified lines
13741374 expanded: true,
13751375 });
13761376 });
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+ });
13771462 });
13781463
13791464 describe("computeStableMessagesTimelineRows", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+1−3
@@ -894,9 +894,7 @@ export function deriveMessagesTimelineRows(input: { · 893 unmodified lines
894894 onlyToolEntries: true,
895895 summary: summarizeToolGroup(visibleGroupedEntries),
896896 summaryKind,
897− hasFailure: visibleGroupedEntries.some((entry) =>
898− workEntryDisplayIndicatesToolFailure(entry),
899− ),
897+ hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),
900898 });
901899 if (expanded) {
902900 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
apps/web/src/components/chat/MessagesTimeline.test.tsx+39−0
@@ -813,6 +813,45 @@ describe("MessagesTimeline", () => { · 812 unmodified lines
813813 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
814814 });
815815
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+
816855 it("shows the animated one-line label for a live tool group", () => {
817856 const turnId = TurnId.make("turn-live");
818857 const markup = renderToStaticMarkup(