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
30 changes: 30 additions & 0 deletions docs/design/background-agent-status-and-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Background agent status and details

## Problem

An Agent tool call returns as soon as a background agent is launched. Its transcript block therefore has a terminal tool event whose payload says `status: background`. The Web Shell intentionally maps that launch result back to a pending tool card, but nothing later reconciles the card with the live background-task registry. The footer task list reaches a terminal state while the original Agent card remains running.

Foreground agents already open in the shared subagent detail panel. Background agents have the same `toolUseId`, task registry entry, JSONL transcript, and virtual-session resolver, but this path lacks explicit coverage.

## Design

Keep the launch projection unchanged: a launch result with `status: background` remains pending until authoritative task state arrives. The daemon already emits terminal background-agent notifications over the session SSE stream with the task `status` and `toolUseId`. The Web Shell consumes that hidden notification metadata and reconciles it back into the projected Agent tool card.

- `completed` and `cancelled` complete the card.
- `failed` fails the card.
- The notification timestamp becomes the card end time.
- Notifications without `toolUseId`, non-agent notifications, and unrelated tool calls do not directly change messages.

The existing subagent detail provider remains the only UI path. Background Agent cards stay clickable while pending and after terminal reconciliation. The virtual-session resolver continues to stream the task JSONL and obtain live status from the task registry without filtering on foreground/background mode. For legacy tasks without `toolUseId`, it matches the launch record to the persisted sidecar and keeps a terminal sidecar status when the original background launch result still says `running`.

While detached work is active, its main-list card uses a dedicated static `background task` label instead of the foreground `running` label. The card does not use the running shimmer or a ticking elapsed timer. Terminal notifications replace that label with the normal completed, failed, or cancelled presentation.

Background agents are omitted from the bottom status bar because their progress is available from the clickable card and detail panel. They remain in the full Tasks panel. Other background task kinds, including shell commands, remain in the bottom status bar and retain their existing polling. A background Agent by itself does not activate bottom-bar task polling.

Persisted notification records do not always retain a `toolUseId`. When a loaded transcript contains an active background Agent card, the Web Shell therefore resolves each pending card through the existing subagent endpoint after transcript catch-up. It repeats this one-shot check after a reconnect and when any terminal Agent notification arrives, even if that notification cannot identify the card directly. It never starts an interval. Input focus and ordinary streaming do not change the pending Agent call IDs or terminal-notification key and therefore do not trigger another request.

The docked detail panel expands from the right edge so the chat is pushed left continuously instead of resizing before a separate panel motion. Reduced-motion preferences disable the docked animation. Panel tabs keep a fixed width, truncate long titles, and scroll horizontally when the tab list exceeds the available space.

## Scope

This change updates the Web Shell projection and the daemon's virtual-subagent status resolver. It does not rewrite persisted parent transcripts, alter the task lifecycle, add task polling for background agents, remove agents from the full Tasks panel, or add a second subagent viewer.
28 changes: 25 additions & 3 deletions packages/cli/src/serve/virtual-subagent-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@ import type { WorkspaceRuntime } from './workspace-registry.js';
import {
createVirtualSubagentSessionId,
parseVirtualSubagentSessionId,
preferTerminalTaskStatus,
VirtualSubagentSessions,
} from './virtual-subagent-sessions.js';

const tempDirs: string[] = [];

it.each(['running', 'paused'])(
'keeps a terminal task status over non-terminal %s metrics',
(metricsStatus) => {
expect(preferTerminalTaskStatus(metricsStatus, 'completed')).toBe(
'completed',
);
},
);
Comment on lines +26 to +33

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The unit test only exercises the direction where metricsStatus is non-terminal and selectedStatus is terminal; the reverse direction — a terminal metricsStatus over a non-terminal selectedStatus (the metricsStatus ?? selectedStatus fallback path) — has no direct coverage. — Concrete cost: a regression simplifying preferTerminalTaskStatus to always return selectedStatus would pass both existing parametrized cases (selectedStatus is 'completed' in each). In production, when the parent transcript metrics already record a terminal status (e.g. 'failed') but the sidecar/registry status is still 'running', the function would silently return 'running' instead of 'failed'.

Suggested change
it.each(['running', 'paused'])(
'keeps a terminal task status over non-terminal %s metrics',
(metricsStatus) => {
expect(preferTerminalTaskStatus(metricsStatus, 'completed')).toBe(
'completed',
);
},
);
it.each(['running', 'paused'])(
'keeps a terminal task status over non-terminal %s metrics',
(metricsStatus) => {
expect(preferTerminalTaskStatus(metricsStatus, 'completed')).toBe(
'completed',
);
},
);
it.each(['completed', 'failed', 'cancelled'])(
'preserves a terminal %s metrics status over a non-terminal selected status',
(metricsStatus) => {
expect(preferTerminalTaskStatus(metricsStatus, 'running')).toBe(
metricsStatus,
);
},
);

— qwen3.8-max-preview via Qwen Code /review


afterEach(async () => {
await Promise.all(
tempDirs
Expand Down Expand Up @@ -632,7 +642,7 @@ describe('VirtualSubagentSessions', () => {
await iterator.return?.();
});

it('matches legacy sidecars through the recorded launch prompt', async () => {
it('keeps terminal legacy sidecar status over the background launch result', async () => {
const runtimeDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-subagent-runtime-'),
);
Expand Down Expand Up @@ -677,7 +687,7 @@ describe('VirtualSubagentSessions', () => {
status: 'success',
resultDisplay: {
type: 'task_execution',
status: 'completed',
status: 'background',
tokenCount: 123,
executionSummary: {
totalDurationMs: 88_610,
Expand Down Expand Up @@ -728,7 +738,19 @@ describe('VirtualSubagentSessions', () => {
v: 1 as const,
sessionId: parentSessionId,
now: Date.now(),
tasks: [],
tasks: [
{
kind: 'agent' as const,
id: 'general-purpose-random',
label: 'legacy task',
description: 'legacy task',
status: 'completed' as const,
startTime: Date.now(),
runtimeMs: 1,
outputFile,
isBackgrounded: true,
},
],
}),
},
} as unknown as WorkspaceRuntime;
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/serve/virtual-subagent-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ function normalizeTaskStatus(status: unknown): string | undefined {
return status;
}

function isTerminalTaskStatus(status: string | undefined): boolean {
return (
status === 'completed' ||
status === 'failed' ||
status === 'cancelled' ||
status === 'canceled'
);
}

export function preferTerminalTaskStatus(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Good — the general isTerminalTaskStatus guard covers running, paused, and any future non-terminal states. Exporting preferTerminalTaskStatus for direct testing is the right call.

— qwen3.8-max-preview via Qwen Code /review

metricsStatus: string | undefined,
selectedStatus: string,
): string {
return !isTerminalTaskStatus(metricsStatus) &&
isTerminalTaskStatus(selectedStatus)
? selectedStatus
: (metricsStatus ?? selectedStatus);
}

function durationBetween(start: string, end?: string): number | undefined {
if (!end) return undefined;
const startTime = Date.parse(start);
Expand Down Expand Up @@ -849,7 +868,7 @@ export class VirtualSubagentSessions {
const metrics = findToolCallMetrics(parentRecords, toolCallId);
return {
...selected,
status: metrics.status ?? selected.status,
status: preferTerminalTaskStatus(metrics.status, selected.status),
durationMs: metrics.durationMs ?? selected.durationMs,
totalTokens: metrics.totalTokens ?? selected.totalTokens,
inputTokens: metrics.inputTokens ?? selected.inputTokens,
Expand Down
33 changes: 33 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@
background: transparent;
}

.artifactPanelDock {
flex: 0 0 var(--artifact-panel-dock-width);
width: var(--artifact-panel-dock-width);
min-width: 0;
display: flex;
animation: artifactPanelDockOpen 200ms cubic-bezier(0.32, 0.72, 0, 1);
}

.artifactPanelClip {
flex: 1 1 auto;
min-width: 0;
display: flex;
overflow: hidden;
}

@keyframes artifactPanelDockOpen {
from {
flex-basis: 0;
width: 0;
}

to {
flex-basis: var(--artifact-panel-dock-width);
width: var(--artifact-panel-dock-width);
}
}

@media (prefers-reduced-motion: reduce) {
.artifactPanelDock {
animation: none;
}
}

/* Positioning context so the scheduled-tasks page (position:absolute) covers
exactly the chat pane. Only applied while the page is shown, so normal chat
layout is untouched. */
Expand Down
32 changes: 31 additions & 1 deletion packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act, createRef, type CSSProperties } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon';
import type { WebShellApi } from './App';
import type { Message } from './adapters/types';
import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl';

type StreamingState = 'idle' | 'responding';
Expand Down Expand Up @@ -854,14 +855,43 @@ mockComponent('./components/messages/TasksStatusMessage', 'TasksStatusMessage');
mockComponent('./components/messages/BtwMessage', 'BtwMessage');
mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay');

const { App } = await import('./App');
const { App, getBackgroundTaskActivityKey } = await import('./App');

(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

const mounted: Array<{ root: Root; container: HTMLElement }> = [];

describe('background task activity key', () => {
it('includes background shells and excludes background agents', () => {
const messages = [
{
id: 'tools',
role: 'tool_group',
tools: [
{
callId: 'shell-call',
toolName: 'shell',
status: 'in_progress',
args: { is_background: true },
},
{
callId: 'agent-call',
toolName: 'agent',
status: 'pending',
args: { run_in_background: true },
},
],
},
] satisfies Message[];

expect(getBackgroundTaskActivityKey(messages)).toBe(
'shell-call:in_progress',
);
});
});

function renderApp(props: React.ComponentProps<typeof App> = {}): {
container: HTMLElement;
rerender: (nextProps?: React.ComponentProps<typeof App>) => void;
Expand Down
57 changes: 30 additions & 27 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ import {
TasksStatusMessage,
type SerializedTasksMessage,
} from './components/messages/TasksStatusMessage';
import { isBackgroundSubAgentToolCall } from './adapters/toolClassification';
import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage';
import {
serializeStatsMessage,
Expand Down Expand Up @@ -946,15 +945,14 @@ function isBackgroundShellToolCall(tool: ACPToolCall): boolean {
);
}

function getBackgroundTaskActivityKey(messages: readonly Message[]): string {
export function getBackgroundTaskActivityKey(
messages: readonly Message[],
): string {
const parts: string[] = [];
for (const message of messages) {
if (message.role !== 'tool_group') continue;
for (const tool of message.tools) {
if (
isBackgroundSubAgentToolCall(tool) ||
isBackgroundShellToolCall(tool)
) {
if (isBackgroundShellToolCall(tool)) {
parts.push(`${tool.callId}:${tool.status}`);
}
Comment thread
ytahdn marked this conversation as resolved.
}
Expand Down Expand Up @@ -2320,12 +2318,9 @@ export function App({
() => getBackgroundTaskActivityKey(messages),
[messages],
);
const [backgroundTasksRefreshTrigger, setBackgroundTasksRefreshTrigger] =
useState(0);
const backgroundTasks = useBackgroundTasks(
backgroundTaskActivityKey,
connection.status === 'connected',
backgroundTasksRefreshTrigger,
);
const footerTasks = useMemo(
() => (renderFooter ? backgroundTasks.map(mapToWebShellTaskInfo) : []),
Expand Down Expand Up @@ -5163,7 +5158,6 @@ export function App({
pushToast('warning', t('fork.notStarted'));
return;
}
setBackgroundTasksRefreshTrigger((value) => value + 1);
pushToast(
'success',
t('fork.started', { name: result.description }),
Expand Down Expand Up @@ -8136,7 +8130,14 @@ export function App({
</DrawerContent>
</Drawer>
) : artifactPanelOpen ? (
<>
<div
className={styles.artifactPanelDock}
style={
{
'--artifact-panel-dock-width': `${artifactPanelWidth + 4}px`,
} as CSSProperties
}
>
<div
className={styles.artifactResizeHandle}
role="separator"
Expand All @@ -8146,22 +8147,24 @@ export function App({
aria-valuenow={artifactPanelWidth}
onPointerDown={handleArtifactPanelResizeStart}
/>
<ArtifactPanel
artifacts={artifactPanelArtifacts}
tabs={artifactPanelTabs}
activeTabId={activeArtifactPanelTabId}
reviewChanges={reviewChanges}
selectedReviewPath={selectedReviewPath}
panelWidth={artifactPanelWidth}
workspaceCwd={connection.workspaceCwd || ''}
loading={artifactsLoading}
error={artifactsError}
onSelectTab={setActiveArtifactPanelTabId}
onCloseTab={closeArtifactPanelTab}
onOpenFilePreview={openFilePreview}
onClose={closeArtifactPanel}
/>
</>
<div className={styles.artifactPanelClip}>
<ArtifactPanel
artifacts={artifactPanelArtifacts}
tabs={artifactPanelTabs}
activeTabId={activeArtifactPanelTabId}
reviewChanges={reviewChanges}
selectedReviewPath={selectedReviewPath}
panelWidth={artifactPanelWidth}
workspaceCwd={connection.workspaceCwd || ''}
loading={artifactsLoading}
error={artifactsError}
onSelectTab={setActiveArtifactPanelTabId}
onCloseTab={closeArtifactPanelTab}
onOpenFilePreview={openFilePreview}
onClose={closeArtifactPanel}
/>
</div>
</div>
) : null}
</div>
</div>
Expand Down
Loading
Loading