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
1524 lines
49 KiB
TypeScript
1524 lines
49 KiB
TypeScript
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);
|
||
});
|