diff --git a/src/__tests__/cli-doctor-progress.test.ts b/src/__tests__/cli-doctor-progress.test.ts new file mode 100644 index 000000000..3f93aebcd --- /dev/null +++ b/src/__tests__/cli-doctor-progress.test.ts @@ -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\./); +}); diff --git a/src/__tests__/daemon-client-progress.test.ts b/src/__tests__/daemon-client-progress.test.ts index 96b05feae..6fc408c08 100644 --- a/src/__tests__/daemon-client-progress.test.ts +++ b/src/__tests__/daemon-client-progress.test.ts @@ -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', @@ -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({ @@ -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; } @@ -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', @@ -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, ''); diff --git a/src/cli.ts b/src/cli.ts index 3fad4d174..db136e1f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, @@ -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 { @@ -502,6 +509,7 @@ async function dispatchCliCommand( ctx: CliRunContext, client: ReturnType, replayTestReporterRuntime: ReplayTestReporterRuntime | undefined, + commandProgress: CommandProgressState, ): Promise { const { command, positionals, effectiveFlags } = ctx; if (command === 'batch') { @@ -528,6 +536,7 @@ async function dispatchCliCommand( client, debug: ctx.debugOutputEnabled, replayTestReporterRuntime, + commandProgress, }) ) { return; @@ -545,6 +554,7 @@ async function dispatchCliCommand( client, debug: ctx.debugOutputEnabled, replayTestReporterRuntime, + commandProgress, }) ) { return; @@ -795,19 +805,28 @@ function hasExplicitMetroRuntimeOverrides(explicitFlagKeys: Set): 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, { diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 95479be89..29e206645 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -17,12 +17,14 @@ export async function runGenericClientBackedCommand({ client, debug, replayTestReporterRuntime, + commandProgress, }: ClientCommandParams & { command: ClientBackedCliCommandName }): Promise { 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 — diff --git a/src/cli/commands/router-types.ts b/src/cli/commands/router-types.ts index 5c1bc282f..d95e1c6ca 100644 --- a/src/cli/commands/router-types.ts +++ b/src/cli/commands/router-types.ts @@ -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 = { @@ -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; }; /** diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index da787a1ab..0d717d52c 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -29,6 +29,7 @@ export async function tryRunClientBackedCommand(params: { client: AgentDeviceClient; debug?: boolean; replayTestReporterRuntime?: ClientCommandParams['replayTestReporterRuntime']; + commandProgress?: ClientCommandParams['commandProgress']; }): Promise { const flags = { ...params.flags }; const loadDedicatedHandler = diff --git a/src/cli/replay-test/progress.ts b/src/cli/replay-test/progress.ts index 5cf4c1966..fa81d8383 100644 --- a/src/cli/replay-test/progress.ts +++ b/src/cli/replay-test/progress.ts @@ -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'; diff --git a/src/commands/capture/output.ts b/src/commands/capture/output.ts index 809a1de73..82bb54dd4 100644 --- a/src/commands/capture/output.ts +++ b/src/commands/capture/output.ts @@ -11,7 +11,7 @@ export async function snapshotCliOutput(params: { scope?: string; depth?: number; }): Promise { - 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. diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index 177317c2f..10370e464 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -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'; @@ -11,9 +12,11 @@ export async function formatCliOutput(params: { name: CommandName; input: unknown; result: unknown; + progress?: CommandProgressState; }): Promise { return await cliOutputFormatters[params.name]?.({ input: (params.input ?? {}) as Record, result: params.result, + progress: params.progress, }); } diff --git a/src/commands/cli-runner.ts b/src/commands/cli-runner.ts index e54034907..6d2718bd9 100644 --- a/src/commands/cli-runner.ts +++ b/src/commands/cli-runner.ts @@ -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 { @@ -28,6 +30,7 @@ export async function runCliCommandWithOutput(options: CliRunOptions): Promise<{ name: options.command, input, result, + progress: options.commandProgress, }), }; } diff --git a/src/commands/command-progress.test.ts b/src/commands/command-progress.test.ts new file mode 100644 index 000000000..74c034855 --- /dev/null +++ b/src/commands/command-progress.test.ts @@ -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); +}); diff --git a/src/commands/command-progress.ts b/src/commands/command-progress.ts new file mode 100644 index 000000000..2b981a846 --- /dev/null +++ b/src/commands/command-progress.ts @@ -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`); + }; +} diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index 8dce567ea..9b974c3e4 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from 'vitest'; import { doctorCliOutput, managementCliOutputFormatters, openCliOutput } from './output.ts'; import { withNoColorAsync } from '../../__tests__/test-utils/color.ts'; import type { AppOpenResult } from '@agent-device/contracts/client'; -import { markDoctorProgressRendered } from '../../daemon/client/doctor-progress.ts'; describe('openCliOutput', () => { test('prints session state directory on a second line', async () => { @@ -307,12 +306,33 @@ describe('doctorCliOutput', () => { }, ], }; - markDoctorProgressRendered(); - return await doctorCliOutput(result); + return await doctorCliOutput(result, { renderedToStderr: true }); }); expect(output.text).toBe(['Doctor: pass', 'No blockers found.'].join('\n')); }); + + test('keeps the checks when a caller-owned sink consumed the progress events', async () => { + // An SDK or MCP caller passing its own `RequestProgressSink` writes nothing to + // this process's stderr, so its progress state stays unrendered and the reader + // of this output has not seen the checks yet. + const output = await withNoColorAsync(async () => { + const result = { + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android)', + }, + ], + }; + return await doctorCliOutput(result, { renderedToStderr: false }); + }); + + expect(output.text).toBe(['Doctor: pass', '✓ device: Selected Pixel (android)'].join('\n')); + }); }); describe('devices output', () => { diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 7620ab463..70b9f96cb 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -17,6 +17,7 @@ import type { } from '@agent-device/contracts/observability'; import { readCommandMessage } from '@agent-device/kernel/success-text'; import { snapshotCliOutput } from '../capture/output.ts'; +import type { CommandProgressState } from '../command-progress.ts'; import type { CliOutput } from '../command-contract.ts'; import { type CliOutputFormatter, @@ -26,13 +27,13 @@ import { } from '../output-common.ts'; async function devicesCliOutput(result: AgentDeviceDevice[]): Promise { - const { serializeDevice } = await import('../../daemon/result-serialization.ts'); + const { serializeDevice } = await import('../output/result-serialization.ts'); const data = { devices: result.map(serializeDevice) }; return { data, text: result.map(formatDeviceLine).join('\n') }; } async function capabilitiesCliOutput(result: AgentDeviceCapabilitiesResult): Promise { - const { serializeDevice } = await import('../../daemon/result-serialization.ts'); + const { serializeDevice } = await import('../output/result-serialization.ts'); const data = { device: serializeDevice(result.device), availableCommands: result.availableCommands, @@ -78,13 +79,13 @@ async function sessionCliOutput( if ('stateDir' in result) { return { data: result, text: result.stateDir }; } - const { serializeSessionListEntry } = await import('../../daemon/result-serialization.ts'); + const { serializeSessionListEntry } = await import('../output/result-serialization.ts'); const data = { sessions: result.sessions.map(serializeSessionListEntry) }; return { data, text: JSON.stringify(data, null, 2) }; } export async function openCliOutput(result: AppOpenResult): Promise { - const { serializeOpenResult } = await import('../../daemon/result-serialization.ts'); + const { serializeOpenResult } = await import('../output/result-serialization.ts'); const data = serializeOpenResult(result); const lines = [readCommandMessage(data)].filter((line): line is string => Boolean(line)); if (typeof data.sessionStateDir === 'string') { @@ -120,7 +121,7 @@ async function buildOpenInitialSnapshotOutput( } async function closeCliOutput(result: AppCloseResult | SessionCloseResult): Promise { - const { serializeCloseResult } = await import('../../daemon/result-serialization.ts'); + const { serializeCloseResult } = await import('../output/result-serialization.ts'); return messageCliOutput(serializeCloseResult(result)); } @@ -152,12 +153,12 @@ function isDaemonArtifactsResult(result: AgentArtifactsResult): result is Daemon } async function deployCliOutput(result: AppDeployResult): Promise { - const { serializeDeployResult } = await import('../../daemon/result-serialization.ts'); + const { serializeDeployResult } = await import('../output/result-serialization.ts'); return messageCliOutput(serializeDeployResult(result)); } async function installFromSourceCliOutput(result: AppInstallFromSourceResult): Promise { - const { serializeInstallFromSourceResult } = await import('../../daemon/result-serialization.ts'); + const { serializeInstallFromSourceResult } = await import('../output/result-serialization.ts'); return messageCliOutput(serializeInstallFromSourceResult(result)); } @@ -181,16 +182,20 @@ function shutdownCliOutput(result: CommandRequestResult): CliOutput { return { data, text: `${status}: ${device} (${platform})` }; } -export async function doctorCliOutput(result: CommandRequestResult): Promise { - const { consumeDoctorProgressRendered } = await import('../../daemon/client/doctor-progress.ts'); +export async function doctorCliOutput( + result: CommandRequestResult, + progress?: CommandProgressState, +): Promise { const { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } = - await import('../../daemon/handlers/doctor-output.ts'); + await import('../../core/doctor-output.ts'); const data = result as Record; const status = typeof data.status === 'string' ? data.status : 'unknown'; const lines = [`Doctor: ${status}`]; const checks = readDoctorChecks(data.checks); - if (consumeDoctorProgressRendered()) { + // Progress streamed the per-check lines to stderr already; repeating them + // below the summary would print every check twice. + if (progress?.renderedToStderr) { const summary = typeof data.summary === 'string' ? data.summary : undefined; if (summary) lines.push(summary); } else if (checks.length === 0) { @@ -210,7 +215,7 @@ export const managementCliOutputFormatters = { shutdown: resultOutput(shutdownCliOutput), devices: resultOutput(devicesCliOutput), capabilities: resultOutput(capabilitiesCliOutput), - doctor: resultOutput(doctorCliOutput), + doctor: ({ result, progress }) => doctorCliOutput(result as CommandRequestResult, progress), apps: ({ input, result }) => appsCliOutput({ result: result as Parameters[0]['result'], diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts index 34f0e17b4..f2f1e72a4 100644 --- a/src/commands/output-common.ts +++ b/src/commands/output-common.ts @@ -1,14 +1,26 @@ import { readCommandMessage } from '@agent-device/kernel/success-text'; +import type { CommandProgressState } from './command-progress.ts'; import type { CliOutput } from './command-contract.ts'; -export type CliOutputFormatter = (params: { +export type CliOutputFormatterParams = { input: Record; result: unknown; -}) => CliOutput | Promise; + /** + * Progress already rendered for this run, when the caller renders progress + * itself. Absent for a caller that streams progress somewhere the human + * reader of this output will not see (MCP, an SDK sink) — and for one that + * asked for no progress at all. + */ + progress?: CommandProgressState; +}; + +export type CliOutputFormatter = ( + params: CliOutputFormatterParams, +) => CliOutput | Promise; export function resultOutput = CliOutput>( formatter: (result: TResult) => TOutput, -): (params: { input: Record; result: unknown }) => TOutput { +): (params: CliOutputFormatterParams) => TOutput { return ({ result }) => formatter(result as TResult); } diff --git a/src/daemon/__tests__/result-serialization.test.ts b/src/commands/output/result-serialization.test.ts similarity index 69% rename from src/daemon/__tests__/result-serialization.test.ts rename to src/commands/output/result-serialization.test.ts index cdccd36cd..f4ffe92ce 100644 --- a/src/daemon/__tests__/result-serialization.test.ts +++ b/src/commands/output/result-serialization.test.ts @@ -1,40 +1,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { - resolveDeployResultTarget, - resolveInstallFromSourceResultTarget, serializeInstallFromSourceResult, serializeOpenResult, serializeSessionListEntry, -} from '../result-serialization.ts'; - -test('resolveDeployResultTarget follows the public app target precedence', () => { - assert.equal( - resolveDeployResultTarget({ app: 'fallback', bundleId: 'com.example.app', package: 'example' }), - 'com.example.app', - ); - assert.equal(resolveDeployResultTarget({ app: 'fallback', package: 'example' }), 'example'); - assert.equal(resolveDeployResultTarget({ app: 'fallback' }), 'fallback'); -}); - -test('resolveInstallFromSourceResultTarget follows the public install target precedence', () => { - assert.equal( - resolveInstallFromSourceResultTarget({ - launchTarget: 'com.example.app', - appName: 'Demo', - bundleId: 'com.example.bundle', - packageName: 'com.example.package', - }), - 'Demo', - ); - assert.equal( - resolveInstallFromSourceResultTarget({ - launchTarget: 'com.example.app', - packageName: 'com.example.package', - }), - 'com.example.package', - ); -}); +} from './result-serialization.ts'; test('serializeSessionListEntry preserves legacy android session payload shape', () => { const data = serializeSessionListEntry({ diff --git a/src/daemon/result-serialization.ts b/src/commands/output/result-serialization.ts similarity index 93% rename from src/daemon/result-serialization.ts rename to src/commands/output/result-serialization.ts index 595257a30..81b6b3e15 100644 --- a/src/daemon/result-serialization.ts +++ b/src/commands/output/result-serialization.ts @@ -12,6 +12,10 @@ import type { import { publicSnapshotCaptureAnnotations } from '@agent-device/contracts/capture'; import { isSerialAddressablePlatform } from '@agent-device/kernel/device'; import { successText, withSuccessText } from '@agent-device/kernel/success-text'; +import { + resolveDeployResultTarget, + resolveInstallFromSourceResultTarget, +} from '../../core/deploy-result-target.ts'; function serializeSessionDevice( device: AgentDeviceSessionDevice, @@ -86,14 +90,6 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record { return withSuccessText( { @@ -108,15 +104,6 @@ export function serializeDeployResult(result: AppDeployResult): Record { diff --git a/src/daemon/__tests__/snapshot-serialization.test.ts b/src/commands/output/snapshot-serialization.test.ts similarity index 97% rename from src/daemon/__tests__/snapshot-serialization.test.ts rename to src/commands/output/snapshot-serialization.test.ts index 1294cad44..ec3fb9d8c 100644 --- a/src/daemon/__tests__/snapshot-serialization.test.ts +++ b/src/commands/output/snapshot-serialization.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { serializeSnapshotResult } from '../result-serialization.ts'; +import { serializeSnapshotResult } from './result-serialization.ts'; test('serializeSnapshotResult includes Android backend metadata', () => { const data = serializeSnapshotResult({ diff --git a/src/core/deploy-result-target.test.ts b/src/core/deploy-result-target.test.ts new file mode 100644 index 000000000..8c40f8137 --- /dev/null +++ b/src/core/deploy-result-target.test.ts @@ -0,0 +1,34 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + resolveDeployResultTarget, + resolveInstallFromSourceResultTarget, +} from './deploy-result-target.ts'; + +test('resolveDeployResultTarget follows the public app target precedence', () => { + assert.equal( + resolveDeployResultTarget({ app: 'fallback', bundleId: 'com.example.app', package: 'example' }), + 'com.example.app', + ); + assert.equal(resolveDeployResultTarget({ app: 'fallback', package: 'example' }), 'example'); + assert.equal(resolveDeployResultTarget({ app: 'fallback' }), 'fallback'); +}); + +test('resolveInstallFromSourceResultTarget follows the public install target precedence', () => { + assert.equal( + resolveInstallFromSourceResultTarget({ + launchTarget: 'com.example.app', + appName: 'Demo', + bundleId: 'com.example.bundle', + packageName: 'com.example.package', + }), + 'Demo', + ); + assert.equal( + resolveInstallFromSourceResultTarget({ + launchTarget: 'com.example.app', + packageName: 'com.example.package', + }), + 'com.example.package', + ); +}); diff --git a/src/core/deploy-result-target.ts b/src/core/deploy-result-target.ts new file mode 100644 index 000000000..d3d5290f2 --- /dev/null +++ b/src/core/deploy-result-target.ts @@ -0,0 +1,22 @@ +/** + * The public app target a deployment result names, for the one message both the + * daemon deployment handlers and the CLI/MCP serializers render ("Installed: "). + * The precedence is the contract, so it sits below both callers rather than beside either. + */ + +export function resolveDeployResultTarget(result: { + app: string; + bundleId?: string; + package?: string; +}): string { + return result.bundleId ?? result.package ?? result.app; +} + +export function resolveInstallFromSourceResultTarget(result: { + appName?: string; + bundleId?: string; + packageName?: string; + launchTarget: string; +}): string { + return result.appName ?? result.bundleId ?? result.packageName ?? result.launchTarget; +} diff --git a/src/daemon/handlers/doctor-output.ts b/src/core/doctor-output.ts similarity index 100% rename from src/daemon/handlers/doctor-output.ts rename to src/core/doctor-output.ts diff --git a/src/daemon/handlers/status-markers.ts b/src/core/status-markers.ts similarity index 100% rename from src/daemon/handlers/status-markers.ts rename to src/core/status-markers.ts diff --git a/src/daemon/client/__tests__/doctor-progress.test.ts b/src/daemon/client/__tests__/doctor-progress.test.ts deleted file mode 100644 index a315f2e57..000000000 --- a/src/daemon/client/__tests__/doctor-progress.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { consumeDoctorProgressRendered, markDoctorProgressRendered } from '../doctor-progress.ts'; - -test('doctor progress marker is consumed once', () => { - assert.equal(consumeDoctorProgressRendered(), false); - markDoctorProgressRendered(); - assert.equal(consumeDoctorProgressRendered(), true); - assert.equal(consumeDoctorProgressRendered(), false); -}); diff --git a/src/daemon/client/daemon-client-progress.ts b/src/daemon/client/daemon-client-progress.ts index b628fbaab..cd372ae2d 100644 --- a/src/daemon/client/daemon-client-progress.ts +++ b/src/daemon/client/daemon-client-progress.ts @@ -1,4 +1,4 @@ -import type { RequestProgressEvent, RequestProgressSink } from '@agent-device/contracts/progress'; +import type { RequestProgressSink } from '@agent-device/contracts/progress'; // Type-only: importing `node:http` for a value eagerly initializes undici // (~9ms in a fresh process), which the default socket transport never needs. import type http from 'node:http'; @@ -6,7 +6,6 @@ import type { Socket } from 'node:net'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { consumeTextLines } from '@agent-device/host-kit/transport'; -import { markDoctorProgressRendered } from './doctor-progress.ts'; import { isDaemonProgressEnvelope, isDaemonResponseEnvelope, @@ -19,23 +18,6 @@ type ProgressLineReader = { type ProgressResponseFormat = 'socket-legacy' | 'ndjson-envelope'; -function emitProgressEvent( - event: RequestProgressEvent, - options: { - req: DaemonRequest; - onProgress?: RequestProgressSink; - }, -): void { - if (options.onProgress) { - options.onProgress(event); - return; - } - if (event.type === 'command') { - if (options.req.command === 'doctor') markDoctorProgressRendered(); - process.stderr.write(`${event.message}\n`); - } -} - function createInvalidDaemonResponseError( req: DaemonRequest, line: string, @@ -75,10 +57,7 @@ function createProgressLineReader(options: { if (isDaemonProgressEnvelope(message)) { try { - emitProgressEvent(message.event, { - req: options.req, - onProgress: options.onProgress, - }); + options.onProgress?.(message.event); return false; } catch (error) { return finishWithError(error); diff --git a/src/daemon/client/doctor-progress.ts b/src/daemon/client/doctor-progress.ts deleted file mode 100644 index 09fb0ce12..000000000 --- a/src/daemon/client/doctor-progress.ts +++ /dev/null @@ -1,11 +0,0 @@ -let renderedDoctorProgress = false; - -export function markDoctorProgressRendered(): void { - renderedDoctorProgress = true; -} - -export function consumeDoctorProgressRendered(): boolean { - const rendered = renderedDoctorProgress; - renderedDoctorProgress = false; - return rendered; -} diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index ce7552856..d66ad5f6b 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -12,7 +12,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-ru import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { resolvePayloadInput } from '../../core/payload-input.ts'; -import { resolveDeployResultTarget } from '../result-serialization.ts'; +import { resolveDeployResultTarget } from '../../core/deploy-result-target.ts'; import { withSuccessText } from '@agent-device/kernel/success-text'; import { recordSessionAction } from '../session-action-recorder.ts'; import { errorResponse } from '../response.ts'; diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index adca6983a..b6d25c2de 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -16,7 +16,7 @@ import { resolveInstallSource } from '../install-source-resolution.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { resolveInstallFromSourceResultTarget } from '../result-serialization.ts'; +import { resolveInstallFromSourceResultTarget } from '../../core/deploy-result-target.ts'; import { withSuccessText } from '@agent-device/kernel/success-text'; import { recordSessionAction } from '../session-action-recorder.ts'; import { resolveCommandDevice } from '../session-device-resolution.ts'; diff --git a/src/daemon/handlers/session-doctor-output.ts b/src/daemon/handlers/session-doctor-output.ts index 9c81db287..d616af5af 100644 --- a/src/daemon/handlers/session-doctor-output.ts +++ b/src/daemon/handlers/session-doctor-output.ts @@ -1,5 +1,8 @@ import { emitRequestProgress } from '@agent-device/host-kit/request'; -import { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } from './doctor-output.ts'; +import { + formatDoctorCheckDetailLines, + formatDoctorCheckSummaryLine, +} from '../../core/doctor-output.ts'; import type { DoctorCheck, DoctorStatus } from '@agent-device/contracts/observability'; export function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { diff --git a/src/mcp/tool-result.ts b/src/mcp/tool-result.ts index 4267fe40b..5ff6de205 100644 --- a/src/mcp/tool-result.ts +++ b/src/mcp/tool-result.ts @@ -1,3 +1,4 @@ +import { serializeDevice } from '../commands/output/result-serialization.ts'; import type { CommandName } from '../commands/command-metadata.ts'; import type { CommandExecutionResult } from '../commands/command-surface.ts'; @@ -17,10 +18,9 @@ type NonObjectCommandName = { }[NonCollectionCommandName]; const COLLECTION_RESULT_PROJECTORS = { - devices: async (devices: CommandExecutionResult<'devices'>) => { - const { serializeDevice } = await import('../daemon/result-serialization.ts'); - return { devices: devices.map(serializeDevice) }; - }, + devices: async (devices: CommandExecutionResult<'devices'>) => ({ + devices: devices.map(serializeDevice), + }), apps: async (apps: CommandExecutionResult<'apps'>) => ({ apps }), } satisfies CollectionResultProjectors & Record;