Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apps/desktop/src/components/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
toolResultChips,
} from "../lib/tool-presentation";
import {
collectDelegationFailures,
collectDelegationStatuses,
collectDelegationTimings,
delegationRoster,
Expand All @@ -59,6 +60,7 @@ import {
subagentOutcome,
summarizeSubagentActivity,
type DelegationActivityItem,
type DelegationFailure,
type SubagentOutcome,
type SubagentTiming,
} from "../lib/subagent-topology";
Expand Down Expand Up @@ -1168,6 +1170,87 @@ function delegateTaskDescription(message: UiMessage): string {
return typeof task === "string" ? task.trim() : "";
}

/**
* Why a settled delegate failed, at the foot of its detail panel (issue #161).
*
* The step stream ends on `Failed` / `Timed out` / `Aborted` without saying
* why: a delegate that dies before emitting a message row has no other carrier
* for its reason, and the badge plus a duration is all a reader gets. The
* runtime already reports `error: { code, message }` on the delegation roster
* entry, so it is rendered here with the same visual language as the parent
* reply's error card instead of being reachable only by reading the raw tool
* result.
*/
function SubagentFailureCard({
outcome,
failure,
}: {
outcome: SubagentOutcome;
failure: DelegationFailure;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
const detailsId = useId();
const headingId = useId();
// A known runtime code already has a localized sentence; otherwise the
// outcome's own label is the summary, which stays truthful and localized.
const localizedKey = `errors.${failure.code}`;
const localized = failure.code ? t(localizedKey) : localizedKey;
const summary =
failure.code && localized !== localizedKey
? localized
: t(`chat.subagentStatus.${outcome}`);
// A code-only error carries no detail to disclose, so it stays a one-line
// card rather than opening onto an empty box.
const hasMessage = failure.message.length > 0;

return (
<section
className="message-error subagent-failure"
aria-labelledby={headingId}
data-testid="subagent-failure"
>
<div className="message-error-heading">
<span className="message-error-icon" aria-hidden>
<IconCircleAlert size={16} />
</span>
<div className="message-error-copy">
<strong id={headingId}>{summary}</strong>
{failure.code ? <code>{failure.code}</code> : null}
</div>
<div className="message-error-actions">
{/* The toggle stays outside the collapsed region, otherwise hiding
* the details would take away the control that brings them back. */}
{hasMessage ? (
<button
type="button"
className="message-error-toggle"
aria-expanded={open}
aria-controls={detailsId}
onClick={() => setOpen((value) => !value)}
>
<IconChevronRight size={12} aria-hidden />
{open ? t("chat.hideErrorDetails") : t("chat.showErrorDetails")}
</button>
) : null}
</div>
</div>
{hasMessage ? (
<div
id={detailsId}
className={`message-error-details ${open ? "open" : ""}`}
hidden={!open}
>
<div className="message-error-raw">
<pre className="selectable">{failure.message}</pre>
<CopyButton text={failure.message} label={t("chat.copyErrorDetails")} />
</div>
</div>
) : null}
</section>
);
}

/**
* The side-sheet view for a selected delegate. It shows a sticky identity
* header, the task as an inset grouped card, and the live process timeline.
Expand All @@ -1177,11 +1260,13 @@ export function SubagentDetail({
message,
delegate,
delegationStatuses,
delegationFailures,
delegationTimings,
}: {
message: UiMessage;
delegate?: SubagentRun;
delegationStatuses?: ReadonlyMap<string, SubagentOutcome>;
delegationFailures?: ReadonlyMap<string, DelegationFailure>;
delegationTimings?: ReadonlyMap<string, SubagentTiming>;
}) {
const { t } = useTranslation();
Expand All @@ -1201,6 +1286,7 @@ export function SubagentDetail({
? payloadRecord.delegationId
: message.toolCallId || message.id;
const timing = delegationTimings?.get(delegationId);
const failure = delegationFailures?.get(delegationId);
const startedAt =
timing?.startedAt ??
(typeof payloadRecord?.startedAt === "number" ? payloadRecord.startedAt : undefined);
Expand Down Expand Up @@ -1329,6 +1415,11 @@ export function SubagentDetail({
variant="dock"
/>
) : null}
{/* A completed delegate has nothing to explain, so the card is tied to a
* non-success terminal outcome rather than to the error field alone. */}
{failure && outcome !== "completed" && outcome !== "running" ? (
<SubagentFailureCard outcome={outcome} failure={failure} />
) : null}
</div>
);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/components/workpanel/SubagentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
type AssistantActivityItem,
} from "../../lib/assistant-turns";
import {
collectDelegationFailures,
collectDelegationStatuses,
collectDelegationTimings,
isDelegationActivityItem,
type DelegationActivityItem,
type DelegationFailure,
type SubagentOutcome,
type SubagentTiming,
} from "../../lib/subagent-topology";
Expand Down Expand Up @@ -78,6 +80,13 @@ export function SubagentPanel({ selection }: { selection: SubagentPanelSelection
: new Map(),
[isRunning, selected],
);
const delegationFailures = useMemo<ReadonlyMap<string, DelegationFailure>>(
() =>
selected
? collectDelegationFailures(selected.turnActivityItems)
: new Map(),
[selected],
);
const delegationTimings = useMemo<ReadonlyMap<string, SubagentTiming>>(
() =>
selected
Expand Down Expand Up @@ -126,6 +135,7 @@ export function SubagentPanel({ selection }: { selection: SubagentPanelSelection
? { delegate: selected.item.delegate }
: {})}
delegationStatuses={delegationStatuses}
delegationFailures={delegationFailures}
delegationTimings={delegationTimings}
/>
) : (
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/lib/subagent-topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,64 @@ export function collectDelegationStatuses(
return statuses;
}

/**
* A settled subagent's failure as the runtime reported it.
*
* `Task` returns the moment a delegate starts (ADR 0089), so its own result can
* never carry one. `TaskWait`/`TaskList` report `details.delegations[]` and
* `TaskStop` reports `details.stopped[]`, and those entries do carry
* `error: { code, message }` from `SubagentRunResult.error`.
*/
export type DelegationFailure = {
code: string;
message: string;
};

function readDelegationFailure(entry: unknown): DelegationFailure | null {
const record = asRecord(entry);
const error = asRecord(record?.error);
if (!error) return null;
const code = typeof error.code === "string" ? error.code.trim() : "";
const message = typeof error.message === "string" ? error.message.trim() : "";
if (!code && !message) return null;
return { code, message };
}

/**
* Why each settled delegation failed, keyed by delegation id.
*
* {@link collectDelegationStatuses} says *that* a subagent ended as `failed`,
* `timed_out`, or `aborted`; this says why, from the same lifecycle rows and
* with the same last-write-wins rule. A delegate that fails before emitting a
* message row has no other carrier for its reason, which is what left a failed
* subagent's detail panel showing steps and no explanation.
*/
export function collectDelegationFailures(
items: readonly AssistantActivityItem[],
): ReadonlyMap<string, DelegationFailure> {
const failures = new Map<string, DelegationFailure>();
for (const item of items) {
if (item.kind !== "tool") continue;
// Read the row before the guard narrows `item` away: every tool item is a
// potential delegation node, so excluding them leaves TS with `never`.
const { message } = item;
if (isDelegationActivityItem(item)) continue;
const payload = asRecord(toolResultPayload(message));
if (!payload) continue;
for (const entries of [payload.delegations, payload.stopped]) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
const record = asRecord(entry);
const id = record?.delegationId;
if (typeof id !== "string" || !id) continue;
const failure = readDelegationFailure(entry);
if (failure) failures.set(id, failure);
}
}
}
return failures;
}

