authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-22 04:18:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 04:18:27-07:00
logce91284f832fc71bf46ff2a8ffc290e20db4e83d
tree8cdd9695444da2e0770d14e8b938e730530538c6
parent11f051373e79b38fa16f3ec1af825f5164907c1b
signature Signed by PGP key B5690EEEBB952194

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


7 files changed, 230 insertions(+), 6 deletions(-)

apps/server/src/orchestration/ActivityPayloadProjection.ts+8-2
......@@ -342,11 +342,17 @@ export function projectActivityPayload(
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(
384390 return {
385391 ...activity,
386392 payload: {
387 ...payload,
393 ...projectedPayload,
388394 data: projectedData,
389395 },
390396 };
apps/server/src/provider/Layers/CodexAdapter.test.ts+60
......@@ -614,6 +614,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
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(
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
......@@ -220,6 +220,40 @@ describe("projectActivityPayload", () => {
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
......@@ -1374,6 +1374,91 @@ describe("deriveMessagesTimelineRows", () => {
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
13791464describe("computeStableMessagesTimelineRows", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-3
......@@ -894,9 +894,7 @@ export function deriveMessagesTimelineRows(input: {
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
......@@ -813,6 +813,45 @@ describe("MessagesTimeline", () => {
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(