diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 7141f2e451..ce0395bb58 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -3,6 +3,13 @@ import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { + ClickCommandResponseData, + FillCommandResponseData, + FindCommandResponseData, + LongPressCommandResponseData, + PressCommandResponseData, +} from '../contracts/interaction.ts'; import { createAgentDeviceClient, type AgentDeviceClient, @@ -1140,3 +1147,178 @@ test('capture.snapshot passes a digest (non-default level) payload through unnor assert.equal(asRecord.nodeCount, 3); assert.ok(!('identifiers' in asRecord)); }); + +test('interactions expose targetKind-discriminated public response data', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'press') { + return { + ok: true, + data: { + targetKind: 'ref', + ref: 'e5', + x: 88, + y: 99, + message: 'Tapped @e5 (88, 99)', + }, + }; + } + if (req.command === 'click') { + return { + ok: true, + data: { + targetKind: 'point', + x: 10, + y: 20, + button: 'secondary', + message: 'Tapped (10, 20)', + }, + }; + } + if (req.command === 'fill') { + return { + ok: true, + data: { + targetKind: 'ref', + ref: 'e5', + x: 88, + y: 99, + text: 'hello', + message: 'Filled 5 chars', + }, + }; + } + if (req.command === 'longpress') { + return { + ok: true, + data: { + targetKind: 'selector', + selector: 'label=Foo', + x: 30, + y: 40, + gesture: 'longpress', + durationMs: 500, + message: 'Long pressed label=Foo (30, 40)', + }, + }; + } + if (req.command === 'find') { + return { + ok: true, + data: { ref: '@e5', refsGeneration: 42, text: 'Hello' }, + }; + } + throw new Error(`unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const press = await client.interactions.press({ ref: '@e5' }); + const click = await client.interactions.click({ x: 10, y: 20, button: 'secondary' }); + const fill = await client.interactions.fill({ ref: '@e5', text: 'hello' }); + const longPress = await client.interactions.longPress({ + selector: 'label=Foo', + durationMs: 500, + }); + const find = await client.interactions.find({ + locator: 'label', + query: 'Foo', + action: 'getText', + }); + + const pressType: Equal = true; + const clickType: Equal = true; + const fillType: Equal = true; + const longPressType: Equal = true; + const findType: Equal = true; + + assert.equal(press.targetKind, 'ref'); + assert.equal(press.ref, 'e5'); + assert.equal(press.x, 88); + assert.equal(press.y, 99); + + assert.equal(click.targetKind, 'point'); + assert.equal(click.x, 10); + assert.equal(click.y, 20); + assert.equal(click.button, 'secondary'); + + assert.equal(fill.targetKind, 'ref'); + assert.equal(fill.ref, 'e5'); + assert.equal(fill.text, 'hello'); + + assert.equal(longPress.targetKind, 'selector'); + assert.equal(longPress.selector, 'label=Foo'); + assert.equal(longPress.gesture, 'longpress'); + assert.equal(longPress.durationMs, 500); + + assert.equal(find.ref, '@e5'); + assert.equal(find.refsGeneration, 42); + assert.equal(find.text, 'Hello'); + + assert.deepEqual( + [pressType, clickType, fillType, longPressType, findType], + [true, true, true, true, true], + ); +}); + +test('interaction responses expose additive cost and direct-iOS Maestro fallback fields', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'press') { + return { + ok: true, + data: { + targetKind: 'ref', + ref: 'e5', + cost: { wallClockMs: 123, runnerRoundTrips: 2, nodeCount: 5 }, + }, + }; + } + if (req.command === 'click') { + return { + ok: true, + data: { + targetKind: 'selector', + selector: 'id=hidden', + maestroNonHittableCoordinateFallbackAllowed: true, + maestroNonHittableCoordinateFallbackUsed: true, + maestroFallbackReason: 'non-hittable-coordinate', + }, + }; + } + if (req.command === 'find') { + return { + ok: true, + data: { + ref: '@e5', + refsGeneration: 42, + text: 'Hello', + cost: { wallClockMs: 45, runnerRoundTrips: 0 }, + }, + }; + } + throw new Error(`unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const press = await client.interactions.press({ ref: '@e5', cost: true }); + const click = await client.interactions.click({ selector: 'id=hidden' }); + const find = await client.interactions.find({ + locator: 'label', + query: 'Foo', + action: 'getText', + cost: true, + }); + + assert.equal(press.targetKind, 'ref'); + assert.equal(press.cost?.wallClockMs, 123); + assert.equal(press.cost?.runnerRoundTrips, 2); + assert.equal(press.cost?.nodeCount, 5); + + assert.equal(click.targetKind, 'selector'); + assert.equal(click.selector, 'id=hidden'); + assert.equal(click.maestroNonHittableCoordinateFallbackAllowed, true); + assert.equal(click.maestroNonHittableCoordinateFallbackUsed, true); + assert.equal(click.maestroFallbackReason, 'non-hittable-coordinate'); + + assert.equal(find.ref, '@e5'); + assert.equal(find.cost?.wallClockMs, 45); + assert.equal(find.cost?.runnerRoundTrips, 0); +}); diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 957390796b..81d3ff9dc5 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -480,6 +480,12 @@ export type CaptureSnapshotResult = { * token budget); pair a ref with it (`@e12~s`) before a mutation. */ refsGeneration?: number; + /** + * Digest response view only: a capped list of `{ ref, label? }` pairs taken + * from the full `nodes` tree so the MCP layer can still pin refs when the + * default-level `nodes` payload is intentionally omitted. + */ + refs?: Array<{ ref: string; label?: string }>; } & PublicSnapshotCaptureAnnotations; export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { @@ -1190,23 +1196,23 @@ export type AgentDeviceClient = { diff: (options: CaptureDiffOptions) => Promise>; }; interactions: { - click: (options: ClickOptions) => Promise; - press: (options: PressOptions) => Promise; - longPress: (options: LongPressOptions) => Promise; + click: (options: ClickOptions) => Promise>; + press: (options: PressOptions) => Promise>; + longPress: (options: LongPressOptions) => Promise>; swipe: (options: SwipeOptions) => Promise; pan: (options: PanOptions) => Promise; fling: (options: FlingOptions) => Promise; swipeGesture: (options: SwipeGestureOptions) => Promise; focus: (options: FocusOptions) => Promise; type: (options: TypeTextOptions) => Promise; - fill: (options: FillOptions) => Promise; + fill: (options: FillOptions) => Promise>; scroll: (options: ScrollOptions) => Promise; pinch: (options: PinchOptions) => Promise; rotateGesture: (options: RotateGestureOptions) => Promise; transformGesture: (options: TransformGestureOptions) => Promise; get: (options: GetOptions) => Promise; is: (options: IsOptions) => Promise; - find: (options: FindOptions) => Promise; + find: (options: FindOptions) => Promise>; }; replay: { run: (options: ReplayRunOptions) => Promise>; diff --git a/src/contracts/interaction.ts b/src/contracts/interaction.ts index c4ce6adf97..8d7198029d 100644 --- a/src/contracts/interaction.ts +++ b/src/contracts/interaction.ts @@ -1,4 +1,6 @@ import type { Point, SnapshotNode } from '../kernel/snapshot.ts'; +import type { ResponseCost } from '../kernel/contracts.ts'; +import type { ClickButton } from '../core/click-button.ts'; export type SelectorTarget = { kind: 'selector'; @@ -228,6 +230,93 @@ export type SettleObservation = { hint?: string; }; +/** + * Public daemon response data shared by press/click/fill/longpress. + * `buildInteractionResponseData` emits this shape (ADR 0011 Layer 2): + * `targetKind` discriminates the resolved target, identity fields are FLAT + * (`ref`, `selector`, `x`, `y`), and per-command extras ride alongside. + */ +type TouchResponseDataBase = { + message?: string; + warning?: string; + x?: number; + y?: number; + referenceWidth?: number; + referenceHeight?: number; + evidence?: InteractionEvidence; + settle?: SettleObservation; + resolution?: ResolutionDisclosure; + cost?: ResponseCost; + /** Direct iOS Maestro coordinate-fallback signals. */ + maestroNonHittableCoordinateFallbackAllowed?: boolean; + maestroNonHittableCoordinateFallbackUsed?: boolean; + maestroFallbackReason?: 'non-hittable-coordinate'; +}; + +type TouchResponsePoint = TouchResponseDataBase & { + targetKind: 'point'; + x: number; + y: number; +}; + +type TouchResponseRef = TouchResponseDataBase & { + targetKind: 'ref'; + ref: string; + refLabel?: string; + selectorChain?: string[]; + targetHittable?: boolean; + hint?: string; +}; + +type TouchResponseSelector = TouchResponseDataBase & { + targetKind: 'selector'; + selector: string; + selectorChain?: string[]; + refLabel?: string; + targetHittable?: boolean; + hint?: string; +}; + +type TouchPressExtras = { + button?: ClickButton; + count?: number; + intervalMs?: number; + holdMs?: number; + jitterPx?: number; + doubleTap?: boolean; +}; + +export type PressCommandResponseData = + | (TouchResponsePoint & TouchPressExtras) + | (TouchResponseRef & TouchPressExtras) + | (TouchResponseSelector & TouchPressExtras); + +export type ClickCommandResponseData = PressCommandResponseData; + +type TouchFillExtras = { + text: string; + delayMs?: number; +}; + +export type FillCommandResponseData = + | (TouchResponsePoint & TouchFillExtras) + | (TouchResponseRef & TouchFillExtras) + | (TouchResponseSelector & TouchFillExtras); + +type TouchLongPressExtras = { + durationMs?: number; + gesture: 'longpress'; +}; + +export type LongPressCommandResponseData = + | (TouchResponsePoint & TouchLongPressExtras) + | (TouchResponseRef & TouchLongPressExtras) + | (TouchResponseSelector & TouchLongPressExtras); + +/** + * Internal runtime result for press/click. The daemon response layer turns + * this into `PressCommandResponseData` via `buildInteractionResponseData`. + */ export type PressCommandResult = ResolvedInteractionTarget & { backendResult?: Record; message?: string; @@ -236,6 +325,10 @@ export type PressCommandResult = ResolvedInteractionTarget & { settle?: SettleObservation; }; +/** + * Internal runtime result for fill. The daemon response layer turns this into + * `FillCommandResponseData` via `buildInteractionResponseData`. + */ export type FillCommandResult = ResolvedInteractionTarget & { text: string; warning?: string; @@ -245,6 +338,10 @@ export type FillCommandResult = ResolvedInteractionTarget & { settle?: SettleObservation; }; +/** + * Internal runtime result for longpress. The daemon response layer turns this + * into `LongPressCommandResponseData` via `buildInteractionResponseData`. + */ export type LongPressCommandResult = ResolvedInteractionTarget & { durationMs?: number; backendResult?: Record; @@ -252,3 +349,27 @@ export type LongPressCommandResult = ResolvedInteractionTarget & { warning?: string; settle?: SettleObservation; }; + +/** + * Daemon response data for the `find` command. Read-only actions (`exists`, + * `wait`, `get_text`, `get_attrs`) may issue a pinnable ref with + * `refsGeneration`; mutating actions (`click`, `fill`, `focus`, `type`) carry + * `ref` as diagnostic pre-action identity and intentionally omit `refsGeneration` + * (ADR 0014). The shape is intentionally a flat, optional-field record because + * the action positional changes which fields are present. + */ +export type FindCommandResponseData = { + ref?: string; + refsGeneration?: number; + found?: true; + waitedMs?: number; + text?: string; + node?: SnapshotNode; + locator?: string; + query?: string; + x?: number; + y?: number; + message?: string; + settle?: SettleObservation; + cost?: ResponseCost; +}; diff --git a/src/core/command-descriptor/__tests__/command-result.test.ts b/src/core/command-descriptor/__tests__/command-result.test.ts index 93fa2f7516..c61b4a5445 100644 --- a/src/core/command-descriptor/__tests__/command-result.test.ts +++ b/src/core/command-descriptor/__tests__/command-result.test.ts @@ -1,8 +1,9 @@ import { expect, test } from 'vitest'; import type { - FillCommandResult, - LongPressCommandResult, - PressCommandResult, + FillCommandResponseData, + LongPressCommandResponseData, + PressCommandResponseData, + FindCommandResponseData, } from '../../../contracts/interaction.ts'; import type { BootCommandResult, ShutdownCommandResult } from '../../../contracts/device.ts'; import type { ViewportCommandResult } from '../../../contracts/viewport.ts'; @@ -36,10 +37,11 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; test('seeded CommandResult entries resolve to their existing contract result types', () => { - const press: Equal, PressCommandResult> = true; - const click: Equal, PressCommandResult> = true; - const fill: Equal, FillCommandResult> = true; - const longPress: Equal, LongPressCommandResult> = true; + const press: Equal, PressCommandResponseData> = true; + const click: Equal, PressCommandResponseData> = true; + const fill: Equal, FillCommandResponseData> = true; + const longPress: Equal, LongPressCommandResponseData> = true; + const find: Equal, FindCommandResponseData> = true; const boot: Equal, BootCommandResult> = true; const shutdown: Equal, ShutdownCommandResult> = true; const viewport: Equal, ViewportCommandResult> = true; @@ -69,6 +71,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ click, fill, longPress, + find, boot, shutdown, viewport, @@ -116,6 +119,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ true, true, true, + true, ]); }); @@ -133,6 +137,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'click' | 'fill' | 'longpress' + | 'find' | 'boot' | 'shutdown' | 'viewport' diff --git a/src/core/command-descriptor/command-result.ts b/src/core/command-descriptor/command-result.ts index 80117dc946..1d41f4f720 100644 --- a/src/core/command-descriptor/command-result.ts +++ b/src/core/command-descriptor/command-result.ts @@ -1,7 +1,9 @@ import type { - FillCommandResult, - LongPressCommandResult, - PressCommandResult, + ClickCommandResponseData, + FillCommandResponseData, + FindCommandResponseData, + LongPressCommandResponseData, + PressCommandResponseData, } from '../../contracts/interaction.ts'; import type { BootCommandResult, ShutdownCommandResult } from '../../contracts/device.ts'; import type { ViewportCommandResult } from '../../contracts/viewport.ts'; @@ -44,10 +46,11 @@ import type { ReplayCommandResult, ReplaySuiteResult } from '../../contracts/rep * re-read of the handler's literal return; see the per-type docstrings. */ export interface CommandResultMap { - press: PressCommandResult; - click: PressCommandResult; - fill: FillCommandResult; - longpress: LongPressCommandResult; + press: PressCommandResponseData; + click: ClickCommandResponseData; + fill: FillCommandResponseData; + longpress: LongPressCommandResponseData; + find: FindCommandResponseData; boot: BootCommandResult; shutdown: ShutdownCommandResult; viewport: ViewportCommandResult; diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index 199c6450ba..9c14019587 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -595,422 +595,45 @@ test('MCP session tool exposes state-dir resolution without a daemon round-trip' assert.deepEqual(result.structuredContent, { stateDir: '/tmp/agent-device-dev-state' }); }); -// --- #1076 versioned refs: MCP auto-pinning --- - -function createPinningExecutor(runCalls: Array<{ name: string; input: unknown }>) { - return createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'snapshot') { - // Issues refs e2 and e37 at generation 500012. - return { - nodes: [{ ref: 'e2' }, { ref: 'e37' }], - truncated: false, - refsGeneration: 500012, - }; - } - if (name === 'find') { - // A later find capture replaced the tree and issued ONLY e5 at 500013. - return { ref: '@e5', refsGeneration: 500013 }; - } - return { message: `Ran ${name}` }; - }, - }); -} - -test('MCP keeps per-ref provenance: a pre-find snapshot ref stays pinned to ITS generation', async () => { - // THE find-blessing scenario (#1076): snapshot issues e37 at G1, a later - // find issues e5 at G2. A plain @e37 must forward pinned to G1 — pinning it - // to G2 would make the daemon read it as current and silently re-bless it. - // (The daemon side of this flow — precise warning for the G1 pin after the - // find capture replaced the tree — is covered in the provider scenario.) - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - await executor.execute('snapshot', { session: 'demo' }); - await executor.execute('find', { session: 'demo', query: 'Continue' }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e37' } }); - - assert.deepEqual(runCalls[2], { - name: 'press', - input: { session: 'demo', target: { kind: 'ref', ref: '@e37~s500012' } }, - }); -}); - -test('MCP pins the find-issued ref to the find generation', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - await executor.execute('snapshot', { session: 'demo' }); - await executor.execute('find', { session: 'demo', query: 'Continue' }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e5' } }); - - assert.deepEqual(runCalls[2], { - name: 'press', - input: { session: 'demo', target: { kind: 'ref', ref: '@e5~s500013' } }, - }); -}); - -test('MCP auto-pins wait refs and get targets from the per-ref map', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - await executor.execute('snapshot', { session: 'demo' }); - await executor.execute('wait', { session: 'demo', ref: '@e2' }); - await executor.execute('get', { - session: 'demo', - format: 'text', - target: { kind: 'ref', ref: '@e37' }, - }); - - assert.deepEqual(runCalls[1], { - name: 'wait', - input: { session: 'demo', ref: '@e2~s500012' }, - }); - assert.deepEqual(runCalls[2], { - name: 'get', - input: { session: 'demo', format: 'text', target: { kind: 'ref', ref: '@e37~s500012' } }, - }); -}); - -test('MCP merges digest-level snapshot refs too', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - return name === 'snapshot' - ? { nodeCount: 1, refs: [{ ref: 'e9', label: 'Continue' }], refsGeneration: 41 } - : {}; - }, - }); - - await executor.execute('snapshot', { responseLevel: 'digest' }); - await executor.execute('press', { target: { kind: 'ref', ref: '@e9' } }); - - assert.deepEqual(runCalls[1]?.input, { target: { kind: 'ref', ref: '@e9~s41' } }); -}); - -// --- #1101 --settle: interaction responses re-pin from the settled diff --- - -test('MCP merges per-ref pins from a settle response diff (merge-only)', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'snapshot') { - return { nodes: [{ ref: 'e2' }, { ref: 'e37' }], truncated: false, refsGeneration: 7 }; - } - if (name === 'press') { - // press @e2 --settle: the settled diff issues e4 at generation 8. - return { - ref: 'e2', - settle: { - settled: true, - waitedMs: 60, - captures: 2, - quietMs: 25, - timeoutMs: 2000, - refsGeneration: 8, - diff: { - summary: { additions: 1, removals: 1, unchanged: 1 }, - lines: [ - { kind: 'removed', text: '@e2 [button] "Continue"' }, - { kind: 'added', text: '@e4 [text] "Welcome!"', ref: 'e4' }, - ], - }, - }, - }; - } - return {}; - }, - }); - - await executor.execute('snapshot', { session: 'demo' }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e4' } }); - await executor.execute('get', { - session: 'demo', - format: 'text', - target: { kind: 'ref', ref: '@e37' }, - }); - - // The first press consumed the snapshot pin… - assert.deepEqual(runCalls[1]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); - // …its settle diff issued e4 at the settle generation… - assert.deepEqual(runCalls[2]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e4~s8' } }); - // …and refs absent from the diff keep their older pins (merge-only), so the - // daemon can warn precisely about the replaced tree. - assert.deepEqual(runCalls[3]?.input, { - session: 'demo', - format: 'text', - target: { kind: 'ref', ref: '@e37~s7' }, - }); -}); - -test('MCP merges digest-level settled refs too', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'press' && runCalls.length === 1) { - return { - ref: 'e2', - settle: { - settled: true, - waitedMs: 2000, - captures: 7, - quietMs: 25, - timeoutMs: 2000, - refsGeneration: 9, - refs: [{ ref: 'e4' }], - diff: { - summary: { additions: 1, removals: 1, unchanged: 1 }, - }, - }, - }; - } - return {}; - }, - }); - - await executor.execute('press', { - session: 'demo', - responseLevel: 'digest', - target: { kind: 'ref', ref: '@e2' }, - settle: true, - }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e4' } }); - - assert.deepEqual(runCalls[1]?.input, { - session: 'demo', - target: { kind: 'ref', ref: '@e4~s9' }, - }); -}); - -test('MCP merges per-ref pins from a settle response unchanged-interactive tail', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'press') { - // A removals-only diff (modal dismiss) carries no added refs, so the - // tail is the only ref-issuing surface on this response. - return { - ref: 'e2', - settle: { - settled: true, - waitedMs: 60, - captures: 2, - quietMs: 25, - timeoutMs: 2000, - refsGeneration: 9, - diff: { - summary: { additions: 0, removals: 1, unchanged: 1 }, - lines: [{ kind: 'removed', text: '@e2 [button] "OK"' }], - }, - tail: [{ ref: 'e1', role: 'button', label: 'Continue' }], - }, - }; - } - return {}; - }, - }); - - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e1' } }); - - assert.deepEqual(runCalls[1]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e1~s9' } }); -}); - -test('MCP leaves pins untouched for plain (non-settle) interaction responses', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; +test('MCP renders tool text from the unpinned input so the model never sees suffixes', async () => { const executor = createCommandToolExecutor({ createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'snapshot') { - return { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 7 }; - } - // A plain press response has no settle payload and no refsGeneration — - // it must NOT clear the scope (only snapshot/find responses without a - // generation do that). - return { ref: 'e2', x: 10, y: 20 }; - }, + runCommand: async (_client, name) => + name === 'snapshot' + ? { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 9 } + : { message: 'Tapped @e2 (10, 20)' }, }); - await executor.execute('snapshot', { session: 'demo' }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); + await executor.execute('snapshot', {}); + const result = await executor.execute('press', { target: { kind: 'ref', ref: '@e2' } }); - assert.deepEqual(runCalls[2]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); + assert.doesNotMatch(result.content[0]?.text ?? '', /~s9/); }); -// --- ADR 0012 decision 2: resolution diagnostics are never ref-issued/pinned --- - -test('MCP never pins resolution.winnerDiagnostic/alternatives — they are pre-action diagnostics, not refs', async () => { +test('MCP command tool executor pins refs for runCommand while keeping rendered text unpinned', async () => { const runCalls: Array<{ name: string; input: unknown }> = []; const executor = createCommandToolExecutor({ createClient: () => ({}) as AgentDeviceClient, runCommand: async (_client, name, input) => { runCalls.push({ name, input }); if (name === 'snapshot') { - return { nodes: [{ ref: 'e2' }, { ref: 'e3' }], truncated: false, refsGeneration: 7 }; + return { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 500012 }; } - // A disambiguated press: no refsGeneration/settle anywhere on this - // payload — the resolution field must not be read as ref-issuing. - return { - ref: 'e2', - resolution: { - source: 'runtime', - phase: 'pre-action', - kind: 'disambiguated', - matchCount: 2, - winnerDiagnostic: { diagnosticRef: 'diag-e2', role: 'button', label: 'Profile' }, - tiebreak: 'visible', - alternatives: [{ diagnosticRef: 'diag-e3', role: 'button', label: 'Profile' }], - }, - }; + return { message: 'Tapped @e2 (10, 20)' }; }, }); await executor.execute('snapshot', { session: 'demo' }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); - // e3 must stay pinned at the SNAPSHOT's generation, untouched by the press - // response's resolution diagnostics. - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e3' } }); - - assert.deepEqual(runCalls[1]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); - assert.deepEqual(runCalls[2]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e3~s7' } }); -}); - -test('MCP never resolves a resolution diagnosticRef as a usable @ref — a fresh snapshot is required', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - return {}; - }, - }); - - // Never issued, so it passes through unpinned for the daemon to reject. - await executor.execute('press', { - session: 'demo', - target: { kind: 'ref', ref: '@diag-e3' }, - }); - - assert.deepEqual(runCalls[0]?.input, { - session: 'demo', - target: { kind: 'ref', ref: '@diag-e3' }, - }); -}); - -test('MCP passes never-issued refs through unpinned (coarse floor, never guess)', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - await executor.execute('snapshot', { session: 'demo' }); - // e99 was never present in any issuing response for this scope. - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e99' } }); - - assert.deepEqual(runCalls[1]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e99' } }); -}); - -test('MCP passes refs through unpinned when the pin scope has no history', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - // Only OTHER session names have history. - await executor.execute('snapshot', { session: 'other' }); - await executor.execute('press', { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); - - assert.deepEqual(runCalls[1]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); -}); - -test('MCP pin scopes include the state dir: same-named sessions never cross-pollinate', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - await executor.execute('snapshot', { session: 'demo', stateDir: '/state/a' }); - // Same session name against a DIFFERENT daemon state dir: no history there. - await executor.execute('press', { - session: 'demo', - stateDir: '/state/b', - target: { kind: 'ref', ref: '@e2' }, - }); - // The original scope still pins. - await executor.execute('press', { + const result = await executor.execute('press', { session: 'demo', - stateDir: '/state/a', target: { kind: 'ref', ref: '@e2' }, }); - assert.deepEqual(runCalls[1]?.input, { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); - assert.deepEqual(runCalls[2]?.input, { - session: 'demo', - target: { kind: 'ref', ref: '@e2~s500012' }, - }); -}); - -test('MCP clears the whole scope when a ref-issuing response stops carrying a generation', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - let issueGeneration = true; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'snapshot') { - return issueGeneration - ? { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 4 } - : { nodes: [{ ref: 'e2' }], truncated: false }; - } - return {}; - }, - }); - - await executor.execute('snapshot', {}); - issueGeneration = false; - // An older daemon without refsGeneration: the remembered pins must not - // leak onto refs the response did not vouch for. - await executor.execute('snapshot', {}); - await executor.execute('press', { target: { kind: 'ref', ref: '@e2' } }); - - assert.deepEqual(runCalls[2], { + assert.deepEqual(runCalls[1], { name: 'press', - input: { target: { kind: 'ref', ref: '@e2' } }, + input: { session: 'demo', target: { kind: 'ref', ref: '@e2~s500012' } }, }); -}); - -test('MCP never rewrites refs that already carry a suffix and never pins non-@ refs', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createPinningExecutor(runCalls); - - await executor.execute('snapshot', {}); - await executor.execute('press', { target: { kind: 'ref', ref: '@e2~s3' } }); - await executor.execute('press', { target: { kind: 'ref', ref: 'e2' } }); - - assert.deepEqual(runCalls[1]?.input, { target: { kind: 'ref', ref: '@e2~s3' } }); - assert.deepEqual(runCalls[2]?.input, { target: { kind: 'ref', ref: 'e2' } }); -}); - -test('MCP renders tool text from the unpinned input so the model never sees suffixes', async () => { - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name) => - name === 'snapshot' - ? { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 9 } - : { message: 'Tapped @e2 (10, 20)' }, - }); - - await executor.execute('snapshot', {}); - const result = await executor.execute('press', { target: { kind: 'ref', ref: '@e2' } }); - - assert.doesNotMatch(result.content[0]?.text ?? '', /~s9/); + assert.doesNotMatch(result.content[0]?.text ?? '', /~s500012/); }); // --- ADR 0012 migration step 2: replay divergence is a ref-issuing error --- @@ -1132,30 +755,6 @@ test("MCP projections carry a caution divergence's alternateFrom (structuredCont assert.match(text, /if you performed the step's intent as a recorded action: replay --from 3/); }); -test('MCP tool error without a divergence never touches existing pins (merge-only)', async () => { - const runCalls: Array<{ name: string; input: unknown }> = []; - const executor = createCommandToolExecutor({ - createClient: () => ({}) as AgentDeviceClient, - runCommand: async (_client, name, input) => { - runCalls.push({ name, input }); - if (name === 'snapshot') { - return { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 7 }; - } - if (name === 'get') throw new AppError('INVALID_ARGS', 'bad selector'); - return {}; - }, - }); - - await executor.execute('snapshot', {}); - const errorResult = await executor.execute('get', { - target: { kind: 'selector', selector: '???' }, - }); - assert.equal(errorResult.isError, true); - - await executor.execute('press', { target: { kind: 'ref', ref: '@e2' } }); - assert.deepEqual(runCalls[2]?.input, { target: { kind: 'ref', ref: '@e2~s7' } }); -}); - // --- #1271 stage 2 (ADR 0012 amendment): `record`/`noRecord` on the MCP // tool schema for the observation-only commands the repair-segment default // exclusion targets. --- diff --git a/src/mcp/__tests__/tool-ref-pins.test.ts b/src/mcp/__tests__/tool-ref-pins.test.ts new file mode 100644 index 0000000000..d818d07717 --- /dev/null +++ b/src/mcp/__tests__/tool-ref-pins.test.ts @@ -0,0 +1,515 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createToolRefPinStore } from '../tool-ref-pins.ts'; + +function makeStore() { + return createToolRefPinStore(); +} + +test('ref-pin store keeps per-ref provenance across snapshot and find captures', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }, { ref: 'e37' }], truncated: false, refsGeneration: 500012 }, + undefined, + 'demo', + ); + pins.mergeCommandResult('find', { ref: '@e5', refsGeneration: 500013 }, undefined, 'demo'); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e37' } }, + undefined, + ); + assert.deepEqual(pinned, { + session: 'demo', + target: { kind: 'ref', ref: '@e37~s500012' }, + }); +}); + +test('ref-pin store pins the find-issued ref to the find generation', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }, { ref: 'e37' }], truncated: false, refsGeneration: 500012 }, + undefined, + 'demo', + ); + pins.mergeCommandResult('find', { ref: '@e5', refsGeneration: 500013 }, undefined, 'demo'); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e5' } }, + undefined, + ); + assert.deepEqual(pinned, { + session: 'demo', + target: { kind: 'ref', ref: '@e5~s500013' }, + }); +}); + +test('ref-pin store pins wait refs and get targets from the per-ref map', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }, { ref: 'e37' }], truncated: false, refsGeneration: 500012 }, + undefined, + 'demo', + ); + + const wait = pins.pinInput('wait', { session: 'demo', ref: '@e2' }, undefined); + const get = pins.pinInput( + 'get', + { session: 'demo', format: 'text', target: { kind: 'ref', ref: '@e37' } }, + undefined, + ); + + assert.deepEqual(wait, { session: 'demo', ref: '@e2~s500012' }); + assert.deepEqual(get, { + session: 'demo', + format: 'text', + target: { kind: 'ref', ref: '@e37~s500012' }, + }); +}); + +test('ref-pin store merges digest-level snapshot refs too', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodeCount: 1, refs: [{ ref: 'e9', label: 'Continue' }], refsGeneration: 41 }, + undefined, + 'demo', + ); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e9' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e9~s41' } }); +}); + +// --- #1101 --settle: interaction responses re-pin from the settled diff --- + +test('ref-pin store merges per-ref pins from a settle response diff', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }, { ref: 'e37' }], truncated: false, refsGeneration: 7 }, + undefined, + 'demo', + ); + pins.mergeCommandResult( + 'press', + { + ref: 'e2', + settle: { + settled: true, + waitedMs: 60, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + refsGeneration: 8, + diff: { + summary: { additions: 1, removals: 1, unchanged: 1 }, + lines: [ + { kind: 'removed', text: '@e2 [button] "Continue"' }, + { kind: 'added', text: '@e4 [text] "Welcome!"', ref: 'e4' }, + ], + }, + }, + }, + undefined, + 'demo', + ); + + const first = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + const second = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e4' } }, + undefined, + ); + const untouched = pins.pinInput( + 'get', + { session: 'demo', format: 'text', target: { kind: 'ref', ref: '@e37' } }, + undefined, + ); + + assert.deepEqual(first, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); + assert.deepEqual(second, { session: 'demo', target: { kind: 'ref', ref: '@e4~s8' } }); + assert.deepEqual(untouched, { + session: 'demo', + format: 'text', + target: { kind: 'ref', ref: '@e37~s7' }, + }); +}); + +test('ref-pin store merges digest-level settled refs too', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'press', + { + ref: 'e2', + settle: { + settled: true, + waitedMs: 2000, + captures: 7, + quietMs: 25, + timeoutMs: 2000, + refsGeneration: 9, + refs: [{ ref: 'e4' }], + diff: { + summary: { additions: 1, removals: 1, unchanged: 1 }, + }, + }, + }, + undefined, + 'demo', + ); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e4' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e4~s9' } }); +}); + +test('ref-pin store merges per-ref pins from a settle response unchanged-interactive tail', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'press', + { + ref: 'e2', + settle: { + settled: true, + waitedMs: 60, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + refsGeneration: 9, + diff: { + summary: { additions: 0, removals: 1, unchanged: 1 }, + lines: [{ kind: 'removed', text: '@e2 [button] "OK"' }], + }, + tail: [{ ref: 'e1', role: 'button', label: 'Continue' }], + }, + }, + undefined, + 'demo', + ); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e1' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e1~s9' } }); +}); + +test('ref-pin store leaves pins untouched for plain interaction responses', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 7 }, + undefined, + 'demo', + ); + pins.mergeCommandResult('press', { ref: 'e2', x: 10, y: 20 }, undefined, 'demo'); + + const first = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + const second = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + + assert.deepEqual(first, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); + assert.deepEqual(second, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); +}); + +// --- ADR 0012 decision 2: resolution diagnostics are never ref-issued/pinned --- + +test('ref-pin store never pins resolution.winnerDiagnostic/alternatives', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }, { ref: 'e3' }], truncated: false, refsGeneration: 7 }, + undefined, + 'demo', + ); + pins.mergeCommandResult( + 'press', + { + ref: 'e2', + resolution: { + source: 'runtime', + phase: 'pre-action', + kind: 'disambiguated', + matchCount: 2, + winnerDiagnostic: { diagnosticRef: 'diag-e2', role: 'button', label: 'Profile' }, + tiebreak: 'visible', + alternatives: [{ diagnosticRef: 'diag-e3', role: 'button', label: 'Profile' }], + }, + }, + undefined, + 'demo', + ); + + const e2 = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + const e3 = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e3' } }, + undefined, + ); + const diag = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@diag-e3' } }, + undefined, + ); + + assert.deepEqual(e2, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); + assert.deepEqual(e3, { session: 'demo', target: { kind: 'ref', ref: '@e3~s7' } }); + assert.deepEqual(diag, { session: 'demo', target: { kind: 'ref', ref: '@diag-e3' } }); +}); + +test('ref-pin store passes never-issued refs through unpinned', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }, { ref: 'e37' }], truncated: false, refsGeneration: 500012 }, + undefined, + 'demo', + ); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e99' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e99' } }); +}); + +test('ref-pin store passes refs through unpinned when the scope has no history', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 500012 }, + undefined, + 'other', + ); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); +}); + +test('ref-pin store scopes by state dir so same-named sessions never cross-pollinate', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 500012 }, + '/state/a', + 'demo', + ); + + const otherState = pins.pinInput( + 'press', + { session: 'demo', stateDir: '/state/b', target: { kind: 'ref', ref: '@e2' } }, + '/state/b', + ); + const originalState = pins.pinInput( + 'press', + { session: 'demo', stateDir: '/state/a', target: { kind: 'ref', ref: '@e2' } }, + '/state/a', + ); + + assert.deepEqual(otherState, { + session: 'demo', + stateDir: '/state/b', + target: { kind: 'ref', ref: '@e2' }, + }); + assert.deepEqual(originalState, { + session: 'demo', + stateDir: '/state/a', + target: { kind: 'ref', ref: '@e2~s500012' }, + }); +}); + +test('ref-pin store clears the whole scope when a ref-issuing response stops carrying a generation', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 4 }, + undefined, + 'demo', + ); + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false }, + undefined, + 'demo', + ); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e2' } }); +}); + +test('ref-pin store never rewrites refs that already carry a suffix and never pins non-@ refs', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 500012 }, + undefined, + 'demo', + ); + + const alreadyPinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2~s3' } }, + undefined, + ); + const missingAt = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: 'e2' } }, + undefined, + ); + + assert.deepEqual(alreadyPinned, { session: 'demo', target: { kind: 'ref', ref: '@e2~s3' } }); + assert.deepEqual(missingAt, { session: 'demo', target: { kind: 'ref', ref: 'e2' } }); +}); + +test('ref-pin store bounds retained pins per scope', () => { + const pins = makeStore(); + + const nodes = []; + for (let i = 0; i < 1002; i++) { + nodes.push({ ref: `e${i}` }); + } + pins.mergeCommandResult('snapshot', { nodes, refsGeneration: 1 }, undefined, 'demo'); + + const first = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e0' } }, + undefined, + ); + const last = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e1001' } }, + undefined, + ); + + assert.deepEqual(first, { session: 'demo', target: { kind: 'ref', ref: '@e0' } }); + assert.deepEqual(last, { session: 'demo', target: { kind: 'ref', ref: '@e1001~s1' } }); +}); + +// --- ADR 0012 migration step 2: replay divergence is a ref-issuing error --- + +function replayDivergenceDetails(): Record { + return { + step: 2, + action: 'click', + divergence: { + version: 1, + kind: 'action-failure', + step: { index: 2, source: { path: '/tmp/flow.ad', line: 2 } }, + action: 'click "Save"', + cause: { code: 'COMMAND_FAILED', message: 'not hittable' }, + screen: { + state: 'available', + refsGeneration: 12, + refs: [{ ref: 'e5', role: 'button', label: 'Save' }], + }, + suggestions: [ + { selector: 'id="save"', basis: 'id', ref: 'e5', role: 'button', label: 'Save' }, + ], + suggestionCount: 1, + resume: { allowed: false, reason: 'resume not yet supported' }, + repairHint: 'record-and-heal', + }, + }; +} + +test('ref-pin store merges divergence screen refs and pins them for later inputs', () => { + const pins = makeStore(); + + pins.mergeDivergenceScreen(replayDivergenceDetails(), undefined, 'demo'); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e5' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e5~s12' } }); +}); + +test('ref-pin store leaves existing pins untouched for an error without a divergence', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 7 }, + undefined, + 'demo', + ); + pins.mergeDivergenceScreen({ code: 'INVALID_ARGS', message: 'bad selector' }, undefined, 'demo'); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); +}); + +test('ref-pin store leaves existing pins untouched for a mutating find without refsGeneration', () => { + const pins = makeStore(); + + pins.mergeCommandResult( + 'snapshot', + { nodes: [{ ref: 'e2' }], truncated: false, refsGeneration: 7 }, + undefined, + 'demo', + ); + pins.mergeCommandResult('find', { ref: '@e2' }, undefined, 'demo'); + + const pinned = pins.pinInput( + 'press', + { session: 'demo', target: { kind: 'ref', ref: '@e2' } }, + undefined, + ); + assert.deepEqual(pinned, { session: 'demo', target: { kind: 'ref', ref: '@e2~s7' } }); +}); diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 93ed26dbf9..a46b085149 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -57,6 +57,20 @@ function objectSchema( } const stringArraySchema: JsonSchema = { type: 'array', items: { type: 'string' } }; + +const responseCostSchema: JsonSchema = objectSchema( + { + wallClockMs: numberSchema('Total wall-clock time for the request in milliseconds.'), + runnerRoundTrips: numberSchema( + 'Number of real runner round-trips made while serving the request.', + ), + nodeCount: numberSchema( + 'Number of nodes in the original node tree when the response carries one.', + ), + }, + ['wallClockMs', 'runnerRoundTrips'], +); + const artifactSchema = objectSchema( { field: stringSchema(), @@ -101,6 +115,14 @@ function interactionResponseDataSchema(extra: InteractionExtra = {}): JsonSchema message: stringSchema(), evidence: interactionEvidenceSchema, resolution: resolutionDisclosureSchema, + cost: responseCostSchema, + maestroNonHittableCoordinateFallbackAllowed: booleanSchema( + 'Whether the direct iOS Maestro coordinate fallback was allowed for this selector.', + ), + maestroNonHittableCoordinateFallbackUsed: booleanSchema( + 'Whether the direct iOS Maestro coordinate fallback was actually used.', + ), + maestroFallbackReason: constSchema('non-hittable-coordinate'), ...extraProperties, }, ['targetKind', ...extraRequired], @@ -315,6 +337,25 @@ export const COMMAND_OUTPUT_SCHEMAS = { gesture: constSchema('longpress'), }, }), + find: objectSchema( + { + ref: stringSchema('Snapshot ref without the @ prefix when the find action returns one.'), + refsGeneration: numberSchema('ADR 0014 ref frame epoch for read-only find actions.'), + found: booleanSchema('Whether a wait/exists/read-only find satisfied its condition.'), + waitedMs: numberSchema('Milliseconds waited for a read-only find condition.'), + text: stringSchema('Text value returned by find get_text.'), + node: looseObjectSchema('Snapshot node for find get_attrs/get_text.'), + locator: stringSchema('Locator kind used for the find action.'), + query: stringSchema('Query argument used for the find action.'), + x: numberSchema('Resolved x coordinate for mutating find actions.'), + y: numberSchema('Resolved y coordinate for mutating find actions.'), + message: stringSchema('Diagnostic message for mutating find actions.'), + settle: settleObservationSchema, + cost: responseCostSchema, + }, + [], + 'Daemon response data for the find command.', + ), // src/contracts/device.ts boot: objectSchema({ ...deviceHeaderProperties, booted: { type: 'boolean', const: true } }, [ diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index 438d7d379b..6d21dcc29f 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -15,6 +15,7 @@ import { AppError } from '../kernel/errors.ts'; import { formatToolErrorText, normalizeToolError } from './tool-error.ts'; import { resolveMcpConfigDefaults } from './tool-input-config.ts'; import { projectStructuredContent } from './tool-result.ts'; +import { createToolRefPinStore, type ToolRefPinStore } from './tool-ref-pins.ts'; export type ToolResult = { isError: boolean; @@ -69,9 +70,7 @@ export function listCommandTools(): Array<{ } export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): CommandToolExecutor { - // #1076 versioned refs — MCP auto-pinning state: per pin scope (state dir + - // session name), the generation each ref body was LAST ISSUED at. - const refPinsByScope = new Map>(); + const refPins = createToolRefPinStore(); return { execute: async (name, input) => { if (!isCommandName(name)) { @@ -82,12 +81,11 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C const resolvedInput = resolveMcpConfigDefaults(name, input, supportedProperties); const config = readMcpToolConfig(resolvedInput); const commandInput = stripMcpConfigFields(resolvedInput); - const scopeKey = readPinScopeKey(config, commandInput); - const pinnedInput = pinPlainRefArguments(name, commandInput, refPinsByScope.get(scopeKey)); + const pinnedInput = refPins.pinInput(name, commandInput, config.client.stateDir); const client = await createClient(deps, config.client); try { const result = await (deps.runCommand ?? runCommand)(client, name, pinnedInput); - mergeIssuedRefPins(refPinsByScope, scopeKey, name, result); + refPins.mergeCommandResult(name, result, config.client.stateDir, commandInput.session); return { isError: false, structuredContent: projectStructuredContent(name, result), @@ -107,7 +105,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C ], }; } catch (error) { - return buildErrorToolResult(error, refPinsByScope, scopeKey); + return buildErrorToolResult(error, refPins, config.client.stateDir, commandInput.session); } }, }; @@ -121,11 +119,12 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C */ function buildErrorToolResult( error: unknown, - refPinsByScope: Map>, - scopeKey: string, + refPins: ToolRefPinStore, + stateDir: string | undefined, + session: unknown, ): ToolResult { const normalized = normalizeToolError(error); - mergeDivergenceScreenRefPins(refPinsByScope, scopeKey, normalized.details); + refPins.mergeDivergenceScreen(normalized.details, stateDir, session); return { isError: true, structuredContent: normalized, @@ -133,239 +132,6 @@ function buildErrorToolResult( }; } -function mergeDivergenceScreenRefPins( - refPinsByScope: Map>, - scopeKey: string, - details: Record | undefined, -): void { - const divergence = asOptionalRecord(details?.divergence); - const screen = asOptionalRecord(divergence?.screen); - if (!screen || screen.state !== 'available') return; - const refsGeneration = screen.refsGeneration; - if (typeof refsGeneration !== 'number') return; - const issuedRefs: string[] = []; - collectRefBodies(screen.refs, issuedRefs); - mergeIntoScopedPins(refPinsByScope, scopeKey, issuedRefs, refsGeneration); -} - -/** - * #1076 versioned refs — MCP auto-pinning. Snapshot trees and find outputs - * keep plain `e12` refs (snapshots are the most token-expensive artifact the - * model consumes); the issuing response carries the tree's generation ONCE as - * `refsGeneration`. This layer sees those responses before the model does and - * keeps PER-REF provenance: every ref present in a ref-issuing response is - * recorded at that response's generation, and refs absent from it KEEP their - * older pins. That per-ref memory is the point — after snapshot(s12) then - * find(s13), a plain `@e37` from the pre-find snapshot must still forward as - * `@e37~s12` so the daemon warns precisely; a single last-seen generation - * would silently re-bless it at s13 (the exact find-blessing hole #1076 - * describes). Refs never seen in an issuing response pass through unpinned - * (the coarse #1093 warning is the floor). The model never sees or types - * suffixes. - */ -const REF_ISSUING_TOOLS: ReadonlySet = new Set(['snapshot', 'find'] as CommandName[]); - -/** - * `--settle` (#1101) makes an interaction response CONDITIONALLY ref-issuing: - * when it carries `settle.diff` + `settle.refsGeneration`, the diff's added - * lines hand out refs minted from the freshly stored settled tree. These tools - * are NOT in REF_ISSUING_TOOLS on purpose — a plain (non-settle) press carries - * no generation, and treating that as "issuing response without a generation" - * would clear the scope's pins on every ordinary tap. Absent or diff-less - * settle payloads leave pins untouched. - */ -const SETTLE_REF_ISSUING_TOOLS: ReadonlySet = new Set([ - 'press', - 'click', - 'fill', - 'longpress', -] as CommandName[]); - -const TARGET_REF_TOOLS: ReadonlySet = new Set([ - 'press', - 'click', - 'fill', - 'longpress', - 'get', -] as CommandName[]); - -/** - * Bound on remembered pins per scope. Refs still alive keep getting re-merged - * at the latest generation by every snapshot, so evicting the least recently - * ISSUED pins only degrades stale-ref precision back to the coarse floor. - */ -const MAX_REF_PINS_PER_SCOPE = 1000; - -/** - * Pin scope: state dir + session name. `stateDir` is a per-tool-call MCP - * config field, so one MCP server process can serve daemons in different - * state dirs — two same-named sessions there are different sessions and must - * not cross-pollinate generations. - */ -function readPinScopeKey(config: McpToolConfig, input: Record): string { - const session = input.session; - const sessionName = typeof session === 'string' && session.length > 0 ? session : 'default'; - // NUL separator: neither state-dir paths nor session names contain it. - return `${config.client.stateDir ?? ''}\u0000${sessionName}`; -} - -/** - * MERGE-ONLY update rule: refs present in the issuing response move to its - * generation; absent refs keep their older pins (an old pin on a replaced - * tree is exactly what makes the daemon warn). A ref-issuing response WITHOUT - * a `refsGeneration` (older daemon, find with no ref match) clears the whole - * scope — never guess. - */ -function mergeIssuedRefPins( - refPinsByScope: Map>, - scopeKey: string, - name: CommandName, - result: CommandExecutionResult, -): void { - if (SETTLE_REF_ISSUING_TOOLS.has(name)) { - mergeSettleIssuedRefPins(refPinsByScope, scopeKey, result); - return; - } - if (!REF_ISSUING_TOOLS.has(name)) return; - const record = result as CommandExecutionResult<'snapshot' | 'find'>; - const refsGeneration = record.refsGeneration; - if (typeof refsGeneration !== 'number') { - // ADR 0014: a MUTATING find returns its acted ref as diagnostic pre-action - // identity WITHOUT `refsGeneration` — it is explicitly non-issuing and must - // leave remembered pins untouched (forwarding the old pin on a later ref is - // how the daemon produces a precise stale rejection). Only a snapshot that - // genuinely issued no generation clears the scope. - if (name === 'find') return; - refPinsByScope.delete(scopeKey); - return; - } - mergeIntoScopedPins(refPinsByScope, scopeKey, readIssuedRefBodies(record), refsGeneration); -} - -/** - * MERGE-ONLY, like the snapshot/find rule: refs on the settled diff's added - * lines (plus the unchanged-interactive `tail`, when present) move to the - * settle generation; every other pin stays put (the settle capture replaced - * the tree, so an old pin on an unchanged-looking element is exactly what - * makes the daemon warn precisely). No settle payload, no diff, no digest - * refs, or no generation → not an issuing response; pins are left untouched. - */ -function mergeSettleIssuedRefPins( - refPinsByScope: Map>, - scopeKey: string, - result: CommandExecutionResult, -): void { - const interactionResult = result as CommandExecutionResult< - 'press' | 'click' | 'fill' | 'longpress' - >; - const settle = asOptionalRecord(interactionResult.settle); - if (!settle) return; - const refsGeneration = settle.refsGeneration; - if (typeof refsGeneration !== 'number') return; - const lines = asOptionalRecord(settle.diff)?.lines; - const issuedRefs: string[] = []; - collectRefBodies(lines, issuedRefs); - collectRefBodies(settle.refs, issuedRefs); - collectRefBodies(settle.tail, issuedRefs); - mergeIntoScopedPins(refPinsByScope, scopeKey, issuedRefs, refsGeneration); -} - -/** Shared merge-only tail: skip empty issuance, else create-or-reuse the scope's pin map and record. */ -function mergeIntoScopedPins( - refPinsByScope: Map>, - scopeKey: string, - issuedRefs: string[], - refsGeneration: number, -): void { - if (issuedRefs.length === 0) return; - const pins = refPinsByScope.get(scopeKey) ?? new Map(); - refPinsByScope.set(scopeKey, pins); - recordIssuedPins(pins, issuedRefs, refsGeneration); -} - -function recordIssuedPins( - pins: Map, - issuedRefs: string[], - refsGeneration: number, -): void { - for (const ref of issuedRefs) { - // delete-then-set keeps Map insertion order = issue recency for the cap. - pins.delete(ref); - pins.set(ref, refsGeneration); - } - while (pins.size > MAX_REF_PINS_PER_SCOPE) { - pins.delete(pins.keys().next().value!); - } -} - -/** Ref bodies (`e12`, no `@`) issued by a snapshot/find response. */ -function readIssuedRefBodies(record: Record): string[] { - const bodies: string[] = []; - // find: the single returned ref (`@e12`). - if (typeof record.ref === 'string' && record.ref.startsWith('@')) { - bodies.push(record.ref.slice(1)); - } - // snapshot (default level): every node carries its ref. - collectRefBodies(record.nodes, bodies); - // snapshot (digest level): the capped `{ ref, label }` list. - collectRefBodies(record.refs, bodies); - return bodies; -} - -function collectRefBodies(entries: unknown, into: string[]): void { - if (!Array.isArray(entries)) return; - for (const entry of entries) { - const ref = asOptionalRecord(entry)?.ref; - if (typeof ref === 'string' && ref.length > 0) into.push(ref); - } -} - -function pinPlainRefArguments( - name: CommandName, - input: Record, - pins: Map | undefined, -): Record { - // No remembered pins for this scope → pass refs through unpinned. - if (pins === undefined || pins.size === 0) return input; - if (name === 'wait') return pinWaitRef(input, pins) ?? input; - if (TARGET_REF_TOOLS.has(name)) return pinTargetRef(input, pins) ?? input; - return input; -} - -function pinWaitRef( - record: Record, - pins: Map, -): Record | undefined { - if (typeof record.ref !== 'string') return undefined; - const pinned = pinRef(record.ref, pins); - return pinned === record.ref ? undefined : { ...record, ref: pinned }; -} - -function pinTargetRef( - record: Record, - pins: Map, -): Record | undefined { - const target = asOptionalRecord(record.target); - if (target?.kind !== 'ref' || typeof target.ref !== 'string') return undefined; - const pinned = pinRef(target.ref, pins); - return pinned === target.ref ? undefined : { ...record, target: { ...target, ref: pinned } }; -} - -function pinRef(ref: string, pins: Map): string { - // Only pin the canonical plain form `@e12`: an existing `~` means the ref is - // already pinned (or malformed — the daemon owns rejecting that), and a - // missing `@` prefix is not a ref the daemon would accept anyway. Refs with - // no recorded provenance pass through unpinned — never guess. - if (!ref.startsWith('@') || ref.includes('~')) return ref; - const generation = pins.get(ref.slice(1)); - return generation === undefined ? ref : `${ref}~s${generation}`; -} - -function asOptionalRecord(value: unknown): Record | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - return value as Record; -} - export const commandToolExecutor = createCommandToolExecutor(); async function createClient( @@ -421,13 +187,14 @@ function readClientConfig(record: Record): AgentDeviceClientCon function readResponseLevel(value: unknown): ResponseLevel | undefined { if (value === undefined) return undefined; - if (typeof value !== 'string' || !(RESPONSE_LEVELS as readonly string[]).includes(value)) { + const level = RESPONSE_LEVELS.find((candidate) => candidate === value); + if (level === undefined) { throw new AppError( 'INVALID_ARGS', "Expected responseLevel to be one of 'digest', 'default', or 'full'.", ); } - return value as ResponseLevel; + return level; } function readMcpOutputFormat(outputFormat: unknown): McpOutputFormat { diff --git a/src/mcp/tool-ref-pins.ts b/src/mcp/tool-ref-pins.ts new file mode 100644 index 0000000000..4931694e4f --- /dev/null +++ b/src/mcp/tool-ref-pins.ts @@ -0,0 +1,291 @@ +import type { SnapshotNode } from '../kernel/snapshot.ts'; +import type { CommandName } from '../commands/command-metadata.ts'; +import type { CommandExecutionResult } from '../commands/command-surface.ts'; +import { asOptionalRecord } from '../utils/parsing.ts'; + +export type ToolRefPinStore = { + pinInput( + name: CommandName, + input: Record, + stateDir: string | undefined, + ): Record; + mergeCommandResult( + name: CommandName, + result: CommandExecutionResult, + stateDir: string | undefined, + session: unknown, + ): void; + mergeDivergenceScreen( + details: Record | undefined, + stateDir: string | undefined, + session: unknown, + ): void; +}; + +export function createToolRefPinStore(): ToolRefPinStore { + const refPinsByScope = new Map>(); + return { + pinInput: (name, input, stateDir) => + pinPlainRefArguments(name, input, getScopePins(refPinsByScope, stateDir, input.session)), + mergeCommandResult: (name, result, stateDir, session) => + mergeCommandResult(refPinsByScope, name, result, stateDir, session), + mergeDivergenceScreen: (details, stateDir, session) => + mergeDivergenceScreenRefPins(refPinsByScope, makeScopeKey(stateDir, session), details), + }; +} + +/** + * #1076 versioned refs — MCP auto-pinning. Snapshot trees and find outputs + * keep plain `e12` refs (snapshots are the most token-expensive artifact the + * model consumes); the issuing response carries the tree's generation ONCE as + * `refsGeneration`. This layer sees those responses before the model does and + * keeps PER-REF provenance: every ref present in a ref-issuing response is + * recorded at that response's generation, and refs absent from it KEEP their + * older pins. That per-ref memory is the point — after snapshot(s12) then + * find(s13), a plain `@e37` from the pre-find snapshot must still forward as + * `@e37~s12` so the daemon warns precisely; a single last-seen generation + * would silently re-bless it at s13 (the exact find-blessing hole #1076 + * describes). Refs never seen in an issuing response pass through unpinned + * (the coarse #1093 warning is the floor). The model never sees or types + * suffixes. + */ +const REF_ISSUING_TOOLS: ReadonlySet = new Set(['snapshot', 'find'] as const); + +/** + * `--settle` (#1101) makes an interaction response CONDITIONALLY ref-issuing: + * when it carries `settle.diff` + `settle.refsGeneration`, the diff's added + * lines hand out refs minted from the freshly stored settled tree. These tools + * are NOT in REF_ISSUING_TOOLS on purpose — a plain (non-settle) press carries + * no generation, and treating that as "issuing response without a generation" + * would clear the scope's pins on every ordinary tap. Absent or diff-less + * settle payloads leave pins untouched. + */ +const SETTLE_REF_ISSUING_TOOLS: ReadonlySet = new Set([ + 'press', + 'click', + 'fill', + 'longpress', +] as const); + +const TARGET_REF_TOOLS: ReadonlySet = new Set([ + 'press', + 'click', + 'fill', + 'longpress', + 'get', +] as const); + +/** + * Bound on remembered pins per scope. Refs still alive keep getting re-merged + * at the latest generation by every snapshot, so evicting the least recently + * ISSUED pins only degrades stale-ref precision back to the coarse floor. + */ +const MAX_REF_PINS_PER_SCOPE = 1000; + +function getScopePins( + refPinsByScope: Map>, + stateDir: string | undefined, + session: unknown, +): Map | undefined { + return refPinsByScope.get(makeScopeKey(stateDir, session)); +} + +/** + * Pin scope: state dir + session name. `stateDir` is a per-tool-call MCP + * config field, so one MCP server process can serve daemons in different + * state dirs — two same-named sessions there are different sessions and must + * not cross-pollinate generations. + */ +function makeScopeKey(stateDir: string | undefined, session: unknown): string { + const sessionName = typeof session === 'string' && session.length > 0 ? session : 'default'; + // NUL separator: neither state-dir paths nor session names contain it. + return `${stateDir ?? ''}\u0000${sessionName}`; +} + +function mergeCommandResult( + refPinsByScope: Map>, + name: CommandName, + result: CommandExecutionResult, + stateDir: string | undefined, + session: unknown, +): void { + const scopeKey = makeScopeKey(stateDir, session); + if (SETTLE_REF_ISSUING_TOOLS.has(name)) { + mergeSettleIssuedRefPins( + refPinsByScope, + scopeKey, + result as CommandExecutionResult<'press' | 'click' | 'fill' | 'longpress'>, + ); + return; + } + if (!REF_ISSUING_TOOLS.has(name)) return; + if (name === 'find') { + mergeFindRefPins(refPinsByScope, scopeKey, result as CommandExecutionResult<'find'>); + } else { + mergeSnapshotRefPins(refPinsByScope, scopeKey, result as CommandExecutionResult<'snapshot'>); + } +} + +/** + * MERGE-ONLY update rule: refs present in the issuing response move to its + * generation; absent refs keep their older pins (an old pin on a replaced + * tree is exactly what makes the daemon warn). A ref-issuing response WITHOUT + * a `refsGeneration` (older daemon, find with no ref match) clears the whole + * scope — never guess. + */ +type SnapshotPinView = { + refsGeneration?: number; + refs?: Array<{ ref: string }>; + nodes?: SnapshotNode[]; +}; + +function mergeSnapshotRefPins( + refPinsByScope: Map>, + scopeKey: string, + result: SnapshotPinView, +): void { + const refsGeneration = result.refsGeneration; + if (typeof refsGeneration !== 'number') { + refPinsByScope.delete(scopeKey); + return; + } + const bodies: string[] = []; + for (const { ref } of result.refs ?? []) { + bodies.push(ref); + } + for (const node of result.nodes ?? []) { + bodies.push(node.ref); + } + mergeIntoScopedPins(refPinsByScope, scopeKey, bodies, refsGeneration); +} + +function mergeFindRefPins( + refPinsByScope: Map>, + scopeKey: string, + result: CommandExecutionResult<'find'>, +): void { + // ADR 0014: a MUTATING find returns its acted ref as diagnostic pre-action + // identity WITHOUT `refsGeneration` — it is explicitly non-issuing and must + // leave remembered pins untouched (forwarding the old pin on a later ref is + // how the daemon produces a precise stale rejection). Only a read-only find + // that genuinely found a ref with a generation gets pinned. + const refsGeneration = result.refsGeneration; + const ref = result.ref; + if (typeof refsGeneration !== 'number' || typeof ref !== 'string' || !ref.startsWith('@')) { + return; + } + mergeIntoScopedPins(refPinsByScope, scopeKey, [ref.slice(1)], refsGeneration); +} + +/** + * MERGE-ONLY, like the snapshot/find rule: refs on the settled diff's added + * lines (plus the unchanged-interactive `tail`, when present) move to the + * settle generation; every other pin stays put (the settle capture replaced + * the tree, so an old pin on an unchanged-looking element is exactly what + * makes the daemon warn precisely). No settle payload, no diff, no digest + * refs, or no generation → not an issuing response; pins are left untouched. + */ +function mergeSettleIssuedRefPins( + refPinsByScope: Map>, + scopeKey: string, + result: CommandExecutionResult<'press' | 'click' | 'fill' | 'longpress'>, +): void { + const { settle } = result; + if (settle?.refsGeneration === undefined) return; + const issuedRefs = [...(settle.diff?.lines ?? []), ...(settle.refs ?? []), ...(settle.tail ?? [])] + .map((entry) => entry.ref) + .filter((ref): ref is string => typeof ref === 'string'); + mergeIntoScopedPins(refPinsByScope, scopeKey, issuedRefs, settle.refsGeneration); +} + +/** Shared merge-only tail: skip empty issuance, else create-or-reuse the scope's pin map and record. */ +function mergeIntoScopedPins( + refPinsByScope: Map>, + scopeKey: string, + issuedRefs: string[], + refsGeneration: number, +): void { + if (issuedRefs.length === 0) return; + const pins = refPinsByScope.get(scopeKey) ?? new Map(); + refPinsByScope.set(scopeKey, pins); + recordIssuedPins(pins, issuedRefs, refsGeneration); +} + +function recordIssuedPins( + pins: Map, + issuedRefs: string[], + refsGeneration: number, +): void { + for (const ref of issuedRefs) { + // delete-then-set keeps Map insertion order = issue recency for the cap. + pins.delete(ref); + pins.set(ref, refsGeneration); + } + while (pins.size > MAX_REF_PINS_PER_SCOPE) { + pins.delete(pins.keys().next().value!); + } +} + +function mergeDivergenceScreenRefPins( + refPinsByScope: Map>, + scopeKey: string, + details: Record | undefined, +): void { + const divergence = asOptionalRecord(details?.divergence); + const screen = asOptionalRecord(divergence?.screen); + if (screen?.state !== 'available') return; + const refsGeneration = screen.refsGeneration; + if (typeof refsGeneration !== 'number') return; + const issuedRefs: string[] = []; + collectDivergenceRefs(screen.refs, issuedRefs); + mergeIntoScopedPins(refPinsByScope, scopeKey, issuedRefs, refsGeneration); +} + +function collectDivergenceRefs(refs: unknown, into: string[]): void { + if (!Array.isArray(refs)) return; + for (const entry of refs) { + const ref = asOptionalRecord(entry)?.ref; + if (typeof ref === 'string' && ref.length > 0) into.push(ref); + } +} + +function pinPlainRefArguments( + name: CommandName, + input: Record, + pins: Map | undefined, +): Record { + // No remembered pins for this scope → pass refs through unpinned. + if (pins === undefined || pins.size === 0) return input; + if (name === 'wait') return pinWaitRef(input, pins) ?? input; + if (TARGET_REF_TOOLS.has(name)) return pinTargetRef(input, pins) ?? input; + return input; +} + +function pinWaitRef( + record: Record, + pins: Map, +): Record | undefined { + if (typeof record.ref !== 'string') return undefined; + const pinned = pinRef(record.ref, pins); + return pinned === record.ref ? undefined : { ...record, ref: pinned }; +} + +function pinTargetRef( + record: Record, + pins: Map, +): Record | undefined { + const target = asOptionalRecord(record.target); + if (target?.kind !== 'ref' || typeof target.ref !== 'string') return undefined; + const pinned = pinRef(target.ref, pins); + return pinned === target.ref ? undefined : { ...record, target: { ...target, ref: pinned } }; +} + +function pinRef(ref: string, pins: Map): string { + // Only pin the canonical plain form `@e12`: an existing `~` means the ref is + // already pinned (or malformed — the daemon owns rejecting that), and a + // missing `@` prefix is not a ref the daemon would accept anyway. Refs with + // no recorded provenance pass through unpinned — never guess. + if (!ref.startsWith('@') || ref.includes('~')) return ref; + const generation = pins.get(ref.slice(1)); + return generation === undefined ? ref : `${ref}~s${generation}`; +} diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index 294638d431..64dc3b72a7 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -143,6 +143,10 @@ export function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +export function asOptionalRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + type WithoutUndefined> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K]; } & {