/** One subagent named on a lifecycle row's roster (ADR 0089). */
export type DelegationRosterEntry = {
delegationId: string;
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/styles/work-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,14 @@
padding: 0 16px;
}

/* Issue #161: the failure card closes out a settled delegate, so it lines up
with the task card and the run rows instead of the panel's own edges. The
card carries its own padding, so it is inset with margins. */
.subagent-detail > .subagent-failure {
width: auto;
margin: 0 16px;
}

.subagent-detail-section-label,
.subagent-detail > .subagent-run .subagent-run-heading.is-dock {
min-height: 20px;
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/test/subagent-panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const detailSource = transcriptSource.slice(
transcriptSource.indexOf("export function SubagentDetail"),
transcriptSource.indexOf("/**\n * A truthful one-level graph", transcriptSource.indexOf("export function SubagentDetail")),
);
const failureCardSource = transcriptSource.slice(
transcriptSource.indexOf("function SubagentFailureCard("),
transcriptSource.indexOf("export function SubagentDetail"),
);
const storeSource = await readFile(
new URL("../src/stores/app-store.ts", import.meta.url),
"utf8",
Expand Down Expand Up @@ -152,3 +156,37 @@ test("the subagent dock uses a grouped identity, task card, and process timeline
assert.match(transcriptSource, /t\("chat.subagentProcess"\)/);
assert.match(detailSource, /variant="dock"/);
});

test("a settled delegate that failed explains itself at the foot of the dock", () => {
// The status says *that* a delegate failed; only the lifecycle rows can say
// why (ADR 0089), and the dock is the surface that renders the outcome.
assert.match(panelSource, /collectDelegationFailures\(selected\.turnActivityItems\)/);
assert.match(panelSource, /delegationFailures=\{delegationFailures\}/);
assert.match(
detailSource,
/delegationFailures\?: ReadonlyMap<string, DelegationFailure>/,
);
assert.match(detailSource, /delegationFailures\?\.get\(delegationId\)/);
// Tied to a non-success terminal outcome, not to the error field alone, so a
// completed or still-running delegate never shows an error card.
assert.match(
detailSource,
/failure && outcome !== "completed" && outcome !== "running"/,
);
assert.match(failureCardSource, /function SubagentFailureCard\(/);
assert.match(failureCardSource, /data-testid="subagent-failure"/);
assert.match(failureCardSource, /className="message-error subagent-failure"/);
assert.match(failureCardSource, /t\(`chat\.subagentStatus\.\$\{outcome\}`\)/);
assert.match(failureCardSource, /t\(localizedKey\)/);
assert.match(failureCardSource, /<CopyButton /);
// The disclosure control must precede the region it collapses, otherwise
// hiding the details would take away the control that brings them back.
assert.match(
failureCardSource,
/className="message-error-toggle"[\s\S]*?className=\{`message-error-details/,
);
assert.match(
workPanelCss,
/\.subagent-detail > \.subagent-failure\s*\{[\s\S]*?margin:\s*0 16px;/,
);
});
Loading
Loading