Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/__tests__/cli-doctor-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { runCliCapture } from './cli-capture.ts';

/**
* The human `doctor` run, end to end over the CLI's own progress wiring: the
* transport hands `command` progress to the sink the CLI installed, which
* renders it to stderr and records that it did, and the output formatter then
* prints the summary alone rather than repeating every check the reader has
* already seen. Without progress it prints the checks.
*/

const DOCTOR_RESULT = {
status: 'pass',
summary: 'No blockers found.',
checks: [
{
id: 'agent-device',
status: 'pass',
summary: 'agent-device 0.17.9 using /tmp/agent-device',
},
{ id: 'device', status: 'warn', summary: 'No booted device.' },
],
};

test('doctor prints only the summary when progress already streamed the checks', async () => {
const result = await runCliCapture(['doctor'], async (_req, options) => {
options?.onProgress?.({
type: 'command',
status: 'progress',
message: '✓ agent-device: agent-device 0.17.9 using /tmp/agent-device',
});
options?.onProgress?.({
type: 'command',
status: 'progress',
message: '! device: No booted device.',
});
return { ok: true, data: DOCTOR_RESULT };
});

assert.equal(result.code, null);
assert.match(
result.stderr,
/✓ agent-device: agent-device 0\.17\.9 using \/tmp\/agent-device\n! device: No booted device\.\n/,
);
assert.match(result.stdout, /Doctor: pass\nNo blockers found\.\n/);
assert.doesNotMatch(result.stdout, /✓ agent-device:/);
});

test('doctor prints the checks when no progress reached stderr', async () => {
const result = await runCliCapture(['doctor'], async () => ({
ok: true,
data: DOCTOR_RESULT,
}));

assert.equal(result.code, null);
assert.equal(result.stderr, '');
assert.match(result.stdout, /✓ agent-device: agent-device 0\.17\.9 using \/tmp\/agent-device/);
assert.match(result.stdout, /! device: No booted device\./);
});
24 changes: 19 additions & 5 deletions src/__tests__/daemon-client-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ test('readDaemonSocketProgressResponse forwards split progress lines before resp
}
});

