From 50ca625332b4a99823af7a59f5c241ccd32c412b Mon Sep 17 00:00:00 2001 From: BK Date: Fri, 4 Sep 2026 11:15:26 +0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(engine-a):=20per-device=20sessions=20?= =?UTF-8?q?=E2=80=94=20cross-device=20dispatches=20stop=20colliding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine binds a session NAME to one device; alloy's fixed 'alloy' session made any cross-device sequence fail with 'is owned by session alloy' (documented Crucible pain). Clients are now cached per (engine, session); every device-scoped dispatch derives its session from the udid (alloy-); device-agnostic calls (devices list, apps list without udid) keep the bare default. reset seams clear ALL clients so stale-handle heal still rebuilds every session. --- src/adapter-a.ts | 26 +++++++++++++++++------- src/registrations.ts | 43 +++++++++++++++++++++++++++------------- test/contract.helpers.ts | 2 +- test/contract.test.ts | 11 ++++++++++ 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/adapter-a.ts b/src/adapter-a.ts index 5bd1061..8d27674 100644 --- a/src/adapter-a.ts +++ b/src/adapter-a.ts @@ -70,18 +70,30 @@ export interface AClient { }; } -let cached: { key: string; client: AClient } | null = null; +/** One client per (engine, session) — the engine binds a session NAME to one + * device; a shared 'alloy' session made any cross-device sequence collide + * ("is owned by session alloy"). Per-device sessions (`alloy-`) give + * each device its own session + claim; DEVICE-SCOPED tools pass d.udid, + * device-agnostic ones (list/health) use the bare 'alloy' default. */ +const clients = new Map(); -export async function loadEngineAClient(resolved: ResolvedEngines['engineA']): Promise { - if (cached && cached.key === resolved.importUrl) return cached.client; +export function sessionForUdid(udid: string | undefined): string { + if (!udid) return 'alloy'; + return `alloy-${udid.replace(/-/g, '').slice(0, 8).toLowerCase()}`; +} + +export async function loadEngineAClient(resolved: ResolvedEngines['engineA'], session = 'alloy'): Promise { + const key = `${resolved.importUrl}::${session}`; + const cached = clients.get(key); + if (cached) return cached; const mod = (await import(resolved.importUrl)) as Record; const factoryName = resolved.clientFactory ?? 'createClient'; const factory = mod[factoryName]; if (typeof factory !== 'function') { throw new AlloyError('ENGINE_UNAVAILABLE', 'engine A client factory missing at configured entry', { engine: 'A' }); } - const client = (factory as (config?: { session?: string }) => AClient)({ session: 'alloy' }); - cached = { key: resolved.importUrl, client }; + const client = (factory as (config?: { session?: string }) => AClient)({ session }); + clients.set(key, client); return client; } @@ -127,12 +139,12 @@ export function toSettingsOptions( } export function resetEngineACacheForTests(): void { - cached = null; + clients.clear(); } /** Production seam (see resetEngineBCache): drop the cached engine-A client so * the next loadEngineAClient() re-creates it — same stale-handle recovery class * as engine B. */ export function resetEngineACache(): void { - cached = null; + clients.clear(); } diff --git a/src/registrations.ts b/src/registrations.ts index 2442577..bc48656 100644 --- a/src/registrations.ts +++ b/src/registrations.ts @@ -8,6 +8,7 @@ import { buildHealthReport, probeImports, type HealthReport } from './health.ts' import type { ServerState } from './server.ts'; import { loadEngineAClient, + sessionForUdid, toTarget, toFindQuery, toAlertAction, @@ -74,7 +75,8 @@ export function registerPhase0Tools(deps: StubDeps): void { // ── engine A surface (Phase 1) ─────────────────────────────────────────── defineToolFromRow(mustRow('alloy_devices'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string }; if (d.action === 'list') return client.devices.list(); if (d.action === 'boot') { @@ -88,7 +90,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_apps'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { action: 'open' | 'install' | 'reinstall' | 'list'; app?: string; @@ -140,7 +143,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_release'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string }; // Release engine session (SESSION_NOT_FOUND means nothing to close — not an error) try { @@ -156,7 +160,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_snapshot'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; interactiveOnly: boolean }; return client.capture.snapshot({ udid: d.udid, interactiveOnly: d.interactiveOnly }); }); @@ -168,7 +173,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_screenshot'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; outPath?: string }; const o: { udid?: string; outPath?: string } = {}; opt(o, 'udid', d.udid); @@ -177,7 +183,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_act'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; action: 'press' | 'fill' | 'scroll' | 'longpress'; @@ -200,7 +207,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_find'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; by: 'text' | 'label' | 'role' | 'id'; value: string; action: 'none' | 'tap' }; const o: Record = { query: toFindQuery(d.by, d.value), @@ -212,13 +220,15 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_alert'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; action: 'inspect' | 'accept' | 'dismiss' }; return client.command.alert({ action: toAlertAction(d.action), udid: d.udid }); }); defineToolFromRow(mustRow('alloy_settings'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; area: string; values?: Record }; return client.settings.update(toSettingsOptions(d.area, d.values, d.udid)); }); @@ -319,7 +329,8 @@ export function registerPhase0Tools(deps: StubDeps): void { // ── Phase 3: diagnostics (engine A) + js runtime (engine B) ────────────── defineToolFromRow(mustRow('alloy_network'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; limit: number; include?: 'none' | 'headers' | 'bodies' | 'all' }; const o: Record = { action: 'dump', limit: d.limit, udid: d.udid }; if (d.include !== undefined) o['include'] = d.include; @@ -327,7 +338,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_logs'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; action: 'mark' | 'capture' | 'start' | 'stop' | 'clear'; @@ -341,13 +353,15 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_perf'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; area: 'frames' | 'memory' | 'cpu' | 'trace'; action: string }; return client.observability.perf({ area: d.area, action: d.action, udid: d.udid }); }); defineToolFromRow(mustRow('alloy_push'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; app: string; payload: Record }; return client.apps.push({ app: d.app, payload: d.payload, udid: d.udid }); }); @@ -373,7 +387,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_replay'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; scriptPath: string; timeoutMs?: number }; const o: Record = { path: d.scriptPath, udid: d.udid }; if (d.timeoutMs !== undefined) o['timeoutMs'] = d.timeoutMs; diff --git a/test/contract.helpers.ts b/test/contract.helpers.ts index 9406237..4cf87ac 100644 --- a/test/contract.helpers.ts +++ b/test/contract.helpers.ts @@ -85,7 +85,7 @@ export function fakeEngineAClient() { export function makeFakeResolvedEngines(): ResolvedEngines { const dir = mkdtempSync(join(tmpdir(), 'alloy-fake-')); mkdirSync(join(dir, 'node_modules')); - writeFileSync(join(dir, 'entry-a.mjs'), `export function createClient() { return globalThis.__alloyFakeA; }\n`); + writeFileSync(join(dir, 'entry-a.mjs'), `export function createClient(cfg) { (globalThis.__alloySessions ??= []).push(cfg?.session); return globalThis.__alloyFakeA; }\n`); writeFileSync(join(dir, 'entry-b.mjs'), `export function createClient() { return globalThis.__alloyFakeB; }\n`); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fake-engine', version: '1.0.0' })); return { diff --git a/test/contract.test.ts b/test/contract.test.ts index 903a03e..4177b08 100644 --- a/test/contract.test.ts +++ b/test/contract.test.ts @@ -126,6 +126,17 @@ describe('routing table contract (generated)', () => { expect(open.foreground).toBe(true); }); + it('per-device engine sessions: two udid dispatches create two named sessions', async () => { + const rt = freshRuntime(); + (globalThis as Record)['__alloySessions'] = []; + await dispatch('alloy_apps', { action: 'open', app: 'com.example.app', udid: 'SIM-AAAAAAAA-1' }, rt.deps); + await dispatch('alloy_apps', { action: 'open', app: 'com.example.app', udid: 'SIM-BBBBBBBB-2' }, rt.deps); + const sessions = (globalThis as Record)['__alloySessions'] as string[]; + expect(sessions).toContain('alloy-simaaaaa'); + expect(sessions).toContain('alloy-simbbbbb'); + expect(new Set(sessions).size).toBe(2); + }); + it('open acquires held lease; release clears it and closes engine session', async () => { const rt = freshRuntime(); await dispatch( From 1793e3852a9ce5bd6f115318a115511b840f8b92 Mon Sep 17 00:00:00 2001 From: BK Date: Sat, 5 Sep 2026 18:10:48 +0700 Subject: [PATCH 2/5] feat(native-tree): working interactable-at + alloy_restart_app verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routing: bundleId in alloy_native_tree schema (interactable-at requires it + x/y — superRefine enforced); new alloy_restart_app row (engine B) - registrations: pass bundleId through to native-user-interactable- view-at-point (was dropped — engine B zod failed on undefined); new alloy_restart_app → engine B restart-app (devtools-injecting relaunch) Live-verified on LP consumer sim: restart-app + interactable-at returns the real view tree at a point, 27ms/probe. First launch after sim boot lands without DYLD env (ensureEnvReady latched); the first probe's reverifyEnv re-applies it, second restart injects — callers must warm up: restart → probe → restart. --- src/registrations.ts | 12 +++++++++++- src/routing.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/registrations.ts b/src/registrations.ts index bc48656..bdb94eb 100644 --- a/src/registrations.ts +++ b/src/registrations.ts @@ -242,7 +242,13 @@ export function registerPhase0Tools(deps: StubDeps): void { return callBTool(client, 'describe', { udid: d.udid }); }); - defineToolFromRow(mustRow('alloy_native_tree'), async (input) => { + defineToolFromRow(mustRow('alloy_restart_app'), async (input) => { + const client = await loadEngineBClient(requireEngineB(deps).engineB); + const d = input as { udid: string; bundleId: string }; + return callBTool(client, 'restart-app', { udid: d.udid, bundleId: d.bundleId }); + }); + +defineToolFromRow(mustRow('alloy_native_tree'), async (input) => { const client = await loadEngineBClient(requireEngineB(deps).engineB); const d = input as { udid: string; @@ -251,6 +257,9 @@ export function registerPhase0Tools(deps: StubDeps): void { id?: string; x?: number; y?: number; + /** interactable-at: engine B requires the app's bundle id (precheck + * gate + view-space mapping). find-views ignores it. */ + bundleId?: string; }; if (d.query === 'hierarchy') { return callBTool(client, 'native-full-hierarchy', { udid: d.udid }); @@ -262,6 +271,7 @@ export function registerPhase0Tools(deps: StubDeps): void { } return callBTool(client, 'native-user-interactable-view-at-point', { udid: d.udid, + bundleId: d.bundleId!, x: d.x!, y: d.y!, }); diff --git a/src/routing.ts b/src/routing.ts index 112506b..bcf91e4 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -92,6 +92,13 @@ export const routingTable: readonly RoutingRow[] = [ summary: 'Release a held device lease and end the engine session on it', schema: z.object({ udid: deviceRef }).strict(), }, + { + tool: 'alloy_restart_app', + engine: 'B', + lease: 'per-call', + summary: 'Restart app with native-devtools dylib injected (engine B restart-app)', + schema: z.object({ udid: deviceRef, bundleId: z.string().min(1) }).strict(), + }, { tool: 'alloy_snapshot', engine: 'A', @@ -210,9 +217,14 @@ export const routingTable: readonly RoutingRow[] = [ id: z.string().min(1).optional(), x: z.number().finite().optional(), y: z.number().finite().optional(), + bundleId: z.string().min(1).optional(), }) .strict() .superRefine((v, ctx) => { + if (v.query === 'interactable-at') { + if (v.x === undefined || v.y === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'x and y required for interactable-at' }); + if (!v.bundleId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'bundleId required for interactable-at' }); + } if (v.query === 'find-views' && !v.className && !v.id) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'className or id required for find-views' }); } From 3017b56c1831f61a11606386ca6423180592e4dc Mon Sep 17 00:00:00 2001 From: BK Date: Sun, 6 Sep 2026 19:31:51 +0700 Subject: [PATCH 3/5] feat(devices): thread headless through alloy_devices boot alloy_devices {action:'boot', headless:true} now reaches the engine-A client's devices.boot (zod schema + opt-threaded input). Default boot behavior unchanged. --- src/adapter-a.ts | 2 +- src/registrations.ts | 5 +++-- src/routing.ts | 1 + test/devices-headless.test.ts | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 test/devices-headless.test.ts diff --git a/src/adapter-a.ts b/src/adapter-a.ts index 8d27674..5cac45f 100644 --- a/src/adapter-a.ts +++ b/src/adapter-a.ts @@ -23,7 +23,7 @@ export interface ADevice { export interface AClient { devices: { list(options?: { udid?: string }): Promise; - boot(options?: { udid?: string }): Promise; + boot(options?: { udid?: string; headless?: boolean }): Promise; shutdown(options?: { udid?: string }): Promise; }; apps: { diff --git a/src/registrations.ts b/src/registrations.ts index bdb94eb..128e5a0 100644 --- a/src/registrations.ts +++ b/src/registrations.ts @@ -77,11 +77,12 @@ export function registerPhase0Tools(deps: StubDeps): void { defineToolFromRow(mustRow('alloy_devices'), async (input) => { const d0 = input as { udid?: string }; const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); - const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string }; + const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string; headless?: boolean }; if (d.action === 'list') return client.devices.list(); if (d.action === 'boot') { - const o: { udid?: string } = {}; + const o: { udid?: string; headless?: boolean } = {}; opt(o, 'udid', d.udid); + opt(o, 'headless', d.headless); return client.devices.boot(o); } const o: { udid?: string } = {}; diff --git a/src/routing.ts b/src/routing.ts index bcf91e4..6906096 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -77,6 +77,7 @@ export const routingTable: readonly RoutingRow[] = [ .object({ action: z.enum(['list', 'boot', 'shutdown']).default('list'), udid: deviceRef.optional(), + headless: z.boolean().optional(), }) .strict() .superRefine((v, ctx) => { diff --git a/test/devices-headless.test.ts b/test/devices-headless.test.ts new file mode 100644 index 0000000..633c5ff --- /dev/null +++ b/test/devices-headless.test.ts @@ -0,0 +1,41 @@ +/** alloy_devices boot input threading: headless reaches the engine-A client. */ +import { describe, expect, it } from 'vitest'; +import { clearRegistryForTests, dispatch } from '../src/tools.ts'; +import { registerPhase0Tools } from '../src/registrations.ts'; +import { createServerState } from '../src/server.ts'; +import { LeaseManager } from '../src/lease.ts'; +import { + calls, + installFakeEngineAClient, + makeFakeResolvedEngines, +} from './contract.helpers.ts'; + +function freshDeps() { + clearRegistryForTests(); + installFakeEngineAClient(); + const state = createServerState(); + state.resolved = makeFakeResolvedEngines(); + registerPhase0Tools({ state }); + return { state, deps: { leases: new LeaseManager(), adapters: {} } }; +} + +describe('alloy_devices headless boot', () => { + it('boot with headless forwards the option to the engine client', async () => { + const { deps } = freshDeps(); + calls.length = 0; + const r = await dispatch('alloy_devices', { action: 'boot', udid: 'SIM-1', headless: true }, deps); + expect(r.ok).toBe(true); + const bootCall = calls.find((c) => c.ns === 'devices' && c.method === 'boot'); + expect(bootCall).toBeTruthy(); + expect(bootCall?.args).toEqual({ udid: 'SIM-1', headless: true }); + }); + + it('boot without headless forwards only the udid (default GUI behavior)', async () => { + const { deps } = freshDeps(); + calls.length = 0; + const r = await dispatch('alloy_devices', { action: 'boot', udid: 'SIM-1' }, deps); + expect(r.ok).toBe(true); + const bootCall = calls.find((c) => c.ns === 'devices' && c.method === 'boot'); + expect(bootCall?.args).toEqual({ udid: 'SIM-1' }); + }); +}); From e48d890c740ccbeaf7e5fc1fc65bcde3e6901861 Mon Sep 17 00:00:00 2001 From: BK Date: Mon, 28 Sep 2026 15:53:57 +0700 Subject: [PATCH 4/5] =?UTF-8?q?feat(act):=20keyboardDismiss=20action=20?= =?UTF-8?q?=E2=80=94=20targetless=20engine=20verb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alloy_act gains 'keyboardDismiss': hides the on-screen keyboard via the engine's own dismissal (agent-device interactions.keyboardDismiss — callstack/agent-device#3005). Coordinate-free, works on every logical size; replaces device-specific Done-bar taps downstream. - routing: action enum + schema exemption (no target required) + mirror cross-ref comment for Lionidas's contract test - registrations: dispatch before target resolution (takes no target) - adapter-a: AClient interface gains keyboardDismiss NOTE: test/contract.test.ts alloy_restart_app fixture failure is PRE-EXISTING on main (verified via stash) — unrelated to this change. --- src/adapter-a.ts | 2 ++ src/registrations.ts | 3 ++- src/routing.ts | 9 ++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/adapter-a.ts b/src/adapter-a.ts index 5cac45f..000b805 100644 --- a/src/adapter-a.ts +++ b/src/adapter-a.ts @@ -48,6 +48,8 @@ export interface AClient { press(options: { target: ATarget; udid?: string; settle?: boolean }): Promise; longPress(options: { target: ATarget; udid?: string; durationMs?: number }): Promise; fill(options: { target: ATarget; text: string; udid?: string; settle?: boolean }): Promise; + /** Hide the on-screen keyboard (engine-backed, device-independent). */ + keyboardDismiss(options: { udid?: string }): Promise; scroll(options: { direction: string; udid?: string; settle?: boolean }): Promise; find(options: { query: string; action?: string; value?: string; first?: boolean; udid?: string }): Promise; }; diff --git a/src/registrations.ts b/src/registrations.ts index 128e5a0..33a38df 100644 --- a/src/registrations.ts +++ b/src/registrations.ts @@ -188,12 +188,13 @@ export function registerPhase0Tools(deps: StubDeps): void { const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; - action: 'press' | 'fill' | 'scroll' | 'longpress'; + action: 'press' | 'fill' | 'scroll' | 'longpress' | 'keyboardDismiss'; target?: string; text?: string; direction?: 'up' | 'down' | 'left' | 'right'; settle: boolean; }; + if (d.action === 'keyboardDismiss') return client.interactions.keyboardDismiss({ udid: d.udid }); if (d.action === 'scroll') { return client.interactions.scroll({ direction: d.direction!, udid: d.udid, settle: d.settle }); } diff --git a/src/routing.ts b/src/routing.ts index 6906096..843923b 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -130,11 +130,14 @@ export const routingTable: readonly RoutingRow[] = [ tool: 'alloy_act', engine: 'A', lease: 'per-call', + // Lionidas mirror: server/src/manual/alloy-act-contract.test.ts pins this + // verb set (mirror + live-schema tiers). Changing actions/fields here? + // Update that mirror in the same change. summary: 'Press, click, fill, scroll, or long-press with post-action settle diff', schema: z .object({ udid: deviceRef, - action: z.enum(['press', 'fill', 'scroll', 'longpress']), + action: z.enum(['press', 'fill', 'scroll', 'longpress', 'keyboardDismiss']), target: z .union([ z.string().min(1), @@ -154,8 +157,8 @@ export const routingTable: readonly RoutingRow[] = [ if (v.action === 'scroll' && !v.direction) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'direction is required for scroll' }); } - if (v.action !== 'scroll' && !v.target) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'target is required unless action=scroll' }); + if (v.action !== 'scroll' && v.action !== 'keyboardDismiss' && !v.target) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'target is required unless action=scroll or keyboardDismiss' }); } }), }, From 4f8259f8f7147e80da56ba751e8a86b49c444e8f Mon Sep 17 00:00:00 2001 From: BK Date: Mon, 28 Sep 2026 16:04:54 +0700 Subject: [PATCH 5/5] test(contract): add missing alloy_restart_app valid-input fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'every row accepts its valid input' contract test failed because alloy_restart_app (added to the routing table in 1793e38) never got a VALID_INPUTS entry — expect(input).toBeDefined() tripped before the dispatch could even run. The fake engine B's callTool accepts arbitrary tool names, so {udid, bundleId} exercises the full zod → dispatch → callBTool('restart-app') path. Pre-existing failure on main; found again as CI red on this PR's first check run. 73/73 now. --- test/contract.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/contract.test.ts b/test/contract.test.ts index 4177b08..bc26156 100644 --- a/test/contract.test.ts +++ b/test/contract.test.ts @@ -40,6 +40,7 @@ const VALID_INPUTS: Record = { alloy_devices: { action: 'list' }, alloy_apps: { action: 'list' }, alloy_release: { udid: 'SIM-1' }, + alloy_restart_app: { udid: 'SIM-1', bundleId: 'com.example.app' }, alloy_snapshot: { udid: 'SIM-1' }, alloy_stream: { udid: 'SIM-1' }, alloy_screenshot: { udid: 'SIM-1' },