Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
ddd1817
fix(daemon): answer a named missing session on the session, not the d…
thymikee Oct 15, 2026
31005a2
fix(batch): stamp the step session flag only when the caller named on…
thymikee Oct 16, 2026
23782d7
fix(daemon): answer the named-session refusal with its own reason at …
thymikee Oct 16, 2026
a5ef4d8
fix(daemon): answer Apple appstate on the named session whatever the …
thymikee Oct 16, 2026
f947075
test(daemon): pin that the appstate refusal resolves no device (#3674)
thymikee Oct 16, 2026
2ce7a12
fix(daemon): let record stop reach the durable manifest a named sessi…
thymikee Oct 16, 2026
7be63bc
docs: scope the reasonless-session branch to the cause, not the dispa…
thymikee Oct 16, 2026
c13c054
fix(daemon): take record stop recovery from the manifest, never from …
thymikee Oct 16, 2026
54a38c6
fix(daemon): keep the typed named-session reason on the appstate devi…
thymikee Oct 16, 2026
4be2895
docs: correct the reasonless-session family to the details.reason lev…
thymikee Oct 16, 2026
6771128
refactor(daemon): decide the appstate named-session refusal once, at …
thymikee Oct 16, 2026
891323a
fix(daemon): own the appstate test owner's platform family and scope …
thymikee Oct 16, 2026
31f4d58
fix(record): keep host scoping in manifest recovery and pin every rec…
thymikee Oct 16, 2026
d1b6071
fix(record): carry lease discovery context and pin every scoping half
thymikee Oct 16, 2026
3c99486
refactor(record): spread the request scope into manifest re-resolutio…
thymikee Oct 16, 2026
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
6 changes: 6 additions & 0 deletions contracts/fixtures/dispatch-disclosure.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@
"trigger": "a device-bound command with no active session and no device selector: requireSessionOrExplicitSelector refuses before any target resolves, carrying details.reason session_or_device_selector_required",
"dispatched": "no"
},
{
"id": "daemon.route.named-session-refusal",
"producer": "daemon",
"trigger": "a command naming a session this daemon does not hold and no device selector: requireNamedSessionHoldsDevice refuses with SESSION_NOT_FOUND and details.reason named_session_not_found before any device is listed or resolved",
"dispatched": "no"
},
{
"id": "daemon.route.find-type-refused-after-focus",
"producer": "daemon",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import type {
ScreenRecordingCompletion,
} from '@agent-device/contracts/screen-recording-runtime';
import type { DurableResourceEnvelope } from '@agent-device/contracts/durable-resource-envelope';
import { deviceIdentity, sameDeviceIdentity, type DeviceInfo } from '@agent-device/kernel/device';
import {
deviceIdentity,
sameDeviceIdentity,
type DeviceIdentity,
type DeviceInfo,
} from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import { isRecord } from '@agent-device/kernel/record';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
Expand Down Expand Up @@ -70,6 +75,21 @@ export function screenRecordingManifestIsTerminal(params: ScreenRecordingManifes
return record.status === 'decoded' && record.envelope.lifecycle === 'completed';
}

/**
* The device a stop without a held session must be answered on: the one the session's durable
* manifest names, when a decodable manifest exists. `undefined` means there is nothing to recover
* from, and a caller that addressed a session answers on the session instead of choosing a device.
* The caller still resolves this identity against the host to recover the live device fields, and
* `assertManifestBelongsToRequest` still fails the request closed when the resolved device or the
* session address does not match what the manifest recorded.
*/
export function screenRecordingManifestDevice(
params: ScreenRecordingManifestParams,
): DeviceIdentity | undefined {
const { record } = readSessionManifest(params);
return record.status === 'decoded' ? record.envelope.device : undefined;
}

function readSessionManifest(params: ScreenRecordingManifestParams): Readonly<{
resourcePath: string;
record: DurableCaptureResourceRecord<'screen-recording'>;
Expand Down
41 changes: 41 additions & 0 deletions packages/command-registry/src/__tests__/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,47 @@ function batchRequest(commands: string[], responseLevel?: ResponseLevel): BatchR
};
}

// #3674: the daemon refuses a request that names a session it does not hold. A batch step is
// addressed through `session:`, so only a caller who named a session may see one in `flags`:
// stamping every step would hand that refusal to sessionless batches, whose steps are meant to
// resolve their device from the session or the selectors.
test('sessionless batch steps carry no session flag, so they stay sessionless at the daemon', async () => {
const seen: Array<Record<string, unknown> | undefined> = [];
const request: BatchRequest = {
token: 't',
command: 'batch',
positionals: [],
flags: { batchSteps: [{ command: 'snapshot' }] },
};
const response = await runBatch(request, 'cwd:1a2b3c4d5e6f7a8b:default', async (req) => {
seen.push(req.flags as Record<string, unknown> | undefined);
return { ok: true, data: {} };
});

assert.equal(response.ok, true);
assert.equal(seen[0]?.session, undefined);
});

test('a batch whose caller named a session stamps that address onto every step', async () => {
const seen: Array<Record<string, unknown> | undefined> = [];
const request: BatchRequest = {
token: 't',
command: 'batch',
positionals: [],
session: 'e2e-ios-0',
meta: { sessionExplicit: true },
flags: { batchSteps: [{ command: 'snapshot' }, { command: 'get' }] },
};
const response = await runBatch(request, 'e2e-ios-0', async (req) => {
seen.push(req.flags as Record<string, unknown> | undefined);
return { ok: true, data: {} };
});

assert.equal(response.ok, true);
assert.equal(seen[0]?.session, 'e2e-ios-0');
assert.equal(seen[1]?.session, 'e2e-ios-0');
});

// #2997: `testIme` joined INHERITED_PARENT_FLAG_KEYS for the flow command's session
// opens; batch shares that list, so its steps inherit the parent's opt-in the same way.
// Pinning it here keeps the shared-list addition deliberate for both consumers.
Expand Down
7 changes: 6 additions & 1 deletion packages/command-registry/src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type DaemonResponse,
type ResponseLevel,
isNonDefaultResponseLevel,
namesSessionExplicitly,
} from '@agent-device/kernel/contracts';
import { AppError, asAppError, type DaemonError } from '@agent-device/kernel/errors';
import { isRecord } from '@agent-device/kernel/record';
Expand Down Expand Up @@ -320,7 +321,11 @@ async function runBatchStep(
const isFinalStep = stepNumber === totalSteps;
const stepStartedAt = Date.now();
const stepFlags = buildBatchStepFlags(req.flags, step.flags);
if (stepFlags.session === undefined) {
// The step is already addressed through `session:` below. Stamp the flag only when the caller
// genuinely named a session: an unconditional stamp would make a sessionless batch look
// caller-addressed to the daemon's named-session refusal, which no caller of this surface asked
// for (#3674). A step's own recorded flag always wins, explicit parent or not.
if (stepFlags.session === undefined && namesSessionExplicitly(req)) {
stepFlags.session = sessionName;
}
const response = await invoke(
Expand Down
119 changes: 119 additions & 0 deletions packages/daemon/src/core/explicit-session-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { DaemonWireRequest } from '@agent-device/contracts/command';
import { PRE_DISPATCH_REFUSAL_REASONS, normalizeError } from '@agent-device/kernel/errors';
import type { SessionState } from './session-state.ts';
import {
namedSessionNotFoundError,
requireNamedSessionHoldsDevice,
} from './explicit-session-resolution.ts';

function request(overrides: Partial<DaemonWireRequest> = {}): DaemonWireRequest {
return {
token: 't',
session: 'e2e-ios-0',
command: 'snapshot',
positionals: [],
...overrides,
};
}

function heldSession(address = 'e2e-ios-0'): SessionState {
return {
name: address,
device: { platform: 'android', id: 'emulator-5554', name: 'Pixel 9', kind: 'emulator' },
createdAt: 0,
actions: [],
};
}

function normalizeRefusal(error: unknown) {
assert.ok(error instanceof Error);
return normalizeError(error);
}

test('a named session the daemon does not hold is refused on the session, not the device', () => {
assert.throws(
() =>
requireNamedSessionHoldsDevice({
req: request({ meta: { sessionExplicit: true } }),
sessionAddress: 'e2e-ios-0',
session: undefined,
}),
(error: unknown) => {
const refusal = normalizeRefusal(error);
assert.equal(refusal.code, 'SESSION_NOT_FOUND');
assert.equal(
refusal.message,
'No active session "e2e-ios-0". Run agent-device open <app> --session e2e-ios-0 first, ' +
'or omit --session to run against a device selector.',
);
assert.equal(refusal.details?.reason, PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound);
assert.equal(refusal.details?.dispatched, 'no');
assert.equal(refusal.details?.session, 'e2e-ios-0');
assert.match(String(refusal.hint), /agent-device session list/);
return true;
},
);
});

test('the session flag alone names a session as firmly as the client meta does', () => {
// The predicate itself is pinned in @agent-device/kernel/contracts; what the guard owes is
// that BOTH carriers reach the refusal.
const fromMeta = request({ meta: { sessionExplicit: true } });
const fromFlag = request({ flags: { session: 'e2e-ios-0' } });
for (const req of [fromMeta, fromFlag]) {
assert.throws(
() =>
requireNamedSessionHoldsDevice({ req, sessionAddress: 'e2e-ios-0', session: undefined }),
{ code: 'SESSION_NOT_FOUND' },
);
}
});

test('a request that never named a session keeps its sessionless device resolution', () => {
requireNamedSessionHoldsDevice({
req: request({ session: 'cwd:1a2b3c4d5e6f7a8b:ios' }),
sessionAddress: 'cwd:1a2b3c4d5e6f7a8b:ios',
session: undefined,
});
requireNamedSessionHoldsDevice({
req: request({ session: 'default', flags: {} }),
sessionAddress: 'default',
session: undefined,
});
});

test('a session the daemon holds never reaches the refusal', () => {
requireNamedSessionHoldsDevice({
req: request({ meta: { sessionExplicit: true } }),
sessionAddress: 'e2e-ios-0',
session: heldSession(),
});
});

test('a named session beside a device selector keeps the target the caller stated', () => {
for (const flags of [
{ udid: 'SIM-1' },
{ serial: 'emulator-5554' },
{ device: 'iPhone 17' },
{ platform: 'ios' as const },
{ target: 'tv' as const },
]) {
requireNamedSessionHoldsDevice({
req: request({ flags, meta: { sessionExplicit: true } }),
sessionAddress: 'e2e-ios-0',
session: undefined,
});
}
});

test('the recovery command echoes the address back, quoted only where the shell needs it', () => {
// A scoped store key is already a safe shell word, so the hint hands back the exact address
// `session list` printed; a name with a space has to arrive quoted to run as one argument.
const scoped = normalizeRefusal(namedSessionNotFoundError('cwd:1a2b3c4d5e6f7a8b:ios'));
assert.match(String(scoped.message), /--session cwd:1a2b3c4d5e6f7a8b:ios first/);
assert.equal(scoped.details?.session, 'cwd:1a2b3c4d5e6f7a8b:ios');
const spaced = normalizeRefusal(namedSessionNotFoundError('my session'));
assert.match(String(spaced.message), /--session 'my session' first/);
});
58 changes: 58 additions & 0 deletions packages/daemon/src/core/explicit-session-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { DaemonWireRequest } from '@agent-device/contracts/command';
import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell';
import { namesSessionExplicitly } from '@agent-device/kernel/contracts';
import {
AppError,
namedSessionNotFoundDetails,
type RefusalDetails,
} from '@agent-device/kernel/errors';
import { hasDeviceSelectionInput } from './device/device-selector-intent.ts';
import type { SessionState } from './session-state.ts';

/**
* The answer to a request that names a session this daemon does not hold: the session, not the
* device, is what is missing, and the command stopped before anything could reach a device.
*/
export function namedSessionNotFoundError(sessionAddress: string): AppError {
const sessionArg = shellQuoteIfNeeded(sessionAddress);
return new AppError(
'SESSION_NOT_FOUND',
`No active session "${sessionAddress}". Run agent-device open <app> --session ${sessionArg} first, or omit --session to run against a device selector.`,
{
...namedSessionNotFoundDetails(sessionAddress),
hint:
'Run agent-device session list to see the sessions this daemon holds. ' +
"A daemon that was replaced or restarted starts without its predecessor's sessions.",
} satisfies RefusalDetails<'named_session_not_found'>,
);
}

/**
* Refuses a request whose session the daemon does not hold, at the seam where that request would
* otherwise have had its device chosen from its own selectors.
*
* The fall-through this closes is what an unheld session leaves behind. A session-attached command
* names no device, so resolving one from its flags meant picking from every device on the host:
* with one booted the caller met an app-session error, with two an `AMBIGUOUS_MATCH` telling it to
* select a device — an answer that depends on how many devices happen to be up, and that sends the
* caller after a device selector when what it lost was its session (#3674). Refusing here also skips
* the device listing the fall-through paid for.
*
* Two shapes keep the path they chose: a request that never named a session, which is using the
* daemon sessionlessly by design, and one that names a device selector as well, which states its
* target outright and needs no session to supply one. The predicate deciding the first is
* `namesSessionExplicitly` (`@agent-device/kernel/contracts`), traced from what the request carries
* rather than from the session string.
*/
export function requireNamedSessionHoldsDevice(
params: Readonly<{
req: DaemonWireRequest;
sessionAddress: string;
session: SessionState | undefined;
}>,
): void {
if (params.session) return;
if (!namesSessionExplicitly(params.req)) return;
if (hasDeviceSelectionInput(params.req.flags)) return;
throw namedSessionNotFoundError(params.sessionAddress);
}
2 changes: 1 addition & 1 deletion packages/daemon/src/core/snapshot-runtime-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export async function resolveBoundSnapshotCaptureRuntime(
const { ref, session, device } = await resolveSessionDevice(
sessionStore,
sessionName,
req.flags,
req,
params.sessionRef,
);
if (
Expand Down
10 changes: 8 additions & 2 deletions packages/daemon/src/core/snapshot-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import { SessionStore } from './session-store.ts';
export async function resolveSessionDevice(
sessionStore: SessionStore,
sessionName: string,
flags: DaemonRequest['flags'],
req: DaemonRequest,
boundRef?: SessionRef,
) {
const ref = boundRef ?? sessionStore.lookup(sessionName);
const session = ref ? sessionStore.requireCurrent(ref) : undefined;
const device = session?.device ?? (await resolveTargetDevice(flags ?? {}));
// Only a missing session can be refused for being named, so only that branch pays the
// function-scoped import (ADR-0019 loading shape).
if (!session) {
const { requireNamedSessionHoldsDevice } = await import('./explicit-session-resolution.ts');
requireNamedSessionHoldsDevice({ req, sessionAddress: sessionName, session });
}
const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
return { ref, session, device };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ vi.mock('../core/device/device-ready.ts', () => ({
ensureDeviceReady: vi.fn(async () => {}),
}));

import { AppError } from '@agent-device/kernel/errors';
import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors';
import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry';
import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation';
import type { DeviceRuntimeGateway, RuntimeFacts } from '@agent-device/contracts/platform-runtime';
Expand Down Expand Up @@ -273,6 +273,29 @@ async function sessionOrSelectorRefusedBeforeRouting(): Promise<unknown> {
assert.fail(`expected the routing refusal, got ${String(failure)}`);
}

/**
* The store holds `SESSION` and the request names another address, which is what a run against a
* replaced daemon looks like. The row is about the refusal ordering: `snapshot` would otherwise
* have chosen a device from the host inventory and answered with that. `snapshot` declares an
* `observes-app` effect, so the seam stamps `dispatched: no` on any refusal — the driver has to
* pin the producer's own code and reason, or a reverted guard would satisfy the row with the
* device-selection error it replaced.
*/
async function namedSessionRefusedBeforeDeviceSelection(): Promise<unknown> {
const failure = await route(makeSession(SESSION), {
command: 'snapshot',
positionals: [],
session: 'session-never-opened',
meta: { requestId: 'named-session-refusal', sessionExplicit: true },
}).catch((error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'SESSION_NOT_FOUND');
assert.equal(error.details?.reason, PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound);
throw error;
});
assert.fail(`expected the named-session refusal, got ${String(failure)}`);
}

function captureNodes(nodes: readonly unknown[]): void {
gestureRuntimeSpies.captureSnapshot.mockResolvedValue({
backend: 'xctest',
Expand Down Expand Up @@ -354,6 +377,7 @@ const DRIVERS: Record<string, () => Promise<unknown>> = {
'daemon.route.scroll-then-dialog-read-refused': androidScrollThenDialogReadRefused,
'daemon.route.read-only-get': readOnlyGet,
'daemon.route.session-or-selector-refusal': sessionOrSelectorRefusedBeforeRouting,
'daemon.route.named-session-refusal': namedSessionRefusedBeforeDeviceSelection,
};

const ROWS = dispatchDisclosureRowsOwnedBy(
Expand Down
11 changes: 11 additions & 0 deletions packages/daemon/src/execution/selector-runtime-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ async function resolveSelectorRuntimeDevice(
params.captureProof ??= {};
const ref = params.sessionStore.lookup(params.sessionName);
const session = ref?.session;
// Ordered before the required-session return: a named missing session answers with its own
// typed reason. Only that branch pays the function-scoped import (ADR-0019 loading shape).
if (!session) {
const { requireNamedSessionHoldsDevice } =
await import('../core/explicit-session-resolution.ts');
requireNamedSessionHoldsDevice({
req: params.req,
sessionAddress: params.sessionName,
session,
});
}
if (!session && requireSession) return { ok: false, response: noActiveSessionError() };
const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {}));
return { ok: true, ref, session, device };
Expand Down
Loading
Loading