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
74 changes: 71 additions & 3 deletions src/daemon/__tests__/request-router-replay-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,46 @@ import { beforeEach, expect, test, vi } 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';

vi.mock('../../core/dispatch.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));

vi.mock('../runtime-hints.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../runtime-hints.ts')>();
return { ...actual, applyRuntimeHintsToApp: vi.fn(async () => {}) };
});

vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, prewarmIosRunnerSession: vi.fn() };
});

vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/core/apps.ts')>();
return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.app') };
});

vi.mock('../handlers/session-device-utils.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../handlers/session-device-utils.ts')>();
return { ...actual, settleIosSimulator: vi.fn(async () => {}) };
});

import { dispatchCommand } from '../../core/dispatch.ts';
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { createRequestHandler } from '../request-router.ts';

const mockDispatch = vi.mocked(dispatchCommand);
const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock());

beforeEach(() => {
mockDispatch.mockReset();
mockDispatch.mockResolvedValue({});
mockResolveTargetDevice.mockReset();
mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR);
});

test('replay runs active-session actions inside the parent request provider scope', async () => {
Expand Down Expand Up @@ -80,3 +103,48 @@ test('replay routes session-changing actions through the full request path', asy
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(appleRunnerProvider).toHaveBeenCalledTimes(2);
});

test('session list includes a cwd-scoped session opened by replay', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-open-scope-'));
const replayPath = path.join(root, 'flow.ad');
fs.writeFileSync(replayPath, 'open "com.example.app"\n');
const sessionStore = makeSessionStore('agent-device-replay-open-scope-');

const handler = createRequestHandler({
logPath: path.join(os.tmpdir(), 'daemon.log'),
token: 'test-token',
sessionStore,
leaseRegistry: new LeaseRegistry(),
appleRunnerProvider: () => undefined,
trackDownloadableArtifact: () => 'artifact-id',
});

const replayResponse = await handler({
token: 'test-token',
session: 'default',
command: 'replay',
positionals: [replayPath],
flags: { platform: 'ios' },
meta: { cwd: root, requestId: 'replay-open-scope' },
});
expect(replayResponse).toMatchObject({ ok: true });

const listResponse = await handler({
token: 'test-token',
session: 'default',
command: 'session_list',
positionals: [],
meta: { cwd: root, requestId: 'session-list-scope' },
});

expect(listResponse).toMatchObject({
ok: true,
data: {
sessions: [
expect.objectContaining({
name: expect.stringMatching(/^cwd:[a-f0-9]+:default$/),
}),
],
},
});
});
8 changes: 6 additions & 2 deletions src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ type OpenTiming = {

type NewSessionOpenEffects = { mayHaveStarted: boolean };

function resolveOpenSessionScope(req: DaemonRequest): SessionState['sessionScope'] | undefined {
return req.internal?.resolvedSessionScope ?? resolveImplicitSessionScope(req);
}

function applyOrdinaryScriptRecordingOpenOutcome(params: {
session: SessionState;
existingSession: SessionState | undefined;
Expand Down Expand Up @@ -430,7 +434,7 @@ async function completeOpenCommand(params: {
const nextSession = buildNextOpenSession({
existingSession: openDispatchSession,
sessionName: existingSession?.name ?? resolvePublicSessionName(req),
sessionScope: existingSession?.sessionScope ?? resolveImplicitSessionScope(req),
sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req),
device,
surface,
appBundleId: sessionAppBundleId,
Expand Down Expand Up @@ -517,7 +521,7 @@ async function prepareOpenDispatchSession(params: {
const provisionalSession = buildNextOpenSession({
existingSession,
sessionName: existingSession?.name ?? resolvePublicSessionName(req),
sessionScope: existingSession?.sessionScope ?? resolveImplicitSessionScope(req),
sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req),
device,
surface,
appBundleId: sessionAppBundleId,
Expand Down
12 changes: 11 additions & 1 deletion src/daemon/handlers/session-replay-action-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { buildDisplayPositionals } from '../session-event-action.ts';
import { appendReplayTraceEvent } from './session-replay-trace.ts';
import { inferFillText } from '../action-utils.ts';
import { readRecordedInputVariableName } from '../../replay/recorded-input.ts';
import { resolveImplicitSessionScope } from '../session-routing.ts';

type ReplayBaseRequest = Omit<DaemonRequest, 'command' | 'positionals'>;

Expand Down Expand Up @@ -136,7 +137,16 @@ async function invokeResolvedReplayAction(params: {
// `session-action-recorder.ts`) reads it to keep an authored
// `get`/`is`/`find`/`snapshot` step in its own healed script while still
// excluding an interactive diagnostic read of the same command.
internal: { ...req.internal, replayPlanStep: true },
internal: {
...req.internal,
replayPlanStep: true,
...(resolved.command === 'open'
? {
resolvedSessionScope:
req.internal?.resolvedSessionScope ?? resolveImplicitSessionScope(req),
}
: {}),
},
};
return await invoke(buildReplayInteractionRequest(baseReq, resolved));
}
Expand Down
5 changes: 5 additions & 0 deletions src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export type DaemonOpenLifecycle = {
type DaemonRequestInternal = {
openLifecycle?: DaemonOpenLifecycle;
admittedLease?: DeviceLease;
/**
* Implicit caller scope resolved before a nested dispatch replaces the
* public session name with its effective scoped key.
*/
resolvedSessionScope?: SessionState['sessionScope'];
/** Terminate the targeted app without ending the owning daemon session. */
closeAppOnly?: boolean;
/** Provider-owned viewport already resolved while normalizing a nested gesture command. */
Expand Down
Loading