Skip to content
53 changes: 52 additions & 1 deletion src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,53 @@ test('metro reload forwards host, port, bundle URL, and timeout to client.metro.
assert.equal(stdout, 'Reloaded React Native apps via http://127.0.0.1:9090/reload\n');
});

test('events prints a parsed session timeline from the generic CLI route', async () => {
let observed: Parameters<AgentDeviceClient['observability']['events']>[0] | undefined;
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
events: async (options) => {
observed = options;
return {
path: '/tmp/session/events.ndjson',
cursor: '0',
limit: 100,
events: [
{
version: 1,
ts: '2026-07-02T12:00:00.000Z',
session: 'default',
kind: 'action.recorded',
command: 'click',
summary: 'Tapped @e14',
details: { ref: '@e14' },
},
],
};
},
});

const output = await captureOutput(async () => {
const handled = await tryRunClientBackedCommand({
command: 'events',
positionals: [],
flags: {
json: false,
help: false,
version: false,
},
client,
});
assert.equal(handled, true);
});

assert.equal(observed?.limit, undefined);
assert.equal(observed?.cursor, undefined);
assert.match(output.stdout, /action click\s+Tapped @e14/);
assert.match(output.stderr, /path=\/tmp\/session\/events\.ndjson/);
});

test('screenshot forwards --overlay-refs to the client capture API', async () => {
let observed:
| {
Expand Down Expand Up @@ -1074,6 +1121,7 @@ async function captureOutput(
function createStubClient(params: {
installFromSource: AgentDeviceClient['apps']['installFromSource'];
listApps?: AgentDeviceClient['apps']['list'];
events?: AgentDeviceClient['observability']['events'];
prepareMetro?: AgentDeviceClient['metro']['prepare'];
reloadMetro?: AgentDeviceClient['metro']['reload'];
open?: AgentDeviceClient['apps']['open'];
Expand Down Expand Up @@ -1198,7 +1246,10 @@ function createStubClient(params: {
interactions: createThrowingMethodGroup<AgentDeviceClient['interactions']>(),
replay: createThrowingMethodGroup<AgentDeviceClient['replay']>(),
batch: createThrowingMethodGroup<AgentDeviceClient['batch']>(),
observability: createThrowingMethodGroup<AgentDeviceClient['observability']>(),
observability: {
...createThrowingMethodGroup<AgentDeviceClient['observability']>(),
events: params.events ?? unexpectedCommandCall,
},
debug: createThrowingMethodGroup<AgentDeviceClient['debug']>(),
recording: createThrowingMethodGroup<AgentDeviceClient['recording']>(),
settings: {
Expand Down
6 changes: 6 additions & 0 deletions src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ Logs:
agent-device open MyApp --platform ios --relaunch --launch-console ./artifacts/app.console.log
--launch-console is only for direct iOS simulator app launches, not URL opens.

Events:
Use events for a compact session timeline without dumping full app logs.
agent-device events
agent-device events 50 100
Events preserve command names, status, durations, paths, session/device/app identifiers, refs/selectors, and coordinates. Typed text, clipboard writes, push/event payloads, raw unknown command arguments, and matching raw message fragments are replaced with length-only placeholders. --no-record suppresses action.recorded entries, but request start/finish entries still record command, status, and timing.

Network:
Use network dump for recent session HTTP traffic parsed from app logs.
agent-device network dump --include headers
Expand Down
1 change: 1 addition & 0 deletions src/client/client-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function serializeOpenResult(result: AppOpenResult): Record<string, unkno
...(result.sessionStateDir ? { sessionStateDir: result.sessionStateDir } : {}),
...(result.runnerLogPath ? { runnerLogPath: result.runnerLogPath } : {}),
...(result.requestLogPath ? { requestLogPath: result.requestLogPath } : {}),
...(result.eventLogPath ? { eventLogPath: result.eventLogPath } : {}),
...(result.appName ? { appName: result.appName } : {}),
...(result.appBundleId ? { appBundleId: result.appBundleId } : {}),
...(result.startup ? { startup: result.startup } : {}),
Expand Down
7 changes: 7 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export type AppOpenResult = {
sessionStateDir?: string;
runnerLogPath?: string;
requestLogPath?: string;
eventLogPath?: string;
appName?: string;
appBundleId?: string;
appId?: string;
Expand Down Expand Up @@ -842,6 +843,11 @@ export type LogsOptions = AgentDeviceRequestOverrides & {
restart?: boolean;
};

export type EventsOptions = AgentDeviceRequestOverrides & {
cursor?: string;
limit?: number;
};

export type NetworkOptions = AgentDeviceRequestOverrides & {
action?: 'dump' | 'log';
limit?: number;
Expand Down Expand Up @@ -1090,6 +1096,7 @@ export type AgentDeviceClient = {
observability: {
perf: (options?: PerfOptions) => Promise<CommandRequestResult>;
logs: (options?: LogsOptions) => Promise<CommandRequestResult>;
events: (options?: EventsOptions) => Promise<CommandRequestResult>;
network: (options?: NetworkOptions) => Promise<CommandRequestResult>;
audio: (options?: AudioOptions) => Promise<CommandRequestResult>;
};
Expand Down
2 changes: 2 additions & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export function createAgentDeviceClient(
return {
session,
sessionStateDir: readOptionalString(data, 'sessionStateDir'),
eventLogPath: readOptionalString(data, 'eventLogPath'),
appName: readOptionalString(data, 'appName'),
appBundleId,
appId,
Expand Down Expand Up @@ -345,6 +346,7 @@ export function createAgentDeviceClient(
observability: {
perf: async (options = {}) => await executeCommand('perf', options),
logs: async (options = {}) => await executeCommand('logs', options),
events: async (options = {}) => await executeCommand('events', options),
network: async (options = {}) => await executeCommand('network', options),
audio: async (options = {}) => await executeCommand('audio', options),
},
Expand Down
1 change: 1 addition & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const PUBLIC_COMMANDS = {
clipboard: 'clipboard',
devices: 'devices',
doctor: 'doctor',
events: 'events',
diff: 'diff',
fill: 'fill',
find: 'find',
Expand Down
82 changes: 82 additions & 0 deletions src/commands/observability/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
audioCommandDefinition,
audioCommandMetadata,
audioDaemonWriter,
eventsCliReader,
eventsCommandDefinition,
eventsCommandMetadata,
eventsDaemonWriter,
logsCliReader,
logsCommandDefinition,
logsCommandMetadata,
Expand All @@ -14,6 +18,7 @@ import {
networkCommandMetadata,
networkDaemonWriter,
} from './index.ts';
import { observabilityCliOutputFormatters } from './output.ts';

const NO_FLAGS = {} as CliFlags;

Expand All @@ -30,6 +35,8 @@ describe('observability command interface', () => {
test('owns logs and network public metadata', () => {
expect(audioCommandMetadata.name).toBe('audio');
expect(audioCommandDefinition.name).toBe('audio');
expect(eventsCommandMetadata.name).toBe('events');
expect(eventsCommandDefinition.name).toBe('events');
expect(logsCommandMetadata.name).toBe('logs');
expect(logsCommandDefinition.name).toBe('logs');
expect(networkCommandMetadata.name).toBe('network');
Expand Down Expand Up @@ -68,6 +75,81 @@ describe('observability command interface', () => {
});
});

test('reads events pagination as compact daemon positionals', () => {
expect(eventsCliReader(['25', '100'], NO_FLAGS)).toEqual({
limit: 25,
cursor: '100',
});
expect(eventsCliReader(['', '100'], NO_FLAGS)).toEqual({
limit: undefined,
cursor: '100',
});
expect(eventsDaemonWriter({ limit: 25, cursor: '100' })).toMatchObject({
command: 'events',
positionals: ['25', '100'],
});
expect(eventsDaemonWriter({ cursor: '100' })).toMatchObject({
command: 'events',
positionals: ['', '100'],
});
});

test('formats events as a compact human timeline', () => {
const output = observabilityCliOutputFormatters.events({
input: {},
result: {
path: '/tmp/session/events.ndjson',
cursor: '0',
limit: 100,
events: [
{
version: 1,
ts: '2026-07-02T12:00:00.000Z',
session: 'default',
kind: 'request.started',
command: 'open',
summary: 'Started open',
},
{
version: 1,
ts: '2026-07-02T12:00:00.250Z',
session: 'default',
kind: 'request.finished',
command: 'open',
status: 'ok',
summary: 'Finished open',
details: { durationMs: 250 },
},
{
version: 1,
ts: '2026-07-02T12:00:01.000Z',
session: 'default',
kind: 'action.recorded',
command: 'fill',
summary: 'Filled @e14',
details: { ref: '@e14', textLength: 8 },
},
],
},
});

expect(output.text).toContain('2026-07-02 12:00:00.000Z start open');
expect(output.text).toContain('2026-07-02 12:00:00.250Z ok open 250ms');
expect(output.text).toContain('2026-07-02 12:00:01.000Z action fill');
expect(output.text).toContain('Filled @e14 (text=8 chars)');
expect(output.stderr).toContain('path=/tmp/session/events.ndjson');
});

test('formats empty events page with a readable message', () => {
const output = observabilityCliOutputFormatters.events({
input: {},
result: { path: '/tmp/session/events.ndjson', cursor: '0', limit: 100, events: [] },
});

expect(output.text).toBe('No session events found.');
expect(output.stderr).toContain('cursor=0');
});

test('reads network include from flag or positional', () => {
expect(networkCliReader(['dump', '25', 'headers'], NO_FLAGS)).toEqual({
action: 'dump',
Expand Down
57 changes: 55 additions & 2 deletions src/commands/observability/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AudioOptions, LogsOptions, NetworkOptions } from '../../client/client-types.ts';
import type {
AudioOptions,
EventsOptions,
LogsOptions,
NetworkOptions,
} from '../../client/client-types.ts';
import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../kernel/contracts.ts';
import { AppError } from '../../kernel/errors.ts';
import { parseStringMember } from '../../utils/string-enum.ts';
Expand All @@ -20,13 +25,15 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import { observabilityCliOutputFormatters } from './output.ts';

const LOGS_COMMAND_NAME = 'logs';
const EVENTS_COMMAND_NAME = 'events';
const NETWORK_COMMAND_NAME = 'network';
const AUDIO_COMMAND_NAME = 'audio';
const NETWORK_ACTION_VALUES = ['dump', 'log'] as const;
const AUDIO_ACTION_VALUES = ['probe'] as const;
const AUDIO_PROBE_ACTION_VALUES = ['start', 'status', 'stop'] as const;

const logsCommandDescription = 'Manage session app logs.';
const eventsCommandDescription = 'Read the session event timeline.';
const networkCommandDescription = 'Show recent HTTP traffic.';
const audioCommandDescription = 'Probe audio levels.';

Expand All @@ -40,6 +47,15 @@ export const logsCommandMetadata = defineFieldCommandMetadata(
},
);

export const eventsCommandMetadata = defineFieldCommandMetadata(
EVENTS_COMMAND_NAME,
eventsCommandDescription,
{
limit: integerField(),
cursor: stringField(),
},
);

export const networkCommandMetadata = defineFieldCommandMetadata(
NETWORK_COMMAND_NAME,
networkCommandDescription,
Expand All @@ -65,6 +81,11 @@ export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata
client.observability.logs(input),
);

export const eventsCommandDefinition = defineExecutableCommand(
eventsCommandMetadata,
(client, input) => client.observability.events(input),
);

export const networkCommandDefinition = defineExecutableCommand(
networkCommandMetadata,
(client, input) => client.observability.network(input),
Expand All @@ -85,6 +106,14 @@ const logsCliSchema = {
allowedFlags: ['restart'],
} as const satisfies CommandSchemaOverride;

const eventsCliSchema = {
usageOverride: 'events [limit] [cursor]',
listUsageOverride: 'events',
helpDescription: 'Read the daemon-owned session event timeline as paged JSON-friendly entries',
summary: 'Read session event timeline',
positionalArgs: ['limit?', 'cursor?'],
} as const satisfies CommandSchemaOverride;

const networkCliSchema = {
usageOverride:
'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]',
Expand Down Expand Up @@ -113,6 +142,12 @@ export const logsCliReader: CliReader = (positionals, flags) => ({
restart: flags.restart,
});

export const eventsCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
limit: positionals[0]?.trim() ? optionalCliNumber(positionals[0]) : undefined,
cursor: positionals[1],
});

export const networkCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
action: readNetworkAction(positionals[0]),
Expand All @@ -132,6 +167,9 @@ export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input)
logsPositionals(input as LogsOptions),
);

export const eventsDaemonWriter: DaemonWriter = (input) =>
request(EVENTS_COMMAND_NAME, eventsPositionals(input as EventsOptions), input);

export const networkDaemonWriter: DaemonWriter = (input) =>
request(NETWORK_COMMAND_NAME, networkPositionals(input as NetworkOptions), {
...input,
Expand All @@ -151,6 +189,16 @@ const logsCommandFacet = defineCommandFacet({
cliOutputFormatter: observabilityCliOutputFormatters.logs,
});

const eventsCommandFacet = defineCommandFacet({
name: EVENTS_COMMAND_NAME,
metadata: eventsCommandMetadata,
definition: eventsCommandDefinition,
cliSchema: eventsCliSchema,
cliReader: eventsCliReader,
daemonWriter: eventsDaemonWriter,
cliOutputFormatter: observabilityCliOutputFormatters.events,
});

const networkCommandFacet = defineCommandFacet({
name: NETWORK_COMMAND_NAME,
metadata: networkCommandMetadata,
Expand All @@ -173,13 +221,18 @@ const audioCommandFacet = defineCommandFacet({

export const observabilityCommandFamily = defineCommandFamilyFromFacets({
name: 'observability',
commands: [logsCommandFacet, networkCommandFacet, audioCommandFacet],
commands: [logsCommandFacet, eventsCommandFacet, networkCommandFacet, audioCommandFacet],
});

function logsPositionals(input: { action?: string; message?: string }): string[] {
return [input.action ?? 'path', ...optionalString(input.message)];
}

function eventsPositionals(input: EventsOptions): string[] {
if (input.cursor === undefined) return optionalNumber(input.limit);
return [input.limit === undefined ? '' : String(input.limit), input.cursor];
}

function networkPositionals(input: NetworkOptions): string[] {
return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)];
}
Expand Down
Loading
Loading