Skip to content
Merged
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
2 changes: 1 addition & 1 deletion skills/dogfood/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ Read current CLI guidance:
agent-device help dogfood
```

Loop: open named session -> snapshot -i + screenshot -> explore flows -> capture evidence per issue -> close.
Loop: open app -> snapshot -i + screenshot -> explore flows -> capture evidence per issue -> close.

Target app is required; infer platform or ask. Findings must come from runtime behavior, not source reads. Let `help dogfood` provide exact report shape, evidence commands, and current workflow guidance.
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
currentFlags: CliFlags,
runtime: SessionRuntimeHints | undefined,
): AgentDeviceClientConfig => ({
session: currentFlags.session ?? sessionName,
session: currentFlags.session,
requestId,
stateDir: currentFlags.stateDir,
daemonBaseUrl: currentFlags.daemonBaseUrl,
Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'
return stripUndefined({
requestId: options.requestId,
cwd: options.cwd,
sessionExplicit: options.session !== undefined,
debug: options.debug,
lockPolicy: options.lockPolicy,
lockPlatform: options.lockPlatform,
Expand Down
1 change: 1 addition & 0 deletions src/client-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export function serializeOpenResult(result: AppOpenResult): Record<string, unkno
return withSuccessText(
{
session: result.session,
...(result.sessionStateDir ? { sessionStateDir: result.sessionStateDir } : {}),
...(result.appName ? { appName: result.appName } : {}),
...(result.appBundleId ? { appBundleId: result.appBundleId } : {}),
...(result.startup ? { startup: result.startup } : {}),
Expand Down
1 change: 1 addition & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &

export type AppOpenResult = {
session: string;
sessionStateDir?: string;
appName?: string;
appBundleId?: string;
appId?: string;
Expand Down
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export function createAgentDeviceClient(
const appId = appBundleId;
return {
session,
sessionStateDir: readOptionalString(data, 'sessionStateDir'),
appName: readOptionalString(data, 'appName'),
appBundleId,
appId,
Expand Down
34 changes: 33 additions & 1 deletion src/commands/__tests__/client-output.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import { describe, expect, test } from 'vitest';
import { recordCliOutput } from '../client-output.ts';
import { openCliOutput, recordCliOutput } from '../client-output.ts';

describe('openCliOutput', () => {
test('prints session state directory on a second line', () => {
const output = openCliOutput({
session: 'default',
sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default',
identifiers: { session: 'default' },
});

expect(output.text).toBe(
['Opened: default', 'Session state: /tmp/agent-device/sessions/cwd_123_default'].join('\n'),
);
expect(output.data).toMatchObject({
session: 'default',
sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default',
});
});
});

describe('recordCliOutput', () => {
test('prints session state directory for record-created sessions', () => {
const output = recordCliOutput({
recording: 'started',
outPath: '/tmp/recording.mp4',
sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default',
});

expect(output.text).toBe(
['/tmp/recording.mp4', 'Session state: /tmp/agent-device/sessions/cwd_123_default'].join(
'\n',
),
);
});

test('prints chunked Android recording paths clearly for human stdout', () => {
const output = recordCliOutput({
recording: 'stopped',
Expand Down
9 changes: 8 additions & 1 deletion src/commands/client-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ export function sessionCliOutput(result: { sessions: AgentDeviceSession[] }): Cl
}

export function openCliOutput(result: AppOpenResult): CliOutput {
return messageOutput(serializeOpenResult(result));
const data = serializeOpenResult(result);
const lines = [readCommandMessage(data)].filter((line): line is string => Boolean(line));
if (typeof data.sessionStateDir === 'string') {
lines.push(`Session state: ${data.sessionStateDir}`);
}
return { data, text: lines.join('\n') || null };
}

export function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput {
Expand Down Expand Up @@ -207,6 +212,8 @@ function defaultCommandCliOutput(result: CommandRequestResult): CliOutput {
function formatRecordSingleOutput(data: Record<string, unknown>, outPath: string): string {
const lines: string[] = [];
if (outPath) lines.push(outPath);
if (typeof data.sessionStateDir === 'string')
lines.push(`Session state: ${data.sessionStateDir}`);
if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`);
if (typeof data.overlayWarning === 'string')
lines.push(`Overlay warning: ${data.overlayWarning}`);
Expand Down
2 changes: 2 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type DaemonRequestMeta = {
requestId?: string;
debug?: boolean;
cwd?: string;
sessionExplicit?: boolean;
tenantId?: string;
runId?: string;
leaseId?: string;
Expand Down Expand Up @@ -348,6 +349,7 @@ export const daemonCommandRequestSchema = schema<DaemonRequest>((input, path) =>
requestId: optionalString(meta, 'requestId', `${path}.meta`),
debug: optionalBoolean(meta, 'debug', `${path}.meta`),
cwd: optionalString(meta, 'cwd', `${path}.meta`),
sessionExplicit: optionalBoolean(meta, 'sessionExplicit', `${path}.meta`),
tenantId: optionalString(meta, 'tenantId', `${path}.meta`),
runId: optionalString(meta, 'runId', `${path}.meta`),
leaseId: optionalString(meta, 'leaseId', `${path}.meta`),
Expand Down
16 changes: 13 additions & 3 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { uploadArtifact } from './upload-client.ts';
import { computeDaemonCodeSignature } from './daemon/code-signature.ts';
import { PUBLIC_COMMANDS } from './command-catalog.ts';
import { shellQuote } from './utils/shell-quote.ts';
import {
readDaemonHttpProgressResponse,
shouldReadDaemonProgressStream,
Expand Down Expand Up @@ -155,6 +156,7 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
requestId,
debug,
cwd: req.meta?.cwd,
sessionExplicit: req.meta?.sessionExplicit,
tenantId: req.meta?.tenantId ?? req.flags?.tenant,
runId: req.meta?.runId ?? req.flags?.runId,
leaseId: req.meta?.leaseId ?? req.flags?.leaseId,
Expand Down Expand Up @@ -1765,11 +1767,19 @@ export function resolveDaemonStartupHint(
process.env.AGENT_DEVICE_STATE_DIR,
),
): string {
const cleanupCommand = buildDaemonMetadataCleanupCommand(paths);
if (state.hasLock && !state.hasInfo) {
return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.lockPath} still exists without ${paths.infoPath}. Retry with --debug; if this persists, remove ${paths.lockPath} after confirming no agent-device daemon process is running.`;
return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.lockPath} still exists without ${paths.infoPath}. Retry with --debug; if this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`;
}
if (state.hasLock && state.hasInfo) {
return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.infoPath} and ${paths.lockPath} still remain. Retry with --debug; if this persists, remove both files after confirming no agent-device daemon process is running.`;
return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.infoPath} and ${paths.lockPath} still remain. Retry with --debug; if this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`;
}
return `agent-device did not observe reachable daemon metadata after retrying. Stale metadata was cleaned automatically when safe; retry with --debug and check daemon diagnostics logs.`;
if (state.hasInfo) {
return `agent-device did not observe reachable daemon metadata after retrying, and ${paths.infoPath} still remains. Stale metadata was cleaned automatically when safe; retry with --debug. If this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`;
}
return `agent-device did not observe reachable daemon metadata after retrying. Stale metadata was cleaned automatically when safe; retry with --debug and check daemon diagnostics logs. If stale metadata returns after confirming no agent-device daemon process is running, run: ${cleanupCommand}`;
}

function buildDaemonMetadataCleanupCommand(paths: Pick<DaemonPaths, 'infoPath' | 'lockPath'>) {
return `rm -f ${shellQuote(paths.infoPath)} ${shellQuote(paths.lockPath)}`;
}
2 changes: 1 addition & 1 deletion src/daemon/__tests__/request-execution-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ test('prepareLockedRequestScope preserves existing-session selector validation',
sessionStore,
trackDownloadableArtifact: () => 'artifact-id',
}),
).toThrow(/cannot be used with --platform=ios/i);
).toThrow(/already bound to android device "Pixel" \(emulator-5554\).*--platform=ios/i);
});

test('prepareLockedRequestScope blocks commands for invalidated recordings before handlers run', async () => {
Expand Down
31 changes: 31 additions & 0 deletions src/daemon/__tests__/request-finalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test, expect } from 'vitest';
import { finalizeDaemonResponse } from '../request-finalization.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';

test('finalizeDaemonResponse preserves handler error hints from details', () => {
const req: DaemonRequest = {
token: 'token',
session: 'default',
command: 'open',
positionals: [],
flags: {},
};
const response: DaemonResponse = {
ok: false,
error: {
code: 'DEVICE_IN_USE',
message: 'Device is already in use by session "default".',
details: {
session: 'default',
hint: 'Run agent-device session list and reuse --session default.',
},
},
};

const finalized = finalizeDaemonResponse(req, response, () => 'artifact-id');

expect(finalized.ok).toBe(false);
if (!finalized.ok) {
expect(finalized.error.hint).toBe('Run agent-device session list and reuse --session default.');
}
});
4 changes: 4 additions & 0 deletions src/daemon/__tests__/request-router-lock-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi
if (!response.ok) {
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.message).toMatch(/--udid=SIM-999/i);
expect(response.error.hint).toMatch(/agent-device session list/i);
expect(response.error.hint).toMatch(/agent-device close --session qa-ios/i);
}
});

Expand Down Expand Up @@ -108,6 +110,8 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.message).toMatch(/Batch failed at step 1/i);
expect(response.error.message).toMatch(/--serial=emulator-5554/i);
expect(response.error.hint).toMatch(/agent-device session list/i);
expect(response.error.hint).toMatch(/agent-device close --session qa-ios/i);
}
});

Expand Down
18 changes: 18 additions & 0 deletions src/daemon/__tests__/request-router-open.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getResolveTargetDeviceMock } from './request-router-dispatch-mocks.ts';
Expand Down Expand Up @@ -57,6 +58,23 @@ beforeEach(() => {
mockEnsureDeviceReady.mockResolvedValue(undefined);
});

test('open returns and creates the session state directory', async () => {
const sessionStore = makeSessionStore('agent-device-router-open-');
const device = makeIosDevice('SIM-STATE');
mockResolveTargetDevice.mockResolvedValue(device);

const handler = createOpenHandler(sessionStore);

const response = await handler(openRequest('session-a', { platform: 'ios' }, 'req-open-state'));

expect(response.ok).toBe(true);
if (response.ok) {
expect(response.data?.session).toBe('session-a');
expect(response.data?.sessionStateDir).toEqual(expect.stringContaining('session-a'));
expect(fs.existsSync(String(response.data?.sessionStateDir))).toBe(true);
}
});

test('router serializes same-device open requests before first session creation finishes', async () => {
const sessionStore = makeSessionStore('agent-device-router-open-');
const sameDevice = makeIosDevice('SIM-001');
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/__tests__/request-router-replay-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test('replay runs active-session actions inside the parent request provider scop
session: 'default',
command: 'replay',
positionals: [replayPath],
meta: { cwd: root, requestId: 'replay-scope-1' },
meta: { cwd: root, requestId: 'replay-scope-1', sessionExplicit: true },
});

expect(response).toMatchObject({ ok: true });
Expand Down Expand Up @@ -73,7 +73,7 @@ test('replay routes session-changing actions through the full request path', asy
session: 'default',
command: 'replay',
positionals: [replayPath],
meta: { cwd: root, requestId: 'replay-scope-2' },
meta: { cwd: root, requestId: 'replay-scope-2', sessionExplicit: true },
});

expect(response).toMatchObject({ ok: true });
Expand Down
6 changes: 3 additions & 3 deletions src/daemon/__tests__/request-router-screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ test('screenshot resolves relative positional path against request cwd', async (
session: 'default',
command: 'screenshot',
positionals: ['evidence/test.png'],
meta: { cwd: callerCwd, requestId: 'req-1' },
meta: { cwd: callerCwd, requestId: 'req-1', sessionExplicit: true },
});

expect(capturedPath).toBeTruthy();
Expand Down Expand Up @@ -287,7 +287,7 @@ test('screenshot keeps absolute positional path unchanged', async () => {
session: 'default',
command: 'screenshot',
positionals: [absolutePath],
meta: { cwd: '/some/other/dir', requestId: 'req-2' },
meta: { cwd: '/some/other/dir', requestId: 'req-2', sessionExplicit: true },
});

expect(capturedPath).toBe(absolutePath);
Expand Down Expand Up @@ -359,7 +359,7 @@ test('screenshot resolves --out flag path against request cwd', async () => {
command: 'screenshot',
positionals: [],
flags: { out: 'evidence/test.png' },
meta: { cwd: callerCwd, requestId: 'req-3' },
meta: { cwd: callerCwd, requestId: 'req-3', sessionExplicit: true },
});

expect(capturedOut).toBeTruthy();
Expand Down
68 changes: 66 additions & 2 deletions src/daemon/__tests__/session-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ function makeStore(t: TestContext): SessionStore {
return new SessionStore(path.join(root, 'sessions'));
}

test('reuses lone active session for implicit default session', (t) => {
test('does not reuse lone active session for implicit default session from another scope', (t) => {
const store = makeStore(t);
store.set('android', makeSession('android'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
t.onTestFinished(() => {
fs.rmSync(cwd, { recursive: true, force: true });
});

const resolved = resolveEffectiveSessionName(
{
Expand All @@ -41,9 +45,69 @@ test('reuses lone active session for implicit default session', (t) => {
command: 'open',
positionals: ['com.google.android.apps.maps'],
flags: {},
meta: { cwd },
},
store,
);

assert.match(resolved, /^cwd:[a-f0-9]{16}:default$/);
assert.notEqual(resolved, 'android');
});

test('uses git worktree root for implicit default session scope', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
const nested = path.join(root, 'packages', 'app');
fs.mkdirSync(path.join(root, '.git'));
fs.mkdirSync(nested, { recursive: true });
t.onTestFinished(() => {
fs.rmSync(root, { recursive: true, force: true });
});

const store = makeStore(t);
const fromRoot = resolveEffectiveSessionName(
{
token: 't',
session: 'default',
command: 'snapshot',
positionals: [],
flags: {},
meta: { cwd: root },
},
store,
);
const fromNested = resolveEffectiveSessionName(
{
token: 't',
session: 'default',
command: 'snapshot',
positionals: [],
flags: {},
meta: { cwd: nested },
},
store,
);

assert.equal(fromNested, fromRoot);
});

test('keeps explicitly configured default session global', (t) => {
const store = makeStore(t);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
t.onTestFinished(() => {
fs.rmSync(cwd, { recursive: true, force: true });
});

const resolved = resolveEffectiveSessionName(
{
token: 't',
session: 'default',
command: 'snapshot',
positionals: [],
flags: {},
meta: { cwd, sessionExplicit: true },
},
store,
);

assert.equal(resolved, 'android');
assert.equal(resolved, 'default');
});
Loading
Loading