+
+ {(stats.duration || stats.tokens) && (
+
+ )}
+ >
+ );
+ return (
+
+ {approvalPending ? (
+
+ {rowContent}
+
+ ) : (
+
{
+ if (subagentDetails) subagentDetails.onOpen(agent);
+ else
+ setExpandedId(isExpanded ? null : agent.callId);
+ }}
+ >
+ {rowContent}
+
+
+ )}
{!subagentDetails && isExpanded && (
diff --git a/packages/web-shell/client/hooks/useMessages.test.ts b/packages/web-shell/client/hooks/useMessages.test.ts
index 200846e527b..3ecda1ff9e7 100644
--- a/packages/web-shell/client/hooks/useMessages.test.ts
+++ b/packages/web-shell/client/hooks/useMessages.test.ts
@@ -604,6 +604,442 @@ describe('background agent task reconciliation', () => {
vi.useRealTimers();
});
+ it('does not fail an agent while its launch approval is unanswered', async () => {
+ vi.useFakeTimers();
+ // The agent call is pending with an unresolved permission request for the
+ // same callId; its subagent session cannot exist yet, so the
+ // reconciliation 404 probe must be skipped rather than accumulating
+ // missing-agent misses and painting a failure.
+ hookState.blocks = [
+ baseBlock({
+ id: 'perm-agent',
+ kind: 'permission',
+ requestId: 'req-1',
+ sessionId: 'session-1',
+ title: 'Launch agent',
+ options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'agent-call',
+ kind: 'other',
+ status: 'pending',
+ title: 'Launch agent',
+ rawInput: { run_in_background: true },
+ },
+ preview: { kind: 'generic' as const },
+ }),
+ baseBlock({
+ id: 'agent-block-agent-call',
+ kind: 'tool',
+ toolCallId: 'agent-call',
+ title: 'Agent',
+ status: 'in_progress',
+ toolName: 'agent',
+ rawInput: { run_in_background: true },
+ rawOutput: { type: 'task_execution', status: 'background' },
+ }),
+ ];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession.mockRejectedValue(
+ new DaemonHttpError(
+ 404,
+ { code: 'session_not_found', toolCallId: 'agent-call' },
+ 'not found',
+ ),
+ );
+ const { container, render, unmount } = mountStatusConsumer();
+
+ await act(async () => render());
+ // The pending-permission agent is excluded from reconciliation, so the
+ // probe never fires and the card cannot be marked failed.
+ expect(hookState.resolveSubagentSession).not.toHaveBeenCalled();
+ expect(container.textContent).toBe('pending');
+
+ // Even after several retry windows of wall-clock time it stays active.
+ await act(async () => vi.advanceTimersByTimeAsync(120_000));
+ expect(hookState.resolveSubagentSession).not.toHaveBeenCalled();
+ expect(container.textContent).toBe('pending');
+
+ // Once the launch approval resolves, the reconciliation must resume
+ // probing: the subagent session may now register, and a missing session
+ // crosses the grace into a visible failure exactly like any other
+ // background agent.
+ hookState.blocks = [
+ {
+ ...hookState.blocks[0],
+ resolved: 'selected:proceed_once',
+ },
+ hookState.blocks[1],
+ ];
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(1),
+ );
+ // First 404 miss keeps the card pending.
+ expect(container.textContent).toBe('pending');
+ // The retry's second miss crosses the missing-agent grace → failed.
+ await act(async () => vi.advanceTimersByTimeAsync(60_000));
+ await vi.waitFor(() => {
+ expect(container.textContent).toBe('failed');
+ });
+
+ await act(async () => unmount());
+ vi.useRealTimers();
+ });
+
+ it('probes only the healthy agent while a sibling launch approval is pending', async () => {
+ vi.useFakeTimers();
+ hookState.blocks = [
+ baseBlock({
+ id: 'perm-agent-a',
+ kind: 'permission',
+ requestId: 'req-a',
+ sessionId: 'session-1',
+ title: 'Launch agent A',
+ options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'agent-call-a',
+ kind: 'other',
+ status: 'pending',
+ title: 'Launch agent A',
+ rawInput: { run_in_background: true },
+ },
+ preview: { kind: 'generic' as const },
+ }),
+ baseBlock({
+ id: 'agent-a',
+ kind: 'tool',
+ toolCallId: 'agent-call-a',
+ title: 'Agent',
+ status: 'in_progress',
+ toolName: 'agent',
+ rawInput: { run_in_background: true },
+ rawOutput: { type: 'task_execution', status: 'background' },
+ }),
+ baseBlock({
+ id: 'agent-b',
+ kind: 'tool',
+ toolCallId: 'agent-call-b',
+ title: 'Agent',
+ status: 'in_progress',
+ toolName: 'agent',
+ rawInput: { run_in_background: true },
+ rawOutput: { type: 'task_execution', status: 'background' },
+ }),
+ ];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession.mockResolvedValue({
+ status: 'running',
+ sessionId: 'sub-agent-b',
+ });
+ const { render, unmount } = mountStatusConsumer({ allTools: true });
+
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalled(),
+ );
+ // Exclusion is per callId: the healthy sibling keeps probing while the
+ // approved agent is skipped.
+ const probed = hookState.resolveSubagentSession.mock.calls.map(
+ (call) => call[1],
+ );
+ expect(probed).toContain('agent-call-b');
+ expect(probed).not.toContain('agent-call-a');
+
+ await act(async () => unmount());
+ vi.useRealTimers();
+ });
+
+ it('resets accumulated missing-agent misses when an approval engages', async () => {
+ vi.useFakeTimers();
+ // Phase 1: no permission yet — one probe fires and 404s (miss 1).
+ hookState.blocks = [
+ baseBlock({
+ id: 'agent-a',
+ kind: 'tool',
+ toolCallId: 'agent-call-a',
+ title: 'Agent',
+ status: 'in_progress',
+ toolName: 'agent',
+ rawInput: { run_in_background: true },
+ rawOutput: { type: 'task_execution', status: 'background' },
+ }),
+ ];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession.mockRejectedValue(
+ new DaemonHttpError(
+ 404,
+ { code: 'session_not_found', toolCallId: 'agent-call-a' },
+ 'not found',
+ ),
+ );
+ const { container, render, unmount } = mountStatusConsumer();
+
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(1),
+ );
+ expect(container.textContent).toBe('pending');
+
+ // Phase 2: the launch approval arrives — exclusion engages and must reset
+ // the accumulated miss, so no further probe fires while it is open.
+ hookState.blocks = [
+ baseBlock({
+ id: 'perm-agent-a',
+ kind: 'permission',
+ requestId: 'req-a',
+ sessionId: 'session-1',
+ title: 'Launch agent A',
+ options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'agent-call-a',
+ kind: 'other',
+ status: 'pending',
+ title: 'Launch agent A',
+ rawInput: { run_in_background: true },
+ },
+ preview: { kind: 'generic' as const },
+ }),
+ hookState.blocks[0],
+ ];
+ await act(async () => render());
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(1);
+
+ // Phase 3: the approval resolves — probing resumes with a fresh grace;
+ // the pre-exclusion miss must not carry over, so the second post-approval
+ // 404 still leaves the card pending, and a third marks it failed.
+ hookState.blocks = [
+ {
+ ...hookState.blocks[0],
+ resolved: 'selected:proceed_once',
+ },
+ hookState.blocks[1],
+ ];
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(2),
+ );
+ expect(container.textContent).toBe('pending');
+ await act(async () => vi.advanceTimersByTimeAsync(60_000));
+ await vi.waitFor(() => {
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(3);
+ expect(container.textContent).toBe('failed');
+ });
+
+ await act(async () => unmount());
+ vi.useRealTimers();
+ });
+
+ it('ignores a permanent failure that settles after approval engages', async () => {
+ const probe = deferred();
+ hookState.blocks = [backgroundAgentBlock('agent-call')];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession.mockReturnValueOnce(probe.promise);
+ const { container, render, unmount } = mountStatusConsumer();
+
+ await act(async () => render());
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(1);
+
+ hookState.blocks = [
+ baseBlock({
+ id: 'perm-agent',
+ kind: 'permission',
+ requestId: 'req-agent',
+ sessionId: 'session-1',
+ title: 'Launch agent',
+ options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'agent-call',
+ kind: 'other',
+ status: 'pending',
+ title: 'Launch agent',
+ rawInput: { run_in_background: true },
+ },
+ preview: { kind: 'generic' as const },
+ }),
+ backgroundAgentBlock('agent-call'),
+ ];
+ await act(async () => render());
+ await act(async () => {
+ probe.reject(
+ new DaemonHttpError(
+ 400,
+ { code: 'invalid_subagent_ref' },
+ 'bad request',
+ ),
+ );
+ });
+
+ expect(container.textContent).toBe('pending');
+ await act(async () => unmount());
+ });
+
+ it('does not count a late 404 after approval engages', async () => {
+ vi.useFakeTimers();
+ const probe = deferred();
+ const missing = new DaemonHttpError(
+ 404,
+ { code: 'session_not_found', toolCallId: 'agent-call' },
+ 'not found',
+ );
+ hookState.blocks = [backgroundAgentBlock('agent-call')];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession
+ .mockReturnValueOnce(probe.promise)
+ .mockRejectedValue(missing);
+ const { container, render, unmount } = mountStatusConsumer();
+
+ await act(async () => render());
+ hookState.blocks = [
+ baseBlock({
+ id: 'perm-agent',
+ kind: 'permission',
+ requestId: 'req-agent',
+ sessionId: 'session-1',
+ title: 'Launch agent',
+ options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'agent-call',
+ kind: 'other',
+ status: 'pending',
+ title: 'Launch agent',
+ rawInput: { run_in_background: true },
+ },
+ preview: { kind: 'generic' as const },
+ }),
+ backgroundAgentBlock('agent-call'),
+ ];
+ await act(async () => render());
+ await act(async () => probe.reject(missing));
+ expect(container.textContent).toBe('pending');
+
+ hookState.blocks = [
+ { ...hookState.blocks[0], resolved: 'selected:proceed_once' },
+ hookState.blocks[1],
+ ];
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(2),
+ );
+ expect(container.textContent).toBe('pending');
+
+ await act(async () => vi.advanceTimersByTimeAsync(3_000));
+ await vi.waitFor(() => expect(container.textContent).toBe('failed'));
+
+ await act(async () => unmount());
+ vi.useRealTimers();
+ });
+
+ it('paces transient-error attempts across permission churn', async () => {
+ vi.useFakeTimers();
+ const agent = backgroundAgentBlock('agent-call');
+ const permission = baseBlock({
+ id: 'perm-file',
+ kind: 'permission',
+ requestId: 'req-file',
+ sessionId: 'session-1',
+ title: 'Write file',
+ options: [{ optionId: 'allow', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'file-call',
+ kind: 'other',
+ status: 'pending',
+ title: 'Write file',
+ rawInput: {},
+ },
+ preview: { kind: 'generic' as const },
+ });
+ hookState.blocks = [agent];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession.mockRejectedValue(
+ new DaemonHttpError(500, { code: 'internal_error' }, 'server error'),
+ );
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const { container, render, unmount } = mountStatusConsumer();
+
+ await act(async () => render());
+ for (let index = 0; index < 8; index += 1) {
+ hookState.blocks = index % 2 === 0 ? [permission, agent] : [agent];
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(
+ index + 2,
+ ),
+ );
+ }
+
+ expect(container.textContent).toBe('pending');
+ expect(warnSpy).not.toHaveBeenCalledWith(
+ '[web-shell] background agent reconciliation retry budget exhausted; marking agents failed',
+ expect.anything(),
+ );
+
+ await act(async () => unmount());
+ warnSpy.mockRestore();
+ vi.useRealTimers();
+ });
+
+ it('keeps the missing-agent grace when an unrelated permission re-probes', async () => {
+ vi.useFakeTimers();
+ // Phase 1: a background agent probes and 404s once (miss 1).
+ hookState.blocks = [backgroundAgentBlock('agent-call')];
+ hookState.resolveSubagentSession.mockReset();
+ hookState.resolveSubagentSession.mockRejectedValue(
+ new DaemonHttpError(
+ 404,
+ { code: 'session_not_found', toolCallId: 'agent-call' },
+ 'not found',
+ ),
+ );
+ const { container, render, unmount } = mountStatusConsumer();
+
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(1),
+ );
+ expect(container.textContent).toBe('pending');
+
+ // Phase 2: an unrelated permission appears inside the retry window. The
+ // effect re-runs and probes again immediately; that second 404 must not
+ // count toward the grace because the base backoff has not elapsed — the
+ // ladder is wall-clock paced, not round-paced.
+ hookState.blocks = [
+ baseBlock({
+ id: 'perm-file',
+ kind: 'permission',
+ requestId: 'req-file',
+ sessionId: 'session-1',
+ title: 'Write file',
+ options: [{ optionId: 'allow', label: 'Allow', raw: {} }],
+ toolCall: {
+ toolCallId: 'file-call',
+ kind: 'other',
+ status: 'pending',
+ title: 'Write file',
+ rawInput: {},
+ },
+ preview: { kind: 'generic' as const },
+ }),
+ backgroundAgentBlock('agent-call'),
+ ];
+ await act(async () => render());
+ await vi.waitFor(() =>
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(2),
+ );
+ // Two immediate 404s inside the backoff window must leave the miss count
+ // at 1, so the card stays pending.
+ expect(container.textContent).toBe('pending');
+
+ // Phase 3: the next probe after the base backoff crosses the grace.
+ await act(async () => vi.advanceTimersByTimeAsync(60_000));
+ await vi.waitFor(() => {
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(3);
+ expect(container.textContent).toBe('failed');
+ });
+
+ await act(async () => unmount());
+ vi.useRealTimers();
+ });
+
it('gives a session-level 404 the same grace as a missing agent', async () => {
vi.useFakeTimers();
hookState.blocks = [backgroundAgentBlock('agent-call')];
@@ -1161,13 +1597,15 @@ describe('background agent task reconciliation', () => {
);
expect(container.textContent).toBe('pending');
- // The double-count must not shorten the documented budget: failure
- // still takes eight erroring rounds in total.
- for (const delay of [6_000, 12_000, 24_000, 48_000, 60_000, 60_000]) {
+ // The immediate identity-triggered round is inside the pacing window,
+ // so only wall-clock-paced errors consume the documented budget.
+ for (const delay of [
+ 6_000, 12_000, 24_000, 48_000, 60_000, 60_000, 60_000,
+ ]) {
await act(async () => vi.advanceTimersByTimeAsync(delay));
}
await vi.waitFor(() => {
- expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(8);
+ expect(hookState.resolveSubagentSession).toHaveBeenCalledTimes(9);
expect(container.textContent).toBe('failed');
});
} finally {
diff --git a/packages/web-shell/client/hooks/useMessages.ts b/packages/web-shell/client/hooks/useMessages.ts
index ce850357670..2c3ac76fd76 100644
--- a/packages/web-shell/client/hooks/useMessages.ts
+++ b/packages/web-shell/client/hooks/useMessages.ts
@@ -125,6 +125,36 @@ export function getPendingBackgroundAgentKey(
return callIds.join('|');
}
+type DaemonPermissionTranscriptBlock = Extract<
+ DaemonTranscriptBlock,
+ { kind: 'permission' }
+>;
+
+/**
+ * CallIds whose permission request is still unanswered. Such an agent has not
+ * spawned yet, so its subagent session legitimately does not exist and the
+ * reconciliation 404 probe must not count toward the missing-agent grace.
+ */
+function getPendingPermissionCallIds(
+ blocks: readonly DaemonTranscriptBlock[],
+): Set {
+ const ids = new Set();
+ for (const block of blocks) {
+ if (block.kind !== 'permission') continue;
+ const perm = block as DaemonPermissionTranscriptBlock;
+ if (perm.resolved) continue;
+ const toolCall = getRecord(perm.toolCall);
+ const callId =
+ typeof toolCall?.['toolCallId'] === 'string'
+ ? toolCall['toolCallId']
+ : typeof toolCall?.['id'] === 'string'
+ ? toolCall['id']
+ : undefined;
+ if (callId) ids.add(callId);
+ }
+ return ids;
+}
+
export function reconcileBackgroundAgentResolutions(
messages: Message[],
resolutions: ReadonlyMap,
@@ -207,6 +237,13 @@ export function useMessagesFromBlocks(
() => getPendingBackgroundAgentKey(reconciledMessages),
[reconciledMessages],
);
+ // A stable primitive key for the effect dependency: the Set's identity
+ // changes on every transcript delta, which would re-run the reconciliation
+ // effect on each streamed update and defeat the retry backoff.
+ const pendingPermissionKey = useMemo(
+ () => [...getPendingPermissionCallIds(blocks)].sort().join('|'),
+ [blocks],
+ );
const backgroundAgentNotificationKey = useMemo(
() => getBackgroundAgentNotificationKey(blocks),
[blocks],
@@ -234,6 +271,13 @@ export function useMessagesFromBlocks(
errorAttempts: new Map(),
});
const missingAgentMissesRef = useRef(new Map());
+ // Last 404 timestamp per callId. The grace ladder is wall-clock paced: a
+ // miss only counts toward the grace once the base backoff has elapsed since
+ // the previous miss, so a re-probe triggered by an unrelated transcript
+ // change (for example another permission appearing) cannot collapse the
+ // retry ladder into two immediate misses.
+ const missTimestampsRef = useRef(new Map());
+ const errorTimestampsRef = useRef(new Map());
const lastConnectionKeyRef = useRef(undefined);
useEffect(() => {
@@ -245,6 +289,8 @@ export function useMessagesFromBlocks(
if (lastConnectionKeyRef.current !== connectionKey) {
lastConnectionKeyRef.current = connectionKey;
missingAgentMissesRef.current.clear();
+ missTimestampsRef.current.clear();
+ errorTimestampsRef.current.clear();
retryBackoffRef.current = {
key: '',
attempts: 0,
@@ -272,12 +318,27 @@ export function useMessagesFromBlocks(
const requestKey = `${sessionId}:${pendingBackgroundAgentKey}:${backgroundAgentNotificationKey}`;
const retryScopeKey = `${sessionId}:${pendingBackgroundAgentKey}`;
const cachedRound = reconciliationRequestRef.current;
- const callIds = pendingBackgroundAgentKey.split('|');
+ // Agents still under approval have not spawned their subagent session
+ // yet: exclude them so the 404 probe cannot accumulate missing-agent
+ // misses and paint a failure while the dialog is unanswered. Rebuild the
+ // membership from the stable key — the effect depends on the key, not the
+ // Set, so a transcript delta with unchanged permission content does not
+ // re-run this effect.
+ const pendingPermissionCallIds = new Set(
+ pendingPermissionKey ? pendingPermissionKey.split('|') : [],
+ );
+ const callIds = pendingBackgroundAgentKey
+ .split('|')
+ .filter((callId) => !pendingPermissionCallIds.has(callId));
for (const callId of [...missingAgentMissesRef.current.keys()]) {
if (!callIds.includes(callId)) {
missingAgentMissesRef.current.delete(callId);
+ missTimestampsRef.current.delete(callId);
}
}
+ for (const callId of [...errorTimestampsRef.current.keys()]) {
+ if (!callIds.includes(callId)) errorTimestampsRef.current.delete(callId);
+ }
const roundErrors: Array<{ callId: string; error: unknown }> = [];
const roundNotFounds: string[] = [];
// A settled round that was already processed must not be reused: a
@@ -351,11 +412,26 @@ export function useMessagesFromBlocks(
round.processed = true;
// Grace-miss accounting lives in the active handler, not the per-call
// closure: a superseded round's late 404 must not consume grace that
- // belongs to the live round.
+ // belongs to the live round. The handler also re-checks the current
+ // probe set: a round that straddles a permission transition settles
+ // with misses for an agent that is now excluded, and those must not
+ // be counted (or re-added after the exclusion cleanup ran).
for (const callId of succeeded) {
missingAgentMissesRef.current.delete(callId);
+ missTimestampsRef.current.delete(callId);
+ errorTimestampsRef.current.delete(callId);
+ }
+ for (const callId of [...resolutions.keys()]) {
+ if (!callIds.includes(callId)) resolutions.delete(callId);
}
for (const callId of notFounds) {
+ if (!callIds.includes(callId)) continue;
+ const now = Date.now();
+ const lastMiss = missTimestampsRef.current.get(callId) ?? 0;
+ if (now - lastMiss < BACKGROUND_AGENT_RECONCILIATION_RETRY_BASE_MS) {
+ continue;
+ }
+ missTimestampsRef.current.set(callId, now);
const misses = (missingAgentMissesRef.current.get(callId) ?? 0) + 1;
missingAgentMissesRef.current.set(callId, misses);
if (misses >= MISSING_BACKGROUND_AGENT_GRACE_MISSES) {
@@ -382,7 +458,18 @@ export function useMessagesFromBlocks(
// never consume the budget.
const errorAttempts = new Map();
for (const entry of errors) {
- const count = (previous.errorAttempts.get(entry.callId) ?? 0) + 1;
+ if (!callIds.includes(entry.callId)) continue;
+ const now = Date.now();
+ const lastError = errorTimestampsRef.current.get(entry.callId);
+ const previousCount = previous.errorAttempts.get(entry.callId) ?? 0;
+ const count =
+ lastError !== undefined &&
+ now - lastError < BACKGROUND_AGENT_RECONCILIATION_RETRY_BASE_MS
+ ? previousCount
+ : previousCount + 1;
+ if (count !== previousCount) {
+ errorTimestampsRef.current.set(entry.callId, now);
+ }
errorAttempts.set(entry.callId, count);
if (count >= BACKGROUND_AGENT_RECONCILIATION_MAX_ATTEMPTS) {
failedCallIds.push(entry.callId);
@@ -459,6 +546,7 @@ export function useMessagesFromBlocks(
connection.sessionId,
connection.status,
pendingBackgroundAgentKey,
+ pendingPermissionKey,
reconciliationAttempt,
workspace.client,
]);