Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c46a88a
feat(core): add Agent Team foundation (experimental, flag-gated)
tanzhenxin Jun 8, 2026
8c8bf2e
feat(core): add send_message team routing (experimental)
tanzhenxin Jun 8, 2026
cd573b9
feat(core): let the Agent tool spawn named teammates (experimental)
tanzhenxin Jun 8, 2026
336f8c8
feat(cli): render team_result/task_list tool displays (experimental)
tanzhenxin Jun 8, 2026
9e11fcc
feat(core): treat teammate messages as top-level turns (experimental)
tanzhenxin Jun 8, 2026
ac76dbb
feat(cli): drive teammates from the headless run loop (experimental)
tanzhenxin Jun 8, 2026
0954dd7
feat(cli): drive teammates from the interactive turn loop (experimental)
tanzhenxin Jun 8, 2026
3d16cd1
fix(core): declare proper-lockfile dependency for the team subsystem
tanzhenxin Jun 8, 2026
50e95be
fix(team): address review findings on the agent-team subsystem (exper…
tanzhenxin Jun 8, 2026
47649ad
fix(core): close ownership TOCTOU and lock-ordering hazard in agent-t…
tanzhenxin Jun 9, 2026
0f8d7ea
fix(core): harden agent-team message handling and auto-claim
tanzhenxin Jun 9, 2026
c8e5c51
fix(core): require approval for agent-team task_create/task_update
tanzhenxin Jun 9, 2026
818a90e
docs(core): correct completeOnIdle JSDoc for team teammates
tanzhenxin Jun 9, 2026
ba363e8
fix(core): harden agent-team leader callback and task envelope
tanzhenxin Jun 9, 2026
3d9de93
fix(core): make deleteTask edge cleanup resilient to partial failure
tanzhenxin Jun 9, 2026
78f0013
fix(cli): mount useTeamInProcess so teammate tabs render
tanzhenxin Jun 9, 2026
9219502
test(terminal-capture): add agent-team feature demo + capture fixes
tanzhenxin Jun 9, 2026
50a868e
fix(core): serialize in-process mailbox writers to fix Windows lock f…
tanzhenxin Jun 9, 2026
8e3be23
feat(team): render teammate reports as a compact notification line
tanzhenxin Jun 9, 2026
61e8a94
fix(terminal-capture): widen agent-team-demo Phase C budget so the GI…
tanzhenxin Jun 9, 2026
5a84641
fix(core): separate task-content nonce; forward send_message summary
tanzhenxin Jun 9, 2026
2ddb5a2
fix(core,cli): harden agent-team messaging per review round 4
tanzhenxin Jun 10, 2026
5a0d5e9
Merge remote-tracking branch 'origin/main' into feat/agent-team-v2
tanzhenxin Jun 10, 2026
41f873d
fix(core): don't let a failed dependent unblock abort task completion
tanzhenxin Jun 10, 2026
a5d2532
Merge remote-tracking branch 'origin/main' into feat/agent-team-v2
tanzhenxin Jun 10, 2026
583baca
fix(core): quarantine corrupt teammate inboxes; skip task scan when n…
tanzhenxin Jun 10, 2026
74cc344
fix(core): re-check task ownership under the lock when unassigning a …
tanzhenxin Jun 10, 2026
b5d6018
fix(core): harden team task file layer against partial writes and tra…
tanzhenxin Jun 10, 2026
58929fb
fix(core): drain messages enqueued during the IDLE transition; settle…
tanzhenxin Jun 10, 2026
9337cae
test(core): align FakeAgent queue and abort semantics with AgentInter…
tanzhenxin Jun 10, 2026
25ec5af
fix(core): surface spawn failures, handle shutdown_rejected, envelope…
tanzhenxin Jun 10, 2026
aacb825
fix(core): exclude workflow tool from teammates
tanzhenxin Jun 10, 2026
afcca7e
fix(core): make task tools visible to permission review; reject depen…
tanzhenxin Jun 10, 2026
da713de
fix(core): reclaim stale teams on team_create instead of wedging the …
tanzhenxin Jun 10, 2026
a1da6c4
fix(cli): pass teammate envelopes straight to the model, skipping she…
tanzhenxin Jun 10, 2026
00f44c8
fix(core): exclude Teammate from UserPromptSubmit hooks and record it…
tanzhenxin Jun 10, 2026
f3d32a9
fix(cli): stop teammate-approval rejections from escaping as unhandle…
tanzhenxin Jun 10, 2026
2e96d18
Merge remote-tracking branch 'origin/main' into feat/agent-team-v2
tanzhenxin Jun 10, 2026
96410d1
test(core): add getSessionId to team-lifecycle mock config
tanzhenxin Jun 10, 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
8 changes: 7 additions & 1 deletion integration-tests/terminal-capture/scenario-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,15 @@ export async function loadScenarios(
): Promise<{ configs: ScenarioConfig[]; basedir: string }> {
const absPath = isAbsolute(tsPath) ? tsPath : resolve(tsPath);
const mod = (await import(absPath)) as {
default: ScenarioConfig | ScenarioConfig[];
default?: ScenarioConfig | ScenarioConfig[];
};
const raw = mod.default;
if (raw == null) {
// A .ts file in scenarios/ with no default export is not a declarative
// scenario — e.g. agent-team-demo.ts, a standalone driver script that
// guards its own entrypoint. Skip it so batch runs don't choke on it.
return { configs: [], basedir: dirname(absPath) };
}
const configs = Array.isArray(raw) ? raw : [raw];

for (const config of configs) {
Expand Down
255 changes: 255 additions & 0 deletions integration-tests/terminal-capture/scenarios/agent-team-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
#!/usr/bin/env npx tsx
/**
* Full agent-team feature demo — one continuous streaming GIF.
*
* Merges the team-lifecycle streaming capture and the tab-navigation demo into
* a single engine-driven run so the GIF tells the whole story end to end:
*
* create team → spawn two teammates in parallel (tab bar appears)
* → DIVE INTO each teammate's own tab to watch their live tool-call stream
* → return to Main → teammates report → leader's combined summary → cleanup
*
* This is a standalone driver script, NOT a declarative scenario-runner
* scenario: the scenario format only supports fixed `sleep` waits, which can't
* reliably hit the tab-navigation window when glm-5.1's reason→create→spawn
* varies ~20-40s. Here we drive the engine directly: stream frames while
* polling, but gate the tab navigation on the teammates actually spawning
* (`waitFor` on the Agent tool's deterministic "is now running concurrently"
* text). Static stretches (the leader idling while teammates read) are
* de-duplicated so the GIF stays tight. It lives under scenarios/ for
* discoverability but guards its own entrypoint so the batch runner
* (run.ts) skips it instead of executing it on import.
*
* Run:
* QWEN_CODE_ENABLE_AGENT_TEAM=1 npx tsx \
* integration-tests/terminal-capture/scenarios/agent-team-demo.ts
* Auth: glm-5.1 via the openai provider (DASHSCOPE_API_KEY in env).
* Output: scenarios/screenshots/agent-team-demo/ (frames + demo.gif).
*/
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
rmSync,
mkdirSync,
existsSync,
writeFileSync,
unlinkSync,
} from 'node:fs';
import { execSync } from 'node:child_process';
import stripAnsi from 'strip-ansi';
import { TerminalCapture } from '../terminal-capture.js';

const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '../../..');
const outputDir = resolve(scriptDir, 'screenshots/agent-team-demo');

const PROMPT =
'Use the agent team tools to create a team "explorers" with two teammates ' +
'"scout-core" and "scout-cli". Spawn BOTH in parallel right away, then end ' +
'your turn and wait for their reports. scout-core: read README.md, ' +
'package.json, and packages/core/package.json one at a time, then write a ' +
'concise summary of the core package. scout-cli: read ' +
'packages/cli/package.json, tsconfig.json, and eslint.config.js one at a ' +
'time, then write a concise summary of the CLI package. After BOTH have ' +
'reported back, give a short combined summary, then delete the team.';

// Raw ANSI escape sequences for arrow keys (sent straight to the PTY).
const ARROW = {
up: '\x1b[A',
down: '\x1b[B',
right: '\x1b[C',
left: '\x1b[D',
};

type FrameHold = 'fast' | 'slow';
interface Frame {
path: string;
hold: FrameHold;
}

const sleep = (ms: number): Promise<void> =>
new Promise((r) => setTimeout(r, ms));

async function main(): Promise<void> {
if (existsSync(outputDir)) {
rmSync(outputDir, { recursive: true });
}
mkdirSync(outputDir, { recursive: true });

const terminal = await TerminalCapture.create({
cols: 120,
rows: 40,
cwd: repoRoot,
outputDir,
title: 'qwen-code',
theme: 'dracula',
chrome: true,
});

const frames: Frame[] = [];
let frameNo = 0;
const snap = async (hold: FrameHold): Promise<void> => {
frameNo += 1;
const name = `frame-${String(frameNo).padStart(4, '0')}.png`;
await terminal.capture(name);
frames.push({ path: join(outputDir, name), hold });
};

const seen = (marker: string): boolean =>
stripAnsi(terminal.getRawOutput())
.toLowerCase()
.includes(marker.toLowerCase());

/**
* Poll at `intervalMs`, capturing a frame only when the output actually
* changed since the last capture (so idle stretches don't bloat the GIF).
* Stops when `marker` appears, after `maxFrames` captures, or `maxPolls`.
*/
const stream = async (
marker: string | null,
opts: { intervalMs: number; maxFrames: number; maxPolls: number },
): Promise<void> => {
let prevLen = -1;
let captured = 0;
for (let i = 0; i < opts.maxPolls; i += 1) {
const len = terminal.getRawOutput().length;
if (len !== prevLen) {
await snap('fast');
prevLen = len;
captured += 1;
if (captured >= opts.maxFrames) return;
}
if (marker && seen(marker)) return;
await sleep(opts.intervalMs);
}
};

/** Send a key, let the UI settle, then take a (held) navigation frame. */
const press = async (key: string): Promise<void> => {
await terminal.type(key);
await terminal.idle(500, 3000);
await snap('slow');
};

try {
await terminal.spawn('node', [
'dist/cli.js',
'--yolo',
'--auth-type',
'openai',
'--model',
'glm-5.1',
]);
await terminal.waitFor('Type your message', { timeout: 30000 });

await terminal.type(PROMPT, { slow: true, delay: 8 });
await terminal.idle(400, 4000);
await terminal.type('\n');

// ── Phase A: stream team creation + teammate spawning ──
// Stop once a teammate's tab has registered (tab bar is up).
await stream('is now running concurrently', {
intervalMs: 1100,
maxFrames: 22,
maxPolls: 45,
});
// Give the second parallel spawn + the tab bar a moment to settle.
await terminal.idle(1200, 5000);
await snap('slow'); // Main view: both teammates running, tab bar visible

// ── Phase B: dive into each teammate's tab and back ──
await press(ARROW.down); // focus the tab bar (hint → "←/→ switch")
await press(ARROW.right); // first teammate's own view
await sleep(1600);
await snap('slow'); // linger on their live tool-call stream
await press(ARROW.right); // second teammate's view
await sleep(1600);
await snap('slow');
await press(ARROW.left); // back to the first teammate
await press(ARROW.left); // back to Main
await press(ARROW.up); // release tab-bar focus

// ── Phase C: stream reports → combined summary → cleanup ──
// The leader sits idle (no Main-view output) while teammates read
// their files, so this stretch captures no frames until a report
// lands. `maxPolls` is therefore the real wall-clock budget: it must
// outlast the *slowest* scout plus the combined summary and delete,
// or the GIF cuts off mid-run. ~200 polls (~5min) tolerates glm-5.1's
// per-teammate latency variance; the loop exits early the instant it
// sees `deleted`, and idle polls capture no frames, so a generous cap
// doesn't bloat the GIF.
await stream('deleted', {
intervalMs: 1400,
maxFrames: 40,
maxPolls: 200,
});
await terminal.idle(1500, 8000);
await snap('slow'); // final consolidated state (scrolled to live bottom)

const gifPath = generateGif(frames, outputDir);
console.log(`\n✅ Agent-team demo: ${frames.length} frames`);
if (gifPath) {
console.log(` GIF: ${gifPath}`);
}
} finally {
await terminal.close();
}
}

/**
* Assemble frames into a single looping GIF via ffmpeg (concat demuxer +
* palettegen). Streaming frames play quickly; navigation frames are held so
* each tab switch is readable; the final frame lingers so the combined summary
* can be read before the loop restarts.
*/
function generateGif(frames: Frame[], dir: string): string | null {
if (frames.length === 0) return null;
const gifPath = join(dir, 'demo.gif');
const listFile = join(dir, 'frames.txt');
const FAST = 0.5;
const SLOW = 1.3;
const FINAL_HOLD = 3.0;

const lines: string[] = [];
frames.forEach((f, i) => {
const isLast = i === frames.length - 1;
const dur = isLast ? FINAL_HOLD : f.hold === 'fast' ? FAST : SLOW;
lines.push(`file '${f.path}'`, `duration ${dur}`);
});
// concat demuxer needs the final frame repeated without a duration.
lines.push(`file '${frames[frames.length - 1]!.path}'`);
writeFileSync(listFile, lines.join('\n'));

try {
execSync(
`ffmpeg -y -f concat -safe 0 -i "${listFile}" ` +
`-vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" ` +
`-loop 0 "${gifPath}"`,
{ stdio: 'pipe' },
);
return gifPath;
} catch {
console.log(' ⚠️ GIF generation requires ffmpeg');
return null;
} finally {
try {
unlinkSync(listFile);
} catch {
// ignore
}
}
}

// Run only when invoked directly (e.g. `npx tsx scenarios/agent-team-demo.ts`),
// NOT when the scenario-runner's batch loader imports this file looking for a
// ScenarioConfig — this is a driver script, not a declarative scenario.
const invokedDirectly =
process.argv[1] != null &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (invokedDirectly) {
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
}
14 changes: 14 additions & 0 deletions integration-tests/terminal-capture/terminal-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,20 @@ export class TerminalCapture {
// 2. Wait for xterm.js rendering to complete
await this.sleep(150);

// 2b. Anchor the viewport to the live bottom. xterm.js does not re-scroll
// to the latest output once the viewport has drifted up — which happens
// after an idle period or a full-screen repaint (e.g. the agent-team
// leader goes idle waiting for teammate reports, then repaints a long
// summary). Without this, a plain viewport screenshot can show stale
// scrollback (the banner / start of the session) instead of the current
// state. Mirrors captureFull's scrollToTop().
await this.page.evaluate(() => {
const W = window as unknown as Record<string, unknown>;
const term = W['term'] as { scrollToBottom?: () => void } | undefined;
term?.scrollToBottom?.();
});
await this.sleep(50);

// 3. Prepare output directory
const dir = outputDir ?? this.outputDir;
mkdirSync(dir, { recursive: true });
Expand Down
58 changes: 38 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading