diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index cb4bfdd5cb..de86e37cef 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -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(); - 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(); + return { ...actual, applyRuntimeHintsToApp: vi.fn(async () => {}) }; +}); + +vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, prewarmIosRunnerSession: vi.fn() }; +}); + +vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.app') }; +}); + +vi.mock('../handlers/session-device-utils.ts', async (importOriginal) => { + const actual = await importOriginal(); + 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 () => { @@ -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$/), + }), + ], + }, + }); +}); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 42b1b33f60..1bc7e85d20 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -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; @@ -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, @@ -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, diff --git a/src/daemon/handlers/session-replay-action-runtime.ts b/src/daemon/handlers/session-replay-action-runtime.ts index badb9dca2c..4689e25025 100644 --- a/src/daemon/handlers/session-replay-action-runtime.ts +++ b/src/daemon/handlers/session-replay-action-runtime.ts @@ -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; @@ -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)); } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index c59058996e..1c34ddc5d2 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -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. */