test('readDaemonSocketProgressResponse renders generic command progress', async () => {
test('readDaemonSocketProgressResponse hands generic command progress to the sink, rendering none itself', async () => {
const socket = createMockSocket();
const req: DaemonRequest = {
session: 'default',
Expand All @@ -181,7 +181,8 @@ test('readDaemonSocketProgressResponse renders generic command progress', async
return true;
}) as typeof process.stderr.write;

const responsePromise = readSocketProgressResponse(socket, req);
const events: RequestProgressEvent[] = [];
const responsePromise = readSocketProgressResponse(socket, req, (event) => events.push(event));
socket.emit(
'data',
`${JSON.stringify({
Expand All @@ -202,7 +203,12 @@ test('readDaemonSocketProgressResponse renders generic command progress', async
);

assert.deepEqual(await responsePromise, { ok: true, data: { via: 'command-progress' } });
assert.equal(stderr, 'Building Apple runner...\n');
// Rendering belongs to the sink the caller installs (the CLI's is
// `createStderrCommandProgressSink`); the transport writes nothing itself.
assert.equal(stderr, '');
assert.deepEqual(events, [
{ type: 'command', status: 'progress', message: 'Building Apple runner...' },
]);
} finally {
process.stderr.write = originalStderrWrite;
}
Expand Down Expand Up @@ -253,7 +259,7 @@ test('readDaemonSocketProgressResponse forwards replay progress events to the si
}
});

test('readDaemonSocketProgressResponse does not render replay progress without a sink', async () => {
test('readDaemonSocketProgressResponse renders nothing without a sink', async () => {
const socket = createMockSocket();
const req: DaemonRequest = {
session: 'default',
Expand Down Expand Up @@ -302,12 +308,20 @@ test('readDaemonSocketProgressResponse does not render replay progress without a
durationMs: 17_800,
},
});
const command = JSON.stringify({
type: 'progress',
event: {
type: 'command',
status: 'progress',
message: 'Building Apple runner...',
},
});
const responseLine = JSON.stringify({
type: 'response',
response: { ok: true, data: { via: 'socket-progress' } },
});

socket.emit('data', `${progress}\n${pass}\n${responseLine}\n`);
socket.emit('data', `${progress}\n${pass}\n${command}\n${responseLine}\n`);

assert.deepEqual(await responsePromise, { ok: true, data: { via: 'socket-progress' } });
assert.equal(stderr, '');
Expand Down
29 changes: 24 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { sendToDaemon } from './daemon/client/daemon-client.ts';
import fs from 'node:fs';
import type { BatchStep } from '@agent-device/contracts/client';
import type { ReplayTestReporterRuntime } from './cli/replay-test/reporting.ts';
import {
createCommandProgressState,
createStderrCommandProgressSink,
type CommandProgressState,
} from './commands/command-progress.ts';
import {
createAgentDeviceClient,
type AgentDeviceClientConfig,
Expand Down Expand Up @@ -170,15 +175,17 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
}
logTailStopper = maybeStartDaemonLogTail(ctx);
const replayTestReporterRuntime = await createReplayReporterForTest(ctx);
const commandProgress = createCommandProgressState();
const client = createAgentDeviceClient(buildClientConfig(ctx), {
transport: createCliDaemonTransport({
command,
flags: ctx.effectiveFlags,
replayTestReporterRuntime,
commandProgress,
transport: deps.sendToDaemon,
}),
});
await dispatchCliCommand(ctx, client, replayTestReporterRuntime);
await dispatchCliCommand(ctx, client, replayTestReporterRuntime, commandProgress);
} catch (error) {
await handleRunCliFailure(error, ctx, logTailStopper);
} finally {
Expand Down Expand Up @@ -502,6 +509,7 @@ async function dispatchCliCommand(
ctx: CliRunContext,
client: ReturnType<typeof createAgentDeviceClient>,
replayTestReporterRuntime: ReplayTestReporterRuntime | undefined,
commandProgress: CommandProgressState,
): Promise<void> {
const { command, positionals, effectiveFlags } = ctx;
if (command === 'batch') {
Expand All @@ -528,6 +536,7 @@ async function dispatchCliCommand(
client,
debug: ctx.debugOutputEnabled,
replayTestReporterRuntime,
commandProgress,
})
) {
return;
Expand All @@ -545,6 +554,7 @@ async function dispatchCliCommand(
client,
debug: ctx.debugOutputEnabled,
replayTestReporterRuntime,
commandProgress,
})
) {
return;
Expand Down Expand Up @@ -795,19 +805,28 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set<FlagKey>): boole
return false;
}

/**
* The human CLI renders streamed progress itself: `test` hands its events to the
* replay-test reporter, every other command streams `command` lines to stderr
* through `commandProgress`, whose state the output formatters then read (a
* doctor summary does not repeat checks progress already printed). `--json` opts
* out of progress entirely, so it installs no sink.
*/
function createCliDaemonTransport(options: {
command: string;
flags: CliFlags;
replayTestReporterRuntime?: ReplayTestReporterRuntime;
commandProgress: CommandProgressState;
transport: CliDaemonTransport;
}): AgentDeviceDaemonTransport {
const { command, flags, replayTestReporterRuntime, transport } = options;
if (flags.json) return createClientDaemonTransport(transport);
const onProgress =
command === 'test' && replayTestReporterRuntime
? replayTestReporterRuntime.onProgress
: createStderrCommandProgressSink(options.commandProgress);
return async (req, context) => {
const transportOptions =
command === 'test' && replayTestReporterRuntime
? { ...context, onProgress: replayTestReporterRuntime.onProgress }
: context;
const transportOptions = { ...context, onProgress };
return await sendClientRequestToCliTransport(
transport,
{
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ export async function runGenericClientBackedCommand({
client,
debug,
replayTestReporterRuntime,
commandProgress,
}: ClientCommandParams & { command: ClientBackedCliCommandName }): Promise<boolean> {
const { result, cliOutput } = await runCliCommandWithOutput({
client,
command: command as CommandName,
positionals,
flags,
commandProgress,
});
// A non-default responseLevel returns a leveled payload (e.g. the snapshot
// digest { nodeCount, refs }) that the per-command CLI formatters assume away —
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/router-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CliFlags } from '@agent-device/contracts/command';
import type { AgentDeviceClient } from '../../agent-device-client.ts';
import type { CliCommandName } from '../../command-catalog.ts';
import type { CommandProgressState } from '../../commands/command-progress.ts';
import type { ReplayTestReporterRuntime } from '../replay-test/reporting.ts';

export type ClientCommandParams = {
Expand All @@ -9,6 +10,8 @@ export type ClientCommandParams = {
client: AgentDeviceClient;
debug?: boolean;
replayTestReporterRuntime?: ReplayTestReporterRuntime;
/** Progress this run's transport already rendered to stderr, read by the output formatters. */
commandProgress?: CommandProgressState;
};

/**
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function tryRunClientBackedCommand(params: {
client: AgentDeviceClient;
debug?: boolean;
replayTestReporterRuntime?: ClientCommandParams['replayTestReporterRuntime'];
commandProgress?: ClientCommandParams['commandProgress'];
}): Promise<boolean> {
const flags = { ...params.flags };
const loadDedicatedHandler =
Expand Down
2 changes: 1 addition & 1 deletion src/cli/replay-test/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
ReplayTestResult,
ReplayTestStep,
} from './reporters/types.ts';
import { formatCliStatusMarker } from '../../daemon/handlers/status-markers.ts';
import { formatCliStatusMarker } from '../../core/status-markers.ts';
import { formatDurationSeconds } from './duration-format.ts';
import { colorize, supportsColor } from '../../commands/output/color.ts';

Expand Down
2 changes: 1 addition & 1 deletion src/commands/capture/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function snapshotCliOutput(params: {
scope?: string;
depth?: number;
}): Promise<CliOutput> {
const { serializeSnapshotResult } = await import('../../daemon/result-serialization.ts');
const { serializeSnapshotResult } = await import('../output/result-serialization.ts');
// --raw is the full-fidelity escape hatch (e.g. rect fallback lookups): keep
// it byte-for-byte, undeduped. Every other presentation (default text and
// --json) collapses labels/identifiers that repeat an ancestor's value.
Expand Down
3 changes: 3 additions & 0 deletions src/commands/cli-output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { listCommandFamilyCliOutputFormatters } from './family/registry.ts';
import type { CliOutput } from './command-contract.ts';
import type { CommandProgressState } from './command-progress.ts';
import type { CliOutputFormatter } from './output-common.ts';
import type { CommandName } from './command-metadata.ts';

Expand All @@ -11,9 +12,11 @@ export async function formatCliOutput(params: {
name: CommandName;
input: unknown;
result: unknown;
progress?: CommandProgressState;
}): Promise<CliOutput | undefined> {
return await cliOutputFormatters[params.name]?.({
input: (params.input ?? {}) as Record<string, unknown>,
result: params.result,
progress: params.progress,
});
}
3 changes: 3 additions & 0 deletions src/commands/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { readInputFromCli } from './cli-grammar.ts';
import { runCommand, type CommandName } from './command-surface.ts';
import type { CliOutput } from './command-contract.ts';
import type { CliFlags } from '@agent-device/contracts/command';
import type { CommandProgressState } from './command-progress.ts';

type CliRunOptions = {
client: AgentDeviceClient;
command: CommandName;
positionals: string[];
flags: CliFlags;
commandProgress?: CommandProgressState;
};

export async function runCliCommand(options: CliRunOptions): Promise<CommandRequestResult> {
Expand All @@ -28,6 +30,7 @@ export async function runCliCommandWithOutput(options: CliRunOptions): Promise<{
name: options.command,
input,
result,
progress: options.commandProgress,
}),
};
}
49 changes: 49 additions & 0 deletions src/commands/command-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { createCommandProgressState, createStderrCommandProgressSink } from './command-progress.ts';

function captureStderr(run: () => void): string {
let stderr = '';
const originalWrite = process.stderr.write.bind(process.stderr);
(process.stderr as { write: unknown }).write = ((chunk: unknown) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
try {
run();
} finally {
process.stderr.write = originalWrite;
}
return stderr;
}

test('the stderr sink renders command progress and records that it did', () => {
const state = createCommandProgressState();
const sink = createStderrCommandProgressSink(state);
assert.equal(state.renderedToStderr, false);

const stderr = captureStderr(() => {
sink({ type: 'command', status: 'progress', message: 'Building Apple runner...' });
});

assert.equal(stderr, 'Building Apple runner...\n');
assert.equal(state.renderedToStderr, true);
});

test('the stderr sink leaves reporter-owned event types alone', () => {
const state = createCommandProgressState();
const sink = createStderrCommandProgressSink(state);

const stderr = captureStderr(() => {
sink({
type: 'replay-test',
file: '/tmp/01-login.ad',
status: 'pass',
index: 1,
total: 1,
});
});

assert.equal(stderr, '');
assert.equal(state.renderedToStderr, false);
});
33 changes: 33 additions & 0 deletions src/commands/command-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { RequestProgressEvent, RequestProgressSink } from '@agent-device/contracts/progress';

/**
* Whether streamed `command` progress has already been rendered for the human
* reader of this CLI run.
*
* A command whose final output would repeat what progress already said reads it
* and prints the shorter form instead (`doctorCliOutput`). The state is handed
* to the formatter with the result, so a caller that renders progress somewhere
* else — an SDK or MCP consumer passing its own `RequestProgressSink`, which
* writes nothing to this process's stderr — is never told progress was rendered
* here, and gets the full output.
*/
export type CommandProgressState = {
renderedToStderr: boolean;
};

export function createCommandProgressState(): CommandProgressState {
return { renderedToStderr: false };
}

/**
* The CLI's own progress sink: `command` progress lines go to stderr as they
* arrive, and `state` records that they did. Other event types belong to their
* own reporters (replay-test) and are not rendered here.
*/
export function createStderrCommandProgressSink(state: CommandProgressState): RequestProgressSink {
return (event: RequestProgressEvent) => {
if (event.type !== 'command') return;
state.renderedToStderr = true;
process.stderr.write(`${event.message}\n`);
};
}
Loading
Loading