Files
stack/packages/mosaic/src/commands/fleet.spec.ts
Jarvis 8466ca2d81
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
fix(fleet): verify via pane-change diff + non-resizing watch
Blocker fix: send --verify now captures a BEFORE snapshot immediately
before the send and an AFTER snapshot after the delay, then uses
classifySendResult(before, after) to classify. A wedged pane showing
stale non-empty content is no longer falsely reported as 'accepted' —
BEFORE==AFTER maps to 'unverifiable' (exit 1, "no pane change after
send"). Blank AFTER still fails closed as 'unverifiable'. Only
AFTER != BEFORE without a draft suffix counts as 'accepted' (exit 0).

Should-fix: agent watch now uses a GROUPED VIEWER SESSION instead of a
bare 'tmux attach -r' against the agent session. A bare attach lets the
viewer terminal shrink the agent's window; a grouped session has
independent sizing so the agent's window is never affected.
Sequence: new-session -d -t '=<agent>' -s '<agent>-watch-<pid>' (runner),
attach -r to viewer session (interactiveRunner), kill-session on detach
(runner). New builder functions exported: buildAgentWatchCreateViewerCommand,
buildAgentWatchAttachCommand, buildAgentWatchKillViewerCommand,
buildViewerSessionName. buildAgentWatchCommand kept but deprecated.

New exports: classifySendResult(before, after) — the testable classifier.

Tests added:
- classifySendResult unit suite (6 cases): accepted/draft/unverifiable/
  stale-pane/both-blank/before-blank-after-response
- send --verify regression: stale (before==after non-empty) => exit 1
- send --verify regression: blank AFTER => exit 1
- send --verify regression: draft after pane change => exit 1
- send --verify regression: changed non-draft => exit 0
- send --verify: 3-call sequence assertion (before-capture, send, after-capture)
- watch dispatch: grouped viewer session created/attached/killed; no bare
  attach against agent session; viewer name matches <agent>-watch-<pid>

PRD Known-limitations updated: pane-change check rationale, Phase-3
heartbeat-ack requirement, grouped-session watch design.

All gates pass: pnpm typecheck, pnpm lint, pnpm --filter @mosaicstack/mosaic test
(382 tests, 74 fleet), prettier --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMoEx7hfdFGjUiCHuN1RRi
2026-06-20 22:57:00 -05:00

1524 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildAgentSendCommand,
buildAgentWatchAttachCommand,
buildAgentWatchCommand,
buildAgentWatchCreateViewerCommand,
buildAgentWatchKillViewerCommand,
buildAgentVerifyAcceptedCommand,
buildFleetServiceCommand,
buildSystemdShowCommand,
buildTmuxListPanesCommand,
classifySendResult,
detectDrift,
generateAgentEnv,
getDefaultOperatorSourceLabel,
getDefaultTenantAndHost,
getRosterAgent,
heartbeatPath,
isSendAccepted,
loadFleetRoster,
mergeAgentEnv,
parseHeartbeat,
parseSystemdShow,
parseTmuxListPanes,
registerFleetCommand,
resolveFleetPaths,
type AgentPsRow,
type CommandRunner,
type InteractiveRunner,
} from './fleet.js';
import { registerAgentCommand } from './agent.js';
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
registerFleetCommand(program);
registerAgentCommand(program);
return program;
}
async function tempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
}
describe('registerFleetCommand', () => {
it('registers local canary fleet subcommands', () => {
const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet');
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'init',
'install',
'install-systemd',
'ps',
'restart',
'start',
'status',
'stop',
'verify',
]);
});
it('adds fleet-backed agent subcommands without removing existing options', () => {
const program = buildProgram();
const agent = program.commands.find((command) => command.name() === 'agent');
expect(agent).toBeDefined();
expect(agent!.options.map((option) => option.long)).toContain('--list');
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
'reset',
'roster',
'send',
'status',
'tail',
'watch',
]);
});
});
describe('fleet roster parsing', () => {
let cleanup: string | undefined;
afterEach(async () => {
if (cleanup) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' class: canary',
].join('\n'),
);
const roster = await loadFleetRoster(rosterPath);
expect(roster.tmux.socketName).toBe('mosaic-factory');
expect(roster.tmux.holderSession).toBe('_holder');
expect(roster.agents).toHaveLength(1);
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
});
it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-factory' },
defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'',
].join('\n'),
);
});
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
const generated = [
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'',
].join('\n');
const existing = [
'MOSAIC_AGENT_NAME=old-name',
'MOSAIC_AGENT_RUNTIME=old-runtime',
'MOSAIC_AGENT_WORKDIR=/srv/old',
'MOSAIC_TMUX_SOCKET=old-socket',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note',
'',
].join('\n');
expect(mergeAgentEnv(generated, existing)).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note',
'',
].join('\n'),
);
});
it('rejects unknown roster fields instead of silently defaulting', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'tmux:',
' socketNamee: prod-fleet',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster tmux has unknown field(s): socketNamee.',
);
});
it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 123 },
defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'canary-pi', runtime: 'pi' }],
}),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster tmux socket_name must be a string.',
);
});
it('rejects wrong-typed agent fields', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'canary-pi', runtime: 42 }],
}),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster agent "canary-pi" runtime must be a string.',
);
});
it('rejects duplicate agent names before install can overwrite env files', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' - name: canary-pi',
' runtime: codex',
].join('\n'),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster has duplicate agent name: canary-pi.',
);
});
it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
expect(localCanary.tmux.socketName).toBe('mosaic-factory');
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
});
});
describe('fleet command construction', () => {
it('builds exact systemd user commands for holder and agent operations', () => {
expect(buildFleetServiceCommand('status')).toEqual([
'systemctl',
'--user',
'status',
'mosaic-tmux-holder.service',
]);
expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
'systemctl',
'--user',
'restart',
'mosaic-agent@coder0.service',
]);
});
it('builds socket-scoped agent send commands', () => {
const paths = resolveFleetPaths('/home/test/.config/mosaic');
expect(
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'),
).toEqual([
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
'-L',
'mosaic-factory',
'-S',
'operator:mosaic-cli',
'-s',
'coder0',
'-m',
'hello',
]);
});
it('runs fleet status through injected runner without touching tmux in tests', async () => {
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'ok\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner });
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
});
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('writes init output to the explicit roster path', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
]);
const content = await readFile(rosterPath, 'utf8');
expect(content).toContain('name: canary-pi');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('refuses to overwrite an existing roster unless --force is provided', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(rosterPath, 'site-owned: true\n');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
try {
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
]),
).rejects.toThrow('Fleet roster already exists');
expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
'--force',
]);
expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects unknown init profiles instead of silently falling back', async () => {
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
).rejects.toThrow('Unsupported fleet profile');
});
it('sets process exitCode when status runner fails', async () => {
const originalExitCode = process.exitCode;
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(process.exitCode).toBe(3);
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
}
});
it('loads default fleet/roster.json when roster.yaml is absent', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-canary', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
expect(calls).toEqual([
['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('starts the holder before agents and stops agents before the holder', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
expect(calls).toEqual([
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: coder0',
' runtime: codex',
' - name: reviewer0',
' runtime: pi',
].join('\n'),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
if (args.includes('mosaic-agent@coder0.service')) {
return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
'Fleet stop completed with 1 failure(s)',
);
expect(calls).toEqual([
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
const home = await tempDir();
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
mosaicHome: home,
frameworkRoot: resolve(process.cwd(), 'framework'),
});
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
).rejects.toThrow('install-systemd only supports the default Mosaic home');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it.each(['start', 'stop', 'restart', 'status'] as const)(
'rejects single-agent %s for agents outside the roster',
async (action) => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
},
);
it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('passes a deterministic operator source label for agent sends', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'json-agent',
'--message',
'status check',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
getDefaultOperatorSourceLabel(),
'-s',
'json-agent',
'-m',
'status check',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('allows agent sends to override the source label explicitly', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'handoff',
'--source-label',
'lead:manual',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
'lead:manual',
'-s',
'coder0',
'-m',
'handoff',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects agent status typos before invoking the runner', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('keeps fleet framework assets in the published package file list', async () => {
const packageJson = JSON.parse(
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
) as {
files?: string[];
};
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
});
});
// ---------------------------------------------------------------------------
// Phase-2 observability — unit tests (FR-1, FR-3, FR-5, FR-6)
// ---------------------------------------------------------------------------
describe('fleet ps — command construction', () => {
it('builds exact systemd show command for an agent unit', () => {
expect(buildSystemdShowCommand('canary-pi')).toEqual([
'systemctl',
'--user',
'show',
'mosaic-agent@canary-pi.service',
'-p',
'ActiveState',
'-p',
'SubState',
'-p',
'UnitFileState',
]);
});
it('builds exact tmux list-panes command with the correct format string', () => {
expect(buildTmuxListPanesCommand('canary-pi', 'mosaic-factory')).toEqual([
'tmux',
'-L',
'mosaic-factory',
'list-panes',
'-t',
'=canary-pi:0.0',
'-F',
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}',
]);
});
it('uses DEFAULT_SOCKET_NAME when socket is omitted from list-panes', () => {
const cmd = buildTmuxListPanesCommand('canary-pi');
expect(cmd[2]).toBe('mosaic-factory');
});
it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => {
const home = '/home/test/.config/mosaic';
expect(heartbeatPath('canary-pi', home)).toBe(
'/home/test/.config/mosaic/fleet/run/canary-pi.hb',
);
});
});
describe('fleet ps — heartbeat parsing', () => {
const NOW = 1_700_000_000_000; // fixed epoch ms for deterministic tests
it('parses a healthy heartbeat file', () => {
const ts = new Date(NOW - 10_000).toISOString(); // 10s ago — within 3×15s = 45s
const content = `ts=${ts}\npid=12345\nstatus=ok\n`;
const hb = parseHeartbeat(content, NOW);
expect(hb.health).toBe('healthy');
expect(hb.pid).toBe(12345);
expect(hb.status).toBe('ok');
expect(hb.ageMs).toBe(10_000);
});
it('reports stale when heartbeat is older than 3×interval', () => {
const ts = new Date(NOW - 60_000).toISOString(); // 60s ago > 45s threshold
const content = `ts=${ts}\npid=99\nstatus=busy\n`;
const hb = parseHeartbeat(content, NOW);
expect(hb.health).toBe('stale');
expect(hb.status).toBe('busy');
});
it('reports unknown when heartbeat file is missing (null input)', () => {
const hb = parseHeartbeat(null, NOW);
expect(hb.health).toBe('unknown');
expect(hb.ts).toBeNull();
expect(hb.pid).toBeNull();
expect(hb.ageMs).toBeNull();
});
it('tolerates missing fields in heartbeat file', () => {
const hb = parseHeartbeat('ts=not-a-date\n', NOW);
expect(hb.health).toBe('unknown');
expect(hb.ts).toBeNull();
});
});
describe('fleet ps — systemd show parsing', () => {
it('parses ActiveState, SubState, UnitFileState from systemctl show output', () => {
const output = 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n';
expect(parseSystemdShow(output)).toEqual({
ActiveState: 'active',
SubState: 'running',
UnitFileState: 'enabled',
});
});
it('defaults missing keys to "unknown"', () => {
const result = parseSystemdShow('ActiveState=inactive\n');
expect(result.SubState).toBe('unknown');
expect(result.UnitFileState).toBe('unknown');
});
});
describe('fleet ps — tmux list-panes parsing', () => {
const NOW_MS = 1_700_000_000_000;
it('parses alive pane with pid, command, and idle time', () => {
const activityEpoch = Math.floor((NOW_MS - 30_000) / 1000); // 30s ago
const output = `12345 claude 0 ${activityEpoch}\n`;
const result = parseTmuxListPanes(output, NOW_MS);
expect(result.pid).toBe(12345);
expect(result.command).toBe('claude');
expect(result.dead).toBe(false);
expect(result.idleSeconds).toBe(30);
});
it('reports dead pane when pane_dead=1', () => {
const output = `0 bash 1 0\n`;
const result = parseTmuxListPanes(output, NOW_MS);
expect(result.dead).toBe(true);
});
it('returns nulls for empty pane output', () => {
const result = parseTmuxListPanes('', NOW_MS);
expect(result.pid).toBeNull();
expect(result.command).toBeNull();
expect(result.dead).toBe(true);
expect(result.idleSeconds).toBeNull();
});
});
describe('fleet ps — drift detection', () => {
it('flags drift when roster says pi but pane runs python3', () => {
expect(detectDrift('pi', 'python3')).toBe(true);
});
it('flags drift when roster says claude but pane runs dogfood-agent.py', () => {
expect(detectDrift('claude', 'dogfood-agent.py')).toBe(true);
});
it('does NOT flag drift when pane command matches the roster runtime', () => {
expect(detectDrift('claude', 'claude')).toBe(false);
expect(detectDrift('codex', 'codex')).toBe(false);
expect(detectDrift('pi', 'pi')).toBe(false);
expect(detectDrift('opencode', 'opencode')).toBe(false);
});
it('does NOT flag drift for unknown/custom runtimes (no canonical mapping)', () => {
expect(detectDrift('custom-runtime', 'anything')).toBe(false);
});
it('does NOT flag drift when pane command is null (pane dead)', () => {
expect(detectDrift('pi', null)).toBe(false);
});
});
describe('fleet ps — tenant and host', () => {
it('returns tenant_id and host as non-empty strings', () => {
const { tenant_id, host } = getDefaultTenantAndHost();
expect(typeof tenant_id).toBe('string');
expect(tenant_id.length).toBeGreaterThan(0);
expect(typeof host).toBe('string');
expect(host.length).toBeGreaterThan(0);
});
});
describe('fleet ps — JSON output shape (FR-6)', () => {
it('produces --json records including tenant_id and host for each agent', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' class: canary',
].join('\n'),
);
const nowMs = Date.now();
const activityEpoch = Math.floor((nowMs - 20_000) / 1000);
const runner: CommandRunner = async (command, args) => {
const fullArgs = [command, ...args].join(' ');
if (fullArgs.includes('systemctl') && fullArgs.includes('show')) {
return {
stdout: 'ActiveState=active\nSubState=running\nUnitFileState=disabled\n',
stderr: '',
exitCode: 0,
};
}
if (fullArgs.includes('list-panes')) {
return {
stdout: `12345 python3 0 ${activityEpoch}\n`,
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
};
const lines: string[] = [];
const origLog = console.log;
console.log = (msg: string) => {
lines.push(msg);
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']);
} finally {
console.log = origLog;
await rm(home, { recursive: true, force: true });
}
const json = JSON.parse(lines.join('')) as AgentPsRow[];
expect(Array.isArray(json)).toBe(true);
expect(json).toHaveLength(1);
const row = json[0]!;
// FR-6: tenant_id and host must be present
expect(typeof row.tenant_id).toBe('string');
expect(row.tenant_id.length).toBeGreaterThan(0);
expect(typeof row.host).toBe('string');
expect(row.host.length).toBeGreaterThan(0);
// drift: roster says pi, pane runs python3 → drift flag
expect(row.driftFlag).toBe(true);
// boot-enable warning: active + disabled
expect(row.bootEnableWarning).toBe(true);
// heartbeat missing → unknown
expect(row.heartbeat.health).toBe('unknown');
expect(row.name).toBe('canary-pi');
expect(row.runtime).toBe('pi');
expect(row.systemdActive).toBe('active');
expect(row.systemdEnabled).toBe('disabled');
});
});
describe('fleet ps — command sequences issued', () => {
it('issues systemd show + tmux list-panes per agent', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return {
stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
stderr: '',
exitCode: 0,
};
};
// suppress console.log for table output
const origLog = console.log;
console.log = () => {};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
expect(calls).toEqual([
buildSystemdShowCommand('coder0'),
buildTmuxListPanesCommand('coder0', 'mosaic-factory'),
]);
} finally {
console.log = origLog;
await rm(home, { recursive: true, force: true });
}
});
});
describe('agent watch', () => {
it('builds exact grouped-viewer creation command', () => {
expect(
buildAgentWatchCreateViewerCommand('canary-pi', 'canary-pi-watch-123', 'mosaic-factory'),
).toEqual([
'tmux',
'-L',
'mosaic-factory',
'new-session',
'-d',
'-t',
'=canary-pi',
'-s',
'canary-pi-watch-123',
]);
});
it('builds exact viewer attach command (read-only)', () => {
expect(buildAgentWatchAttachCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([
'tmux',
'-L',
'mosaic-factory',
'attach',
'-r',
'-t',
'canary-pi-watch-123',
]);
});
it('builds exact viewer kill command', () => {
expect(buildAgentWatchKillViewerCommand('canary-pi-watch-123', 'mosaic-factory')).toEqual([
'tmux',
'-L',
'mosaic-factory',
'kill-session',
'-t',
'canary-pi-watch-123',
]);
});
it('buildAgentWatchCommand (deprecated) still uses DEFAULT_SOCKET_NAME when socket is omitted', () => {
const cmd = buildAgentWatchCommand('canary-pi');
expect(cmd[2]).toBe('mosaic-factory');
expect(cmd).toContain('-r');
});
it('dispatch: creates grouped viewer session (runner) then attaches -r to viewer session (interactiveRunner), NOT a bare attach to the agent session', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const capturingCalls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
capturingCalls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const interactiveCalls: string[][] = [];
const interactiveRunner: InteractiveRunner = async (command, args) => {
interactiveCalls.push([command, ...args]);
return 0;
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, interactiveRunner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'coder0']);
// The capturing runner must be used for grouped-session creation and cleanup.
// It must NOT be used for the interactive attach.
expect(capturingCalls).toHaveLength(2); // new-session + kill-session
expect(capturingCalls[0]).toEqual(
expect.arrayContaining(['new-session', '-d', '-t', '=coder0']),
);
// The new-session command must include a viewer session name derived from agent name.
expect(capturingCalls[0]!.join(' ')).toMatch(/coder0-watch-\d+/);
// Kill-session must target the same viewer session, not the agent session.
expect(capturingCalls[1]).toEqual(expect.arrayContaining(['kill-session', '-t']));
expect(capturingCalls[1]!.join(' ')).toMatch(/coder0-watch-\d+/);
// The agent session itself must NOT be the attach target.
expect(capturingCalls[1]!.join(' ')).not.toContain('=coder0');
// The interactiveRunner must attach -r to the VIEWER session, not the agent session.
expect(interactiveCalls).toHaveLength(1);
expect(interactiveCalls[0]).toEqual(expect.arrayContaining(['attach', '-r', '-t']));
// Target must be the viewer session name (not "=coder0").
const attachTarget = interactiveCalls[0]![interactiveCalls[0]!.indexOf('-t') + 1]!;
expect(attachTarget).toMatch(/coder0-watch-\d+/);
expect(attachTarget).not.toBe('=coder0');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects watch for agents not in the roster', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('agent send --verify', () => {
it('builds exact verify capture-pane command', () => {
expect(buildAgentVerifyAcceptedCommand('canary-pi', 'mosaic-factory', 5)).toEqual([
'tmux',
'-L',
'mosaic-factory',
'capture-pane',
'-t',
'=canary-pi:0.0',
'-p',
'-S',
'-5',
]);
});
it('isSendAccepted: returns "accepted" for normal response output', () => {
expect(isSendAccepted('Some response text\nAnother line\n')).toBe('accepted');
});
it('isSendAccepted: returns "draft" when last line starts with "> " (draft pattern)', () => {
expect(isSendAccepted('> my unsent message')).toBe('draft');
});
it('isSendAccepted: returns "unverifiable" for blank/empty pane (full-screen TUI case)', () => {
expect(isSendAccepted('')).toBe('unverifiable');
expect(isSendAccepted(' \n \n')).toBe('unverifiable');
});
// ---------------------------------------------------------------------------
// classifySendResult — BEFORE/AFTER pane-diff classifier (regression suite)
// ---------------------------------------------------------------------------
describe('classifySendResult (BEFORE/AFTER pane-diff classifier)', () => {
it('returns "accepted" when AFTER differs from BEFORE and AFTER has no draft line', () => {
const before = 'Old content from prior interaction\n';
const after = 'Old content from prior interaction\nAgent response: task complete.\n';
expect(classifySendResult(before, after)).toBe('accepted');
});
it('returns "draft" when AFTER differs from BEFORE and AFTER ends in a draft line', () => {
const before = 'Previous output\n';
const after = 'Previous output\n> unsent message\n';
expect(classifySendResult(before, after)).toBe('draft');
});
it('returns "unverifiable" when AFTER is blank/empty (full-screen TUI blank render)', () => {
const before = 'Some previous content\n';
expect(classifySendResult(before, '')).toBe('unverifiable');
expect(classifySendResult(before, ' \n \n')).toBe('unverifiable');
});
it('returns "unverifiable" when AFTER == BEFORE (stale/wedged pane — no change after send)', () => {
const staleContent = 'Old non-empty content that never changed\n';
expect(classifySendResult(staleContent, staleContent)).toBe('unverifiable');
});
it('returns "unverifiable" when both BEFORE and AFTER are blank (both blank => no change)', () => {
expect(classifySendResult('', '')).toBe('unverifiable');
});
it('returns "accepted" when BEFORE is blank and AFTER has non-draft content (pane woke up)', () => {
expect(classifySendResult('', 'Agent is now responding.\n')).toBe('accepted');
});
});
it('issues BEFORE-capture then send then AFTER-capture (3 calls) when --verify is passed', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
let callIndex = 0;
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
const idx = callIndex++;
if ([command, ...args].join(' ').includes('agent-send.sh')) {
return { stdout: '', stderr: '', exitCode: 0 };
}
// BEFORE capture: return old content; AFTER capture: return new content
const stdout = idx === 0 ? 'Old pane content\n' : 'New response from agent\n';
return { stdout, stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'hello world',
'--verify',
]);
// 3 calls: BEFORE-capture, send, AFTER-capture
expect(calls).toHaveLength(3);
expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5));
expect(calls[1]![0]).toContain('agent-send.sh');
expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', 'mosaic-factory', 5));
} finally {
await rm(home, { recursive: true, force: true });
}
}, 10_000);
it('does NOT issue capture-pane verify when --verify is not passed', async () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'hello world',
]);
// Only 1 call: agent-send.sh (no capture-pane)
expect(calls).toHaveLength(1);
expect(calls[0]![0]).toContain('agent-send.sh');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('send --verify: AFTER==BEFORE (stale/wedged pane) sets process.exitCode=1 (unverifiable)', async () => {
const originalExitCode = process.exitCode;
const stderrMessages: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
stderrMessages.push(String(msg));
return true;
});
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner: CommandRunner = async (command, args) => {
const full = [command, ...args].join(' ');
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
// BEFORE and AFTER are identical non-empty stale content — simulates a wedged pane
return { stdout: 'Stale old content that never changed\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'hello',
'--verify',
]);
expect(process.exitCode).toBe(1);
// Must mention "no pane change" to distinguish from blank-capture case
expect(stderrMessages.join('')).toMatch(/no pane change after send/i);
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
await rm(home, { recursive: true, force: true });
}
}, 10_000);
it('send --verify: blank AFTER capture sets process.exitCode=1 (unverifiable, fails closed)', async () => {
const originalExitCode = process.exitCode;
const stderrMessages: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
stderrMessages.push(String(msg));
return true;
});
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
let captureCallCount = 0;
const runner: CommandRunner = async (command, args) => {
const full = [command, ...args].join(' ');
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
captureCallCount++;
// BEFORE: some content; AFTER: blank (full-screen TUI renders blank after send)
const stdout = captureCallCount === 1 ? 'Previous content\n' : '';
return { stdout, stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'hello',
'--verify',
]);
expect(process.exitCode).toBe(1);
expect(stderrMessages.join('')).toMatch(/could not verify delivery/i);
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
await rm(home, { recursive: true, force: true });
}
}, 10_000);
it('send --verify: AFTER differs from BEFORE with draft line sets process.exitCode=1', async () => {
const originalExitCode = process.exitCode;
const stderrMessages: string[] = [];
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
stderrMessages.push(String(msg));
return true;
});
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
let captureCallCount = 0;
const runner: CommandRunner = async (command, args) => {
const full = [command, ...args].join(' ');
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
captureCallCount++;
// BEFORE: old content; AFTER: message appeared but ended as a draft line
const stdout = captureCallCount === 1 ? 'Previous output\n' : '> unsent message\n';
return { stdout, stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'hello',
'--verify',
]);
expect(process.exitCode).toBe(1);
expect(stderrMessages.join('')).toMatch(/unsubmitted draft/i);
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
await rm(home, { recursive: true, force: true });
}
}, 10_000);
it('send --verify: AFTER differs from BEFORE with real response content sets exitCode=0 (accepted)', async () => {
const originalExitCode = process.exitCode;
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
let captureCallCount = 0;
const runner: CommandRunner = async (command, args) => {
const full = [command, ...args].join(' ');
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
captureCallCount++;
// BEFORE: old content; AFTER: new response content (pane changed)
const stdout =
captureCallCount === 1
? 'Old pane content\n'
: 'Old pane content\nAgent response: task completed.\n';
return { stdout, stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'hello',
'--verify',
]);
// exitCode should remain unchanged (not set to 1)
expect(process.exitCode).toBe(originalExitCode);
} finally {
process.exitCode = originalExitCode;
await rm(home, { recursive: true, force: true });
}
}, 10_000);
});