From 83cc3645d4f14bd646017dddeceb2b366e3111ef Mon Sep 17 00:00:00 2001 From: Vellum Assistant Date: Sun, 3 May 2026 03:39:12 +0000 Subject: [PATCH] feat(runtime): add /v1/host-app-control-result route Add the result-pickup HTTP endpoint that the macOS client POSTs to after executing an app-control action. Mirrors the host-cu-result route. Forwards the payload to conversation.hostAppControlProxy.resolve(requestId, payload). Adds the field declaration on Conversation; full lifecycle wiring lands in PR 10. Part of plan: app-control-skill.md (PR 9 of 16) --- .../__tests__/host-app-control-routes.test.ts | 263 ++++++++++++++++++ assistant/src/daemon/conversation.ts | 8 + .../runtime/routes/host-app-control-routes.ts | 135 +++++++++ assistant/src/runtime/routes/index.ts | 2 + 4 files changed, 408 insertions(+) create mode 100644 assistant/src/__tests__/host-app-control-routes.test.ts create mode 100644 assistant/src/runtime/routes/host-app-control-routes.ts diff --git a/assistant/src/__tests__/host-app-control-routes.test.ts b/assistant/src/__tests__/host-app-control-routes.test.ts new file mode 100644 index 00000000000..e7db0394e01 --- /dev/null +++ b/assistant/src/__tests__/host-app-control-routes.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for the /v1/host-app-control-result route handler. + * + * Resolution flows through `pendingInteractions.get/resolve` → `findConversation` + * → `conversation.hostAppControlProxy.resolve`. Late delivery (no pending + * interaction or no conversation) returns 200 without crashing. + */ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; + +// ── Module mocks ───────────────────────────────────────────────────── + +mock.module("../config/env.js", () => ({ + isHttpAuthDisabled: () => true, + hasUngatedHttpAuthDisabled: () => false, +})); + +interface PendingEntry { + conversationId: string; + kind: string; +} + +const pending = new Map(); + +mock.module("../runtime/pending-interactions.js", () => ({ + get: (requestId: string) => pending.get(requestId), + resolve: (requestId: string) => { + const entry = pending.get(requestId); + if (entry) pending.delete(requestId); + return entry; + }, +})); + +interface FakeConversation { + conversationId: string; + hostAppControlProxy?: { + resolve: (requestId: string, payload: unknown) => void; + }; +} + +const conversations = new Map(); + +mock.module("../daemon/conversation-store.js", () => ({ + findConversation: (id: string) => conversations.get(id), +})); + +// ── Real imports (after mocks) ─────────────────────────────────────── + +import { BadRequestError } from "../runtime/routes/errors.js"; +import { ROUTES } from "../runtime/routes/host-app-control-routes.js"; + +afterAll(() => { + mock.restore(); +}); + +const handleHostAppControlResult = ROUTES.find( + (r) => r.endpoint === "host-app-control-result", +)!.handler; + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("handleHostAppControlResult", () => { + beforeEach(() => { + pending.clear(); + conversations.clear(); + }); + + test("happy path: forwards payload to conversation.hostAppControlProxy.resolve", async () => { + const requestId = "ac-req-happy"; + const conversationId = "conv-1"; + pending.set(requestId, { conversationId, kind: "host_app_control" }); + + const resolveCalls: Array<{ requestId: string; payload: unknown }> = []; + conversations.set(conversationId, { + conversationId, + hostAppControlProxy: { + resolve(rid, payload) { + resolveCalls.push({ requestId: rid, payload }); + }, + }, + }); + + const result = await handleHostAppControlResult({ + body: { + requestId, + state: "running", + pngBase64: "AAAA", + windowBounds: { x: 1, y: 2, width: 800, height: 600 }, + executionResult: "ok", + }, + }); + + expect(result).toEqual({ accepted: true }); + expect(resolveCalls).toHaveLength(1); + expect(resolveCalls[0].requestId).toBe(requestId); + expect(resolveCalls[0].payload).toEqual({ + requestId, + state: "running", + pngBase64: "AAAA", + windowBounds: { x: 1, y: 2, width: 800, height: 600 }, + executionResult: "ok", + }); + + // Pending interaction was consumed. + expect(pending.has(requestId)).toBe(false); + }); + + test("happy path: end-to-end resolves the awaiting proxy promise with the payload", async () => { + const requestId = "ac-req-await"; + const conversationId = "conv-await"; + pending.set(requestId, { conversationId, kind: "host_app_control" }); + + // Wire a real awaiter — this mirrors the proxy's pending map behavior + // without coupling the route test to HostProxyBase internals. + let resolved: unknown; + const awaitable = new Promise((resolveFn) => { + conversations.set(conversationId, { + conversationId, + hostAppControlProxy: { + resolve(rid, payload) { + if (rid === requestId) resolveFn(payload); + }, + }, + }); + }).then((p) => { + resolved = p; + return p; + }); + + await handleHostAppControlResult({ + body: { requestId, state: "running", executionResult: "done" }, + }); + await awaitable; + + expect(resolved).toEqual({ + requestId, + state: "running", + executionResult: "done", + }); + }); + + test("late delivery (no pending interaction): returns 200, no crash", async () => { + const result = await handleHostAppControlResult({ + body: { + requestId: "no-such-request", + state: "running", + }, + }); + expect(result).toEqual({ accepted: true }); + }); + + test("late delivery (conversation gone): returns 200, no crash", async () => { + const requestId = "ac-req-orphan"; + pending.set(requestId, { + conversationId: "conv-gone", + kind: "host_app_control", + }); + // No conversation registered for "conv-gone". + + const result = await handleHostAppControlResult({ + body: { requestId, state: "running" }, + }); + expect(result).toEqual({ accepted: true }); + // Pending interaction was still consumed so it cannot leak. + expect(pending.has(requestId)).toBe(false); + }); + + test("late delivery (proxy missing on conversation): returns 200, no crash", async () => { + const requestId = "ac-req-noproxy"; + const conversationId = "conv-no-proxy"; + pending.set(requestId, { conversationId, kind: "host_app_control" }); + conversations.set(conversationId, { conversationId }); // hostAppControlProxy undefined + + const result = await handleHostAppControlResult({ + body: { requestId, state: "running" }, + }); + expect(result).toEqual({ accepted: true }); + expect(pending.has(requestId)).toBe(false); + }); + + test("wrong pending kind: returns 200 without forwarding (treated as late delivery)", async () => { + const requestId = "ac-req-wrong-kind"; + pending.set(requestId, { conversationId: "conv-1", kind: "host_cu" }); + + const resolveCalls: unknown[] = []; + conversations.set("conv-1", { + conversationId: "conv-1", + hostAppControlProxy: { + resolve: () => resolveCalls.push("called"), + }, + }); + + const result = await handleHostAppControlResult({ + body: { requestId, state: "running" }, + }); + + expect(result).toEqual({ accepted: true }); + expect(resolveCalls).toHaveLength(0); + // Wrong-kind interaction is not consumed. + expect(pending.has(requestId)).toBe(true); + }); + + test("malformed body (missing): throws BadRequestError", () => { + expect(() => handleHostAppControlResult({})).toThrow(BadRequestError); + }); + + test("malformed body (non-object): throws BadRequestError", () => { + expect(() => + handleHostAppControlResult({ + body: "not an object" as unknown as Record, + }), + ).toThrow(BadRequestError); + }); + + test("malformed body (missing requestId): throws BadRequestError", () => { + expect(() => + handleHostAppControlResult({ body: { state: "running" } }), + ).toThrow(BadRequestError); + }); + + test("malformed body (missing state): throws BadRequestError", () => { + expect(() => + handleHostAppControlResult({ body: { requestId: "abc" } }), + ).toThrow(BadRequestError); + }); + + test("malformed body (invalid state): throws BadRequestError", () => { + expect(() => + handleHostAppControlResult({ + body: { requestId: "abc", state: "exploded" }, + }), + ).toThrow(BadRequestError); + }); + + test("payload omits undefined optional fields (no leaking undefined keys)", async () => { + const requestId = "ac-req-min"; + const conversationId = "conv-min"; + pending.set(requestId, { conversationId, kind: "host_app_control" }); + + const resolveCalls: Array<{ payload: unknown }> = []; + conversations.set(conversationId, { + conversationId, + hostAppControlProxy: { + resolve(_rid, payload) { + resolveCalls.push({ payload }); + }, + }, + }); + + await handleHostAppControlResult({ + body: { requestId, state: "minimized" }, + }); + + expect(resolveCalls).toHaveLength(1); + const payload = resolveCalls[0].payload as Record; + expect(payload).toEqual({ requestId, state: "minimized" }); + expect(Object.prototype.hasOwnProperty.call(payload, "pngBase64")).toBe( + false, + ); + expect(Object.prototype.hasOwnProperty.call(payload, "windowBounds")).toBe( + false, + ); + }); +}); diff --git a/assistant/src/daemon/conversation.ts b/assistant/src/daemon/conversation.ts index 68a4c6688a9..3c21f4ad854 100644 --- a/assistant/src/daemon/conversation.ts +++ b/assistant/src/daemon/conversation.ts @@ -117,6 +117,7 @@ import { createToolExecutor, } from "./conversation-tool-setup.js"; import { refreshWorkspaceTopLevelContextIfNeeded as refreshWorkspaceImpl } from "./conversation-workspace.js"; +import type { HostAppControlProxy } from "./host-app-control-proxy.js"; import { HostCuProxy } from "./host-cu-proxy.js"; import type { ServerMessage, @@ -204,6 +205,13 @@ export class Conversation { /** @internal */ taskRunId?: string; /** @internal */ callSessionId?: string; /** @internal */ hostCuProxy?: HostCuProxy; + /** + * Per-conversation host app-control proxy (full lifecycle wiring lands + * in PR 10). Declared here so the `/v1/host-app-control-result` route + * can forward result payloads to the awaiting promise. + * @internal + */ + hostAppControlProxy?: HostAppControlProxy; /** @internal */ cesClient?: CesClient; /** @internal */ readonly queue = new MessageQueue(); /** @internal */ currentActiveSurfaceId?: string; diff --git a/assistant/src/runtime/routes/host-app-control-routes.ts b/assistant/src/runtime/routes/host-app-control-routes.ts new file mode 100644 index 00000000000..409d8dd0b60 --- /dev/null +++ b/assistant/src/runtime/routes/host-app-control-routes.ts @@ -0,0 +1,135 @@ +/** + * Route handler for host app-control result submissions. + * + * Resolves pending host app-control proxy requests by requestId when the + * desktop client returns observation/action results via HTTP. App-control + * sessions are per-conversation (not a singleton like host-browser), so we + * look up the owning conversation through the pending-interactions tracker + * and forward the payload to that conversation's `hostAppControlProxy`. + * + * Late-delivery tolerance: returns 200 even when no pending interaction + * matches (e.g. the conversation was disposed before the client reported + * back). The proxy is best-effort — there is no consumer to notify, so a + * 4xx would only confuse a client that already executed the action. + */ +import { z } from "zod"; + +import { findConversation } from "../../daemon/conversation-store.js"; +import type { + HostAppControlResultPayload, + HostAppControlState, +} from "../../daemon/message-types/host-app-control.js"; +import * as pendingInteractions from "../pending-interactions.js"; +import { BadRequestError } from "./errors.js"; +import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; + +const VALID_STATES: ReadonlySet = new Set([ + "running", + "missing", + "minimized", + "occluded", +]); + +// --------------------------------------------------------------------------- +// POST /v1/host-app-control-result +// --------------------------------------------------------------------------- + +function handleHostAppControlResult({ body }: RouteHandlerArgs) { + if (!body || typeof body !== "object") { + throw new BadRequestError("Request body is required"); + } + + const { + requestId, + state, + pngBase64, + windowBounds, + executionResult, + executionError, + } = body as { + requestId?: string; + state?: string; + pngBase64?: string; + windowBounds?: { x: number; y: number; width: number; height: number }; + executionResult?: string; + executionError?: string; + }; + + if (!requestId || typeof requestId !== "string") { + throw new BadRequestError("requestId is required"); + } + + if (!state || !VALID_STATES.has(state as HostAppControlState)) { + throw new BadRequestError( + "state must be one of: running, missing, minimized, occluded", + ); + } + + // Late-delivery tolerance: if the pending interaction is already gone (the + // proxy timed out, the conversation was disposed, etc.), accept the post + // and move on. There is no consumer left to fail loudly to. + const peeked = pendingInteractions.get(requestId); + if (!peeked || peeked.kind !== "host_app_control") { + return { accepted: true }; + } + + const interaction = pendingInteractions.resolve(requestId)!; + const conversation = findConversation(interaction.conversationId); + if (!conversation) { + return { accepted: true }; + } + + const payload: HostAppControlResultPayload = { + requestId, + state: state as HostAppControlState, + ...(pngBase64 !== undefined ? { pngBase64 } : {}), + ...(windowBounds !== undefined ? { windowBounds } : {}), + ...(executionResult !== undefined ? { executionResult } : {}), + ...(executionError !== undefined ? { executionError } : {}), + }; + + conversation.hostAppControlProxy?.resolve(requestId, payload); + + return { accepted: true }; +} + +// --------------------------------------------------------------------------- +// Route definitions (shared HTTP + IPC) +// --------------------------------------------------------------------------- + +export const ROUTES: RouteDefinition[] = [ + { + operationId: "host_app_control_result", + endpoint: "host-app-control-result", + method: "POST", + requireGuardian: true, + summary: "Submit host app-control result", + description: + "Resolve a pending host app-control request by requestId. Returns 200 even when no pending interaction matches (late delivery is tolerated).", + tags: ["host"], + requestBody: z.object({ + requestId: z.string().describe("Pending app-control request ID"), + state: z + .enum(["running", "missing", "minimized", "occluded"]) + .describe("Lifecycle state of the targeted application"), + pngBase64: z + .string() + .describe("Base64 PNG screenshot of the targeted app window") + .optional(), + windowBounds: z + .object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + }) + .optional(), + executionResult: z.string().optional(), + executionError: z.string().optional(), + }), + responseBody: z.object({ + accepted: z.boolean(), + }), + handler: handleHostAppControlResult, + }, +]; diff --git a/assistant/src/runtime/routes/index.ts b/assistant/src/runtime/routes/index.ts index 92337d9f10d..aea7e2ff8f7 100644 --- a/assistant/src/runtime/routes/index.ts +++ b/assistant/src/runtime/routes/index.ts @@ -48,6 +48,7 @@ import { ROUTES as GUARDIAN_ACTION_ROUTES } from "./guardian-action-routes.js"; import { ROUTES as HEARTBEAT_ROUTES } from "./heartbeat-routes.js"; import { ROUTES as HOME_FEED_ROUTES } from "./home-feed-routes.js"; import { ROUTES as HOME_STATE_ROUTES } from "./home-state-routes.js"; +import { ROUTES as HOST_APP_CONTROL_ROUTES } from "./host-app-control-routes.js"; import { ROUTES as HOST_BASH_ROUTES } from "./host-bash-routes.js"; import { ROUTES as HOST_BROWSER_ROUTES } from "./host-browser-routes.js"; import { ROUTES as HOST_CU_ROUTES } from "./host-cu-routes.js"; @@ -143,6 +144,7 @@ export const ROUTES: RouteDefinition[] = [ ...HEARTBEAT_ROUTES, ...HOME_FEED_ROUTES, ...HOME_STATE_ROUTES, + ...HOST_APP_CONTROL_ROUTES, ...HOST_BASH_ROUTES, ...HOST_BROWSER_ROUTES, ...HOST_CU_ROUTES,