Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bf60e53
feat(cli): replace inline AgentExecutionDisplay with always-on LiveAg…
wenshao May 7, 2026
474423e
fix(cli): move elapsed + tokens to the front of LiveAgentPanel rows
wenshao May 7, 2026
2d3e07e
fix(cli): switch LiveAgentPanel row to layout C (right-pinned, no fle…
wenshao May 7, 2026
860c4ee
fix(cli): port Claude Code's bullet + arrow visual to LiveAgentPanel …
wenshao May 7, 2026
bfecc09
fix(cli): widen LiveAgentPanel, drop [in turn] marker, point overflow…
wenshao May 7, 2026
b8acd9c
fix(cli): make Down on focused BackgroundTasksPill open the dialog
wenshao May 7, 2026
e9b7698
fix(cli): address Copilot review on LiveAgentPanel — interval gate, g…
wenshao May 7, 2026
1bb1326
fix(cli): address Copilot second-pass review — dialogOpen tick gate, …
wenshao May 7, 2026
dbad3f9
fix(cli): reconcile registry-missing snapshots as just-finished + add…
wenshao May 7, 2026
93036b1
refactor(cli): split LiveAgentPanelBody + drop dead isPending/isWaiti…
wenshao May 7, 2026
0af28b5
fix(cli): map internal tool names to user-facing display names in Liv…
wenshao May 7, 2026
c3d80fc
fix(cli): keep already-terminal snapshots visible until TTL + close 4…
wenshao May 7, 2026
a722d59
refactor(cli): rename FOREGROUND_ROW_PREFIX from `[in turn]` to `[blo…
wenshao May 7, 2026
a28c939
fix(cli): address 4 review findings — height budget, neutral synthesi…
wenshao May 7, 2026
1b0bd3b
fix(cli): ANSI-escape user-controlled strings + restore one-line scro…
wenshao May 7, 2026
ec41da4
fix(cli): paused agents count as active in tally + sync reconciliatio…
wenshao May 7, 2026
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
277 changes: 277 additions & 0 deletions packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from '@testing-library/react';
import { render } from 'ink-testing-library';
import type { Config } from '@qwen-code/qwen-code-core';
import { LiveAgentPanel } from './LiveAgentPanel.js';
import { BackgroundTaskViewStateContext } from '../../contexts/BackgroundTaskViewContext.js';
import { ConfigContext } from '../../contexts/ConfigContext.js';
import type {
AgentDialogEntry,
DialogEntry,
} from '../../hooks/useBackgroundTaskView.js';

function agentEntry(
overrides: Partial<AgentDialogEntry> = {},
): AgentDialogEntry {
return {
kind: 'agent',
agentId: 'a',
description: 'desc',
status: 'running',
startTime: 0,
abortController: new AbortController(),
...overrides,
} as AgentDialogEntry;
}

function shellEntry(overrides: Partial<DialogEntry> = {}): DialogEntry {
return {
kind: 'shell',
shellId: 'bg_x',
command: 'sleep 60',
cwd: '/tmp',
status: 'running',
startTime: 0,
outputPath: '/tmp/x.out',
abortController: new AbortController(),
...overrides,
} as DialogEntry;
}

function renderPanel(
options: {
entries: readonly DialogEntry[];
dialogOpen?: boolean;
width?: number;
maxRows?: number;
/**
* Stub Config supplying a `getBackgroundTaskRegistry()` for the
* panel's per-tick live re-pull. Omit when the test cares only
* about the snapshot path (panel falls back gracefully).
*/
config?: Config;
} = { entries: [] },
) {
const state = {
entries: options.entries,
selectedIndex: 0,
dialogMode: options.dialogOpen ? ('list' as const) : ('closed' as const),
dialogOpen: Boolean(options.dialogOpen),
pillFocused: false,
};
// Wrap render() in act() so the panel's mount-time effect (the
// 1s wall-clock interval) is flushed inside React's scheduler boundary
// — silences the "update inside a test was not wrapped in act"
// warning ink-testing-library otherwise leaks for every render.
let result!: ReturnType<typeof render>;
act(() => {
result = render(
<ConfigContext.Provider value={options.config}>
<BackgroundTaskViewStateContext.Provider value={state}>
<LiveAgentPanel width={options.width} maxRows={options.maxRows} />
</BackgroundTaskViewStateContext.Provider>
</ConfigContext.Provider>,
);
});
return result;
}

/**
* Build a stub Config exposing only `getBackgroundTaskRegistry` — the
* one method the panel calls. Returning a Map-backed registry whose
* `get` reads from the live store lets a test mutate `recentActivities`
* after render and observe the panel pick up the new value on the next
* tick (the actual production behavior we want to lock in).
*/
function makeRegistryConfig(agents: readonly AgentDialogEntry[]): {
config: Config;
store: Map<string, AgentDialogEntry>;
} {
const store = new Map<string, AgentDialogEntry>();
for (const a of agents) store.set(a.agentId, a);
const config = {
getBackgroundTaskRegistry: () => ({
get: (id: string) => store.get(id),
}),
} as unknown as Config;
return { config, store };
}

describe('<LiveAgentPanel />', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
});

afterEach(() => {
vi.useRealTimers();
});

it('hides when there are no agent entries', () => {
const { lastFrame } = renderPanel({ entries: [] });
expect(lastFrame() ?? '').toBe('');
});

it('hides when only non-agent entries exist (shell-only)', () => {
const { lastFrame } = renderPanel({ entries: [shellEntry()] });
expect(lastFrame() ?? '').toBe('');
});

it('hides when the background dialog is open (avoids duplicate roster)', () => {
const { lastFrame } = renderPanel({
entries: [agentEntry({ subagentType: 'researcher' })],
dialogOpen: true,
});
expect(lastFrame() ?? '').toBe('');
});

it('renders header and a single running agent row', () => {
const { lastFrame } = renderPanel({
entries: [
agentEntry({
agentId: 'a-1',
subagentType: 'researcher',
description: 'researcher: scan repo for TODO markers',
startTime: -5_000, // 5s ago at fake-time 0
recentActivities: [
{ name: 'Glob', description: '**/*.ts', at: -1000 },
],
}),
],
});
const frame = lastFrame() ?? '';
expect(frame).toContain('Active agents');
// Running and total tally both 1.
expect(frame).toContain('(1/1)');
expect(frame).toContain('researcher');
expect(frame).toContain('scan repo for TODO markers');
// Latest activity is rendered next to the row, with elapsed time.
expect(frame).toContain('Glob');
expect(frame).toContain('5s');
});

it('does NOT surface a flavor marker on foreground agents', () => {
// Foreground vs background distinction stays with BackgroundTasksDialog
// (where cancel semantics differ); the panel reads as a glance roster
// and the marker added more confusion than signal.
const { lastFrame } = renderPanel({
entries: [
agentEntry({
agentId: 'fg-1',
subagentType: 'editor',
description: 'editor: tighten import order',
flavor: 'foreground',
}),
],
});
const frame = lastFrame() ?? '';
expect(frame).not.toContain('[in turn]');
expect(frame).toContain('editor');
expect(frame).toContain('tighten import order');
});

it('windows from the tail when entries exceed maxRows', () => {
const entries = [
agentEntry({
agentId: 'a-1',
subagentType: 'old-agent',
description: 'old work',
}),
agentEntry({
agentId: 'a-2',
subagentType: 'mid-agent',
description: 'mid work',
}),
agentEntry({
agentId: 'a-3',
subagentType: 'fresh-agent',
description: 'fresh work',
}),
];
const { lastFrame } = renderPanel({ entries, maxRows: 2 });
const frame = lastFrame() ?? '';
// `more above` callout flagged with the dropped count and points
// at the dialog (the only surface where the user can scroll
// through the full roster + take action).
expect(frame).toContain('1 more above');
expect(frame).toContain('to view all');
// Tail window keeps the newest two rows.
expect(frame).toContain('mid-agent');
expect(frame).toContain('fresh-agent');
// Oldest row falls outside the window.
expect(frame).not.toContain('old-agent');
// Total tally still reflects every agent — windowing is a render
// concern, not a counting one.
expect(frame).toContain('(3/3)');
});

it('re-pulls recentActivities from the live registry on each tick', () => {
// The snapshot from useBackgroundTaskView only refreshes on
// statusChange — appendActivity is intentionally silenced there to
// protect the footer pill / AppContainer from per-tool churn. The
// panel must reach back into the registry on every tick or it
// would freeze on whatever activities the snapshot captured at
// register time (typically empty, since register fires before any
// tools run).
const initial = agentEntry({
agentId: 'live-1',
subagentType: 'researcher',
description: 'researcher: investigate',
recentActivities: [], // snapshot has nothing
});
const { config, store } = makeRegistryConfig([initial]);
const { lastFrame } = renderPanel({ entries: [initial], config });

// First paint: snapshot says no activities, registry agrees.
expect(lastFrame() ?? '').not.toContain('Glob');

// Mutate the registry the way `appendActivity` would in production
// (replace the array reference on the same entry object) and
// advance the wall-clock tick. The panel should re-pull and show
// the new activity without needing a statusChange.
const live = store.get('live-1')!;
store.set('live-1', {
...live,
recentActivities: [
{ name: 'Glob', description: '**/*.ts', at: Date.now() },
],
});
act(() => {
vi.advanceTimersByTime(1000);
});

expect(lastFrame() ?? '').toContain('Glob');
});

it('shows terminal status briefly then falls off after the visibility window', () => {
const { lastFrame } = renderPanel({
entries: [
agentEntry({
agentId: 'done-1',
subagentType: 'finisher',
description: 'finisher: wrap up',
status: 'completed',
startTime: -2000,
endTime: 0, // just terminal
}),
],
});
// Within the visibility window the row is still on screen but the
// running tally drops to 0/1.
expect(lastFrame() ?? '').toContain('finisher');
expect(lastFrame() ?? '').toContain('(0/1)');

act(() => {
vi.advanceTimersByTime(9000);
});
// Past TERMINAL_VISIBLE_MS the row is evicted from the panel; with
// nothing left to show the panel hides itself.
expect(lastFrame() ?? '').toBe('');
});
});
Loading
Loading