Skip to content

Commit a235bd9

Browse files
authored
Merge pull request #131 from pylon-code/upstream/2026-08-28-tool-failure-severity
fix(web): stop showing red x summaries for ordinary tool failures
2 parents 7733645 + aff1b49 commit a235bd9

7 files changed

Lines changed: 264 additions & 33 deletions

File tree

apps/mobile/src/features/threads/thread-work-log.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ export function ThreadWorkLog(props: {
154154
const canExpand = row.canExpand;
155155
const fullDetail = expanded ? row.getFullDetail() : null;
156156
const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
157-
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
157+
// Warnings are not errors. Web reserves destructive red for
158+
// runtime.error and orchestration *.failed rows and paints warnings
159+
// amber; mobile matches that split rather than colouring both rose.
160+
const iconIsDestructive = row.icon === "alert";
161+
const iconIsWarning = row.icon === "warning";
158162

159163
return (
160164
<Animated.View
@@ -189,7 +193,13 @@ export function ThreadWorkLog(props: {
189193
name={workRowSymbolName(row.icon)}
190194
size={13}
191195
weight="medium"
192-
tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
196+
tintColor={
197+
iconIsDestructive
198+
? "#e11d48"
199+
: iconIsWarning
200+
? "#d97706"
201+
: props.iconSubtleColor
202+
}
193203
type="monochrome"
194204
/>
195205
</View>
@@ -199,6 +209,7 @@ export function ThreadWorkLog(props: {
199209
className={cn(
200210
"font-t3-medium text-foreground",
201211
iconIsDestructive && "text-rose-600 dark:text-rose-400",
212+
iconIsWarning && "text-amber-600 dark:text-amber-400",
202213
)}
203214
>
204215
{row.summary}
@@ -239,7 +250,7 @@ export function ThreadWorkLog(props: {
239250
: { ios: "minus", android: "remove" }
240251
}
241252
size={11}
242-
tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
253+
tintColor={props.iconSubtleColor}
243254
type="monochrome"
244255
/>
245256
) : null}

apps/mobile/src/lib/threadActivity.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,38 @@ describe("buildThreadFeed", () => {
434434
]);
435435
});
436436

437+
it("drops runtime warnings with no displayable content", () => {
438+
const thread = makeThread({
439+
id: ThreadId.make("thread-noise"),
440+
projectId: ProjectId.make("project-1"),
441+
title: "Warning noise thread",
442+
activities: [
443+
makeActivity({
444+
id: EventId.make("activity-noise"),
445+
kind: "runtime.warning",
446+
summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
447+
createdAt: "2026-04-01T00:00:02.000Z",
448+
turnId: TurnId.make("turn-1"),
449+
}),
450+
makeActivity({
451+
id: EventId.make("activity-signal"),
452+
kind: "runtime.warning",
453+
summary: "Reconnecting... 2/5",
454+
createdAt: "2026-04-01T00:00:03.000Z",
455+
turnId: TurnId.make("turn-1"),
456+
}),
457+
],
458+
});
459+
460+
const feed = buildThreadFeed(thread);
461+
expect(feed).toMatchObject([
462+
{
463+
type: "activity-group",
464+
activities: [{ id: "activity-signal" }],
465+
},
466+
]);
467+
});
468+
437469
it("collapses matching tool lifecycle rows like desktop", () => {
438470
const thread = makeThread({
439471
id: ThreadId.make("thread-2"),

apps/mobile/src/lib/threadActivity.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,13 +354,25 @@ function deriveWorkLogEntries(
354354
continue;
355355
}
356356
if (activity.summary === "Checkpoint captured") continue;
357+
if (isNoContentRuntimeWarning(activity)) continue;
357358
if (isPlanBoundaryToolActivity(activity)) continue;
358359
if (isAgentInternalActivity(activity)) continue;
359360
entries.push(toDerivedWorkLogEntry(activity));
360361
}
361362
return collapseDerivedWorkLogEntries(entries);
362363
}
363364

365+
/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
366+
* commands_changed, ...) as runtime warnings. The suffix comes from
367+
* describeUnknownSdkMessage in the Claude adapter; a row with no displayable
368+
* text carries nothing a user can act on, so it does not render. */
369+
function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
370+
return (
371+
activity.kind === "runtime.warning" &&
372+
activity.summary.endsWith("(no displayable text content)")
373+
);
374+
}
375+
364376
function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
365377
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
366378
return false;

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,53 @@ describe("MessagesTimeline", () => {
989989
expect(markup).not.toContain('aria-label="Tool call failed"');
990990
});
991991

992+
it("keeps the collapsed summary icon neutral when the group ends in a failure", () => {
993+
const markup = renderToStaticMarkup(
994+
<MessagesTimeline
995+
{...buildProps()}
996+
timelineEntries={[
997+
{
998+
id: "entry-completed",
999+
kind: "work",
1000+
createdAt: "2026-03-17T19:12:28.000Z",
1001+
entry: {
1002+
id: "work-completed",
1003+
createdAt: "2026-03-17T19:12:28.000Z",
1004+
label: "Run tests",
1005+
tone: "tool",
1006+
itemType: "command_execution",
1007+
toolLifecycleStatus: "completed",
1008+
},
1009+
},
1010+
{
1011+
id: "entry-failed",
1012+
kind: "work",
1013+
createdAt: "2026-03-17T19:12:29.000Z",
1014+
entry: {
1015+
id: "work-failed",
1016+
createdAt: "2026-03-17T19:12:29.000Z",
1017+
label: "Run lint",
1018+
tone: "tool",
1019+
itemType: "command_execution",
1020+
toolLifecycleStatus: "failed",
1021+
},
1022+
},
1023+
]}
1024+
/>,
1025+
);
1026+
1027+
expect(markup).toContain("Ran 2 commands");
1028+
// Neutral: the group keeps its command glyph instead of swapping to a
1029+
// failure glyph, and neither the heading nor the icon wrapper goes red once
1030+
// the group ends in an ordinary command failure. Pylon renders the command
1031+
// summary as a DotMatrix, so assert its state rather than a lucide class.
1032+
expect(markup).toContain('data-state="terminal"');
1033+
expect(markup).not.toMatch(/font-medium text-destructive/);
1034+
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
1035+
// The failure stays discoverable for screen readers.
1036+
expect(markup).toContain("tool call failed");
1037+
});
1038+
9921039
it("keeps mixed work logs neutral after a later tool call succeeds", () => {
9931040
const markup = renderToStaticMarkup(
9941041
<MessagesTimeline
@@ -1402,11 +1449,22 @@ describe("MessagesTimeline", () => {
14021449
expect(markup).not.toContain('data-testid="file-diff"');
14031450
});
14041451

1405-
it("renders a failure marker for failed tool lifecycle entries", () => {
1452+
it("renders a muted failure marker for failed tool lifecycle entries", () => {
14061453
const markup = renderToStaticMarkup(
14071454
<MessagesTimeline
14081455
{...buildProps()}
14091456
timelineEntries={[
1457+
{
1458+
id: "entry-info",
1459+
kind: "work",
1460+
createdAt: "2026-03-17T19:12:27.000Z",
1461+
entry: {
1462+
id: "work-info",
1463+
createdAt: "2026-03-17T19:12:27.000Z",
1464+
label: "Status updated",
1465+
tone: "info",
1466+
},
1467+
},
14101468
{
14111469
id: "entry-1",
14121470
kind: "work",
@@ -1426,5 +1484,48 @@ describe("MessagesTimeline", () => {
14261484

14271485
expect(markup).toContain('data-state="error"');
14281486
expect(markup).toContain('aria-label="Tool call failed"');
1487+
// Ordinary tool failures render muted, not red. Pylon's marker is a small
1488+
// DotMatrix status dot that is itself destructive-coloured, so assert on the
1489+
// row treatment rather than on the absence of the class anywhere in the tree.
1490+
expect(markup).not.toMatch(/font-medium text-destructive/);
1491+
expect(markup).not.toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
1492+
});
1493+
1494+
it("keeps the red treatment for severe orchestration failures", () => {
1495+
const markup = renderToStaticMarkup(
1496+
<MessagesTimeline
1497+
{...buildProps()}
1498+
timelineEntries={[
1499+
{
1500+
id: "entry-info",
1501+
kind: "work",
1502+
createdAt: "2026-03-17T19:12:27.000Z",
1503+
entry: {
1504+
id: "work-info",
1505+
createdAt: "2026-03-17T19:12:27.000Z",
1506+
label: "Status updated",
1507+
tone: "info",
1508+
},
1509+
},
1510+
{
1511+
id: "entry-turn-failed",
1512+
kind: "work",
1513+
createdAt: "2026-03-17T19:12:28.000Z",
1514+
entry: {
1515+
id: "work-turn-failed",
1516+
createdAt: "2026-03-17T19:12:28.000Z",
1517+
label: "Provider turn start failed",
1518+
tone: "error",
1519+
sourceActivityKind: "provider.turn.start.failed",
1520+
},
1521+
},
1522+
]}
1523+
/>,
1524+
);
1525+
1526+
// Pylon renders severe failures with circle-alert where upstream uses an x.
1527+
expect(markup).toContain("lucide-circle-alert");
1528+
expect(markup).toMatch(/font-medium text-destructive/);
1529+
expect(markup).toMatch(/size-6 shrink-0 items-center justify-center text-destructive/);
14291530
});
14301531
});

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
workEntryIndicatesToolSuccess,
3939
workLogEntryIsMissingResponse,
4040
workEntryDisplayIndicatesToolFailure,
41+
workEntrySignalsSevereFailure,
4142
workLogEntryIsToolLike,
4243
} from "../../session-logic";
4344
import { type TurnDiffSummary } from "../../types";
@@ -1647,33 +1648,25 @@ function WorkGroupToggleTimelineRow({
16471648
: row.hiddenCount === 1
16481649
? "log entry"
16491650
: "log entries";
1650-
const showHiddenFailure = row.hasFailure && !row.expanded;
1651-
16521651
return (
16531652
<button
16541653
type="button"
16551654
className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1655+
aria-label={
1656+
row.hasFailure && !row.expanded
1657+
? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
1658+
: undefined
1659+
}
16561660
aria-expanded={row.expanded}
16571661
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
16581662
>
1659-
<span
1660-
className={cn(
1661-
"flex size-6 shrink-0 items-center justify-center",
1662-
showHiddenFailure ? "text-destructive" : "text-icon-muted",
1663-
)}
1664-
role={showHiddenFailure ? "img" : undefined}
1665-
aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
1666-
>
1667-
{showHiddenFailure ? (
1668-
<WorkEntryIconSvg name="x" className="shrink-0 stroke-[1.8] opacity-70" />
1669-
) : (
1670-
<ChevronDownIcon
1671-
className={cn(
1672-
"size-4 shrink-0 opacity-70 transition-transform duration-200",
1673-
row.expanded && "rotate-180",
1674-
)}
1675-
/>
1676-
)}
1663+
<span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1664+
<ChevronDownIcon
1665+
className={cn(
1666+
"size-4 shrink-0 opacity-70 transition-transform duration-200",
1667+
row.expanded && "rotate-180",
1668+
)}
1669+
/>
16771670
</span>
16781671
{row.expanded ? (
16791672
<span className="font-medium text-foreground">
@@ -2220,7 +2213,6 @@ type WorkEntryIconName =
22202213
| "square-pen"
22212214
| "terminal"
22222215
| "wrench"
2223-
| "x"
22242216
| "zap";
22252217

22262218
function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
@@ -2258,8 +2250,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
22582250
);
22592251
case "wrench":
22602252
return <WrenchIcon className={fixedIconClassName} aria-hidden />;
2261-
case "x":
2262-
return <DotMatrix sizeRole="inline" aria-hidden state="error" className={className} />;
22632253
case "zap":
22642254
return <ZapIcon className={fixedIconClassName} aria-hidden />;
22652255
}
@@ -2730,14 +2720,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
27302720
(turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
27312721
const showDestructiveRowStyle =
27322722
showFailedIndicator &&
2733-
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
2723+
(workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
2724+
// Ordinary tool failures stay muted; only runtime errors and warnings get
2725+
// color. The red treatment is reserved for severe failures.
27342726
const iconWrapperClass = cn(
27352727
"flex size-6 shrink-0 items-center justify-center",
2736-
showWarningIndicator || showDestructiveRowStyle
2737-
? "text-destructive"
2738-
: workLogEntryIsToolLike(workEntry)
2739-
? "text-icon-muted"
2740-
: iconConfig.className,
2728+
showWarningIndicator
2729+
? "text-warning"
2730+
: showDestructiveRowStyle
2731+
? "text-destructive"
2732+
: workLogEntryIsToolLike(workEntry)
2733+
? "text-icon-muted"
2734+
: iconConfig.className,
27412735
);
27422736
const headingClass = showWarningIndicator
27432737
? "font-medium text-warning"

0 commit comments

Comments
 (0)