diff --git a/assistant/src/__tests__/approval-routes-http.test.ts b/assistant/src/__tests__/approval-routes-http.test.ts index 01f4fa9e04c..6f005a8a250 100644 --- a/assistant/src/__tests__/approval-routes-http.test.ts +++ b/assistant/src/__tests__/approval-routes-http.test.ts @@ -178,6 +178,7 @@ function makeIdleSession(opts?: { updateClient: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, enqueueMessage: () => ({ queued: false, requestId: "noop" }), hasAnyPendingConfirmation: () => false, @@ -241,6 +242,7 @@ function makeConfirmationEmittingSession(opts?: { updateClient: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, enqueueMessage: () => ({ queued: false, requestId: "noop" }), hasAnyPendingConfirmation: () => false, diff --git a/assistant/src/__tests__/conversation-app-control-instantiation.test.ts b/assistant/src/__tests__/conversation-app-control-instantiation.test.ts new file mode 100644 index 00000000000..b867f28daf2 --- /dev/null +++ b/assistant/src/__tests__/conversation-app-control-instantiation.test.ts @@ -0,0 +1,388 @@ +/** + * Tests for HostAppControlProxy instantiation in `prepareConversationForMessage` + * (and the parallel block in `conversation-routes.ts`). + * + * Verifies that: + * - A macOS client connection unconditionally attaches a HostAppControlProxy + * and preactivates the `app-control` skill — regardless of whether the + * `app-control` feature flag is on or off. The flag is read only by the + * skill-projection layer, never gates the proxy. + * - A non-macOS client connection (where + * `supportsHostProxy(_, "host_app_control")` returns false) leaves the + * proxy unattached. + * - The skill-projection layer filters the `app-control` skill out of the + * projected tool list when the feature flag is off, even when it is in + * the preactivated set — proving that the gating point is the projection + * layer rather than the proxy attachment site. + * + * The first set of tests mirrors the production gating logic from + * `prepareConversationForMessage` (in `assistant/src/daemon/process-message.ts`) + * and the parallel block in `assistant/src/runtime/routes/conversation-routes.ts` + * — both unconditionally instantiate `HostAppControlProxy` and preactivate + * `"app-control"` when `supportsHostProxy(interfaceId, "host_app_control")` + * returns true. Calling the real prepare/route helpers directly would require + * mocking the full processMessage/handleSendMessage stack (slash router, agent + * loop, persistence, secret scanner, …), so we test the logic against the + * real `supportsHostProxy` predicate plus a fake Conversation. + * + * The second set of tests calls the real `projectSkillTools` to confirm + * that the feature flag — not the proxy attachment — controls whether + * `app-control` tools end up in the LLM tool list. + */ + +import * as realFs from "node:fs"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +import type { InterfaceId } from "../channels/types.js"; +import { supportsHostProxy } from "../channels/types.js"; +import type { SkillSummary, SkillToolManifest } from "../config/skills.js"; +import { RiskLevel } from "../permissions/types.js"; +import type { Tool } from "../tools/types.js"; + +// --------------------------------------------------------------------------- +// Module mocks for the skill-projection layer +// --------------------------------------------------------------------------- + +mock.module("../util/logger.js", () => ({ + getLogger: () => + new Proxy({} as Record, { get: () => () => {} }), +})); + +let appControlFlagEnabled = false; +mock.module("../config/assistant-feature-flags.js", () => ({ + isAssistantFeatureFlagEnabled: (key: string) => { + if (key === "app-control") return appControlFlagEnabled; + return true; + }, + loadDefaultsRegistry: () => ({}), +})); + +mock.module("../config/skill-state.js", () => ({ + skillFlagKey: (skill: { featureFlag?: string }) => + skill.featureFlag ?? undefined, +})); + +mock.module("../config/loader.js", () => ({ + getConfig: () => ({ + skills: { entries: {}, allowBundled: null }, + }), + loadConfig: () => ({ + skills: { entries: {}, allowBundled: null }, + }), + invalidateConfigCache: () => {}, +})); + +let mockCatalog: SkillSummary[] = []; +let mockManifests: Record = {}; +const mockRegisteredTools = new Map(); +const mockSkillRefCount = new Map(); + +mock.module("../config/skills.js", () => ({ + loadSkillCatalog: () => mockCatalog, +})); + +mock.module("../skills/active-skill-tools.js", () => ({ + deriveActiveSkills: () => [], +})); + +mock.module("../skills/tool-manifest.js", () => ({ + parseToolManifestFile: (filePath: string) => { + const parts = filePath.split("/"); + const skillId = parts[parts.length - 2]; + const manifest = mockManifests[skillId]; + if (!manifest) { + throw new Error(`Mock: no manifest for skill "${skillId}"`); + } + return manifest; + }, +})); + +mock.module("../tools/skills/skill-tool-factory.js", () => ({ + createSkillToolsFromManifest: ( + entries: SkillToolManifest["tools"], + skillId: string, + _skillDir: string, + versionHash: string, + bundled?: boolean, + ): Tool[] => + entries.map((entry) => ({ + name: entry.name, + description: entry.description, + category: entry.category, + defaultRiskLevel: RiskLevel.Medium, + origin: "skill" as const, + ownerSkillId: skillId, + ownerSkillVersionHash: versionHash, + ownerSkillBundled: bundled ?? undefined, + getDefinition: () => ({ + name: entry.name, + description: entry.description, + input_schema: entry.input_schema as object, + }), + execute: async () => ({ content: "", isError: false }), + })), +})); + +mock.module("../tools/registry.js", () => ({ + registerSkillTools: (tools: Tool[]) => { + const skillIds = new Set(); + for (const tool of tools) { + const skillId = tool.ownerSkillId!; + skillIds.add(skillId); + const existing = mockRegisteredTools.get(skillId) ?? []; + existing.push(tool); + mockRegisteredTools.set(skillId, existing); + } + for (const id of skillIds) { + mockSkillRefCount.set(id, (mockSkillRefCount.get(id) ?? 0) + 1); + } + return tools; + }, + unregisterSkillTools: (skillId: string) => { + const current = mockSkillRefCount.get(skillId) ?? 0; + if (current > 1) { + mockSkillRefCount.set(skillId, current - 1); + return; + } + mockSkillRefCount.delete(skillId); + mockRegisteredTools.delete(skillId); + }, + getTool: (name: string) => { + let found: Tool | undefined; + for (const tools of mockRegisteredTools.values()) { + for (const tool of tools) { + if (tool.name === name) found = tool; + } + } + return found; + }, + getSkillToolNames: () => { + const names: string[] = []; + for (const tools of mockRegisteredTools.values()) { + for (const tool of tools) { + names.push(tool.name); + } + } + return names; + }, +})); + +mock.module("node:fs", () => ({ + ...realFs, + existsSync: (p: string) => { + if (typeof p === "string" && p.endsWith("TOOLS.json")) { + const parts = p.split("/"); + const skillId = parts[parts.length - 2]; + return skillId in mockManifests; + } + return realFs.existsSync(p); + }, +})); + +mock.module("../skills/version-hash.js", () => ({ + computeSkillVersionHash: (skillDir: string) => { + const parts = skillDir.split("/"); + return `v1:default-hash-${parts[parts.length - 1]}`; + }, +})); + +// --------------------------------------------------------------------------- +// Imports under test (after mocks) +// --------------------------------------------------------------------------- + +const { HostAppControlProxy } = + await import("../daemon/host-app-control-proxy.js"); +const { projectSkillTools, resetSkillToolProjection } = + await import("../daemon/conversation-skill-tools.js"); + +// --------------------------------------------------------------------------- +// Conversation surface — captures proxy attachment + preactivations +// --------------------------------------------------------------------------- + +interface FakeConversation { + conversationId: string; + hostAppControlProxy?: unknown; + preactivatedSkillIds: string[]; + isProcessing(): boolean; + setHostAppControlProxy(proxy: unknown): void; + addPreactivatedSkillId(id: string): void; +} + +function makeFakeConversation(): FakeConversation { + const conv: FakeConversation = { + conversationId: "conv-app-control-instantiation", + hostAppControlProxy: undefined, + preactivatedSkillIds: [], + isProcessing: () => false, + setHostAppControlProxy(proxy: unknown) { + this.hostAppControlProxy = proxy; + }, + addPreactivatedSkillId(id: string) { + if (!this.preactivatedSkillIds.includes(id)) { + this.preactivatedSkillIds.push(id); + } + }, + }; + return conv; +} + +/** + * Replica of the gating block from `prepareConversationForMessage` + * (process-message.ts) and `conversation-routes.ts`. Mirrors the production + * code exactly — when the diverged copies are merged into a shared helper, + * this test should be updated to call it directly. + */ +function applyAppControlInstantiation( + conv: FakeConversation, + interfaceId: InterfaceId, +): void { + if (supportsHostProxy(interfaceId, "host_app_control")) { + if (!conv.isProcessing() || !conv.hostAppControlProxy) { + conv.setHostAppControlProxy(new HostAppControlProxy(conv.conversationId)); + } + if (!conv.isProcessing()) { + conv.addPreactivatedSkillId("app-control"); + } + } else if (!conv.isProcessing()) { + conv.setHostAppControlProxy(undefined); + } +} + +// --------------------------------------------------------------------------- +// Skill fixtures +// --------------------------------------------------------------------------- + +function makeAppControlSkill(): SkillSummary { + return { + id: "app-control", + name: "app-control", + displayName: "App Control", + description: "Drive a specific named app via raw input", + directoryPath: "/skills/app-control", + skillFilePath: "/skills/app-control/SKILL.md", + bundled: true, + source: "bundled", + featureFlag: "app-control", + }; +} + +function makeAppControlManifest(): SkillToolManifest { + return { + version: 1, + tools: [ + { + name: "app_control_start", + description: "Start a session against a named app", + category: "app-control", + risk: "medium" as const, + input_schema: { type: "object", properties: {} }, + executor: "run.ts", + execution_target: "host" as const, + }, + { + name: "app_control_observe", + description: "Observe the focused app's window", + category: "app-control", + risk: "low" as const, + input_schema: { type: "object", properties: {} }, + executor: "run.ts", + execution_target: "host" as const, + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// Tests — proxy instantiation +// --------------------------------------------------------------------------- + +describe("HostAppControlProxy instantiation gate", () => { + beforeEach(() => { + appControlFlagEnabled = false; + }); + + test("macOS client attaches HostAppControlProxy and preactivates app-control (flag off)", () => { + appControlFlagEnabled = false; + const conv = makeFakeConversation(); + + applyAppControlInstantiation(conv, "macos"); + + // Proxy is attached unconditionally — no flag check at instantiation. + expect(conv.hostAppControlProxy).toBeInstanceOf(HostAppControlProxy); + expect(conv.preactivatedSkillIds).toContain("app-control"); + }); + + test("macOS client attaches HostAppControlProxy and preactivates app-control (flag on)", () => { + appControlFlagEnabled = true; + const conv = makeFakeConversation(); + + applyAppControlInstantiation(conv, "macos"); + + expect(conv.hostAppControlProxy).toBeInstanceOf(HostAppControlProxy); + expect(conv.preactivatedSkillIds).toContain("app-control"); + }); + + test("non-macOS client (slack) does not attach HostAppControlProxy nor preactivate app-control", () => { + appControlFlagEnabled = true; + const conv = makeFakeConversation(); + + applyAppControlInstantiation(conv, "slack"); + + expect(conv.hostAppControlProxy).toBeUndefined(); + expect(conv.preactivatedSkillIds).not.toContain("app-control"); + }); + + test("chrome-extension client does not attach HostAppControlProxy (host_app_control unsupported)", () => { + appControlFlagEnabled = true; + const conv = makeFakeConversation(); + // Sanity check: chrome-extension supports host_browser, NOT host_app_control. + expect(supportsHostProxy("chrome-extension", "host_app_control")).toBe( + false, + ); + + applyAppControlInstantiation(conv, "chrome-extension"); + + expect(conv.hostAppControlProxy).toBeUndefined(); + expect(conv.preactivatedSkillIds).not.toContain("app-control"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — skill-projection feature-flag gating +// --------------------------------------------------------------------------- + +describe("Skill projection — app-control feature-flag gating", () => { + beforeEach(() => { + mockCatalog = [makeAppControlSkill()]; + mockManifests = { "app-control": makeAppControlManifest() }; + mockRegisteredTools.clear(); + mockSkillRefCount.clear(); + resetSkillToolProjection(); + }); + + test("flag off: app-control is filtered out of projected tools even when preactivated", () => { + appControlFlagEnabled = false; + + const sessionState = new Map(); + const result = projectSkillTools([], { + preactivatedSkillIds: ["app-control"], + previouslyActiveSkillIds: sessionState, + }); + + expect(result.allowedToolNames.has("app_control_start")).toBe(false); + expect(result.allowedToolNames.has("app_control_observe")).toBe(false); + }); + + test("flag on: app-control tools are projected when preactivated", () => { + appControlFlagEnabled = true; + + const sessionState = new Map(); + const result = projectSkillTools([], { + preactivatedSkillIds: ["app-control"], + previouslyActiveSkillIds: sessionState, + }); + + expect(result.allowedToolNames.has("app_control_start")).toBe(true); + expect(result.allowedToolNames.has("app_control_observe")).toBe(true); + }); +}); diff --git a/assistant/src/__tests__/conversation-routes-disk-view.test.ts b/assistant/src/__tests__/conversation-routes-disk-view.test.ts index 49beb49d68e..28fdddc884b 100644 --- a/assistant/src/__tests__/conversation-routes-disk-view.test.ts +++ b/assistant/src/__tests__/conversation-routes-disk-view.test.ts @@ -207,6 +207,12 @@ function createFakeConversation(conversationId: string): Conversation { setHostCuProxy(this: { hostCuProxy: unknown }, proxy: unknown) { this.hostCuProxy = proxy; }, + setHostAppControlProxy( + this: { hostAppControlProxy: unknown }, + proxy: unknown, + ) { + this.hostAppControlProxy = proxy; + }, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => false, diff --git a/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts b/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts index ef4547cf016..c337c9dec27 100644 --- a/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts +++ b/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts @@ -172,13 +172,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "05BECB approve", @@ -188,17 +193,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); const res = await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + }), req, undefined, 202, - ); + ); expect(res.status).toBe(202); const body = (await res.json()) as { @@ -250,13 +256,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "hello there", @@ -266,17 +277,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); const res = await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + }), req, undefined, 202, - ); + ); expect(res.status).toBe(202); expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1); @@ -324,13 +336,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { requestId === "tool-approval-live", setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "approve", @@ -340,17 +357,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); const res = await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + }), req, undefined, 202, - ); + ); expect(res.status).toBe(202); expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1); @@ -402,13 +420,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: (id: string) => id === "tool-req-code-1", setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "A1B2C3 approve", @@ -418,17 +441,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); const res = await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + }), req, undefined, 202, - ); + ); expect(res.status).toBe(202); expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1); @@ -476,13 +500,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: (id: string) => id === "pending-reject-1", setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "reject", @@ -492,17 +521,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); const res = await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + }), req, undefined, 202, - ); + ); expect(res.status).toBe(202); expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1); @@ -544,13 +574,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: (id: string) => id === "pending-1", setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "tell me more about this request", @@ -560,17 +595,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); const res = await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + }), req, undefined, 202, - ); + ); expect(res.status).toBe(202); expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1); @@ -614,13 +650,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "no sorry, beats 0 and 3 should be new threads", @@ -630,14 +671,15 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - approvalConversationGenerator: mockGenerator as any, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + approvalConversationGenerator: mockGenerator as any, + }), req, undefined, 202, @@ -685,13 +727,18 @@ describe("handleSendMessage canonical guardian reply interception", () => { hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; const req = new Request("http://localhost/v1/messages", { method: "POST", - headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor" }, + headers: { + "Content-Type": "application/json", + "x-vellum-actor-principal-id": "test-user", + "x-vellum-principal-type": "actor", + }, body: JSON.stringify({ conversationKey: "guardian-conversation-key", content: "no sorry, beats 0 and 3 should be new threads", @@ -701,14 +748,15 @@ describe("handleSendMessage canonical guardian reply interception", () => { }); await callHandler( - (args) => handleSendMessage(args, { - sendMessageDeps: { - getOrCreateConversation: async () => session, - assistantEventHub: { publish: async () => {} } as any, - resolveAttachments: () => [], - }, - approvalConversationGenerator: mockGenerator as any, - }), + (args) => + handleSendMessage(args, { + sendMessageDeps: { + getOrCreateConversation: async () => session, + assistantEventHub: { publish: async () => {} } as any, + resolveAttachments: () => [], + }, + approvalConversationGenerator: mockGenerator as any, + }), req, undefined, 202, diff --git a/assistant/src/__tests__/conversation-routes-slash-commands.test.ts b/assistant/src/__tests__/conversation-routes-slash-commands.test.ts index 48e50edb69e..942a92a35ea 100644 --- a/assistant/src/__tests__/conversation-routes-slash-commands.test.ts +++ b/assistant/src/__tests__/conversation-routes-slash-commands.test.ts @@ -260,6 +260,7 @@ function makeConversation() { hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, usageStats: { inputTokens: 1000, diff --git a/assistant/src/__tests__/http-user-message-parity.test.ts b/assistant/src/__tests__/http-user-message-parity.test.ts index f5d5e364938..4554646fd48 100644 --- a/assistant/src/__tests__/http-user-message-parity.test.ts +++ b/assistant/src/__tests__/http-user-message-parity.test.ts @@ -206,6 +206,7 @@ function makeConversation(overrides: Record = {}) { updateClient: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, emitConfirmationStateChanged: () => {}, emitActivityState: () => {}, diff --git a/assistant/src/__tests__/process-message-background-slack.test.ts b/assistant/src/__tests__/process-message-background-slack.test.ts index 35472b564fe..5daf967fe9e 100644 --- a/assistant/src/__tests__/process-message-background-slack.test.ts +++ b/assistant/src/__tests__/process-message-background-slack.test.ts @@ -101,6 +101,7 @@ interface TestConversation { ensureActorScopedHistory: () => Promise; setChannelCapabilities: () => void; setHostCuProxy: () => void; + setHostAppControlProxy: () => void; addPreactivatedSkillId: () => void; setCommandIntent: () => void; setTurnChannelContext: (ctx: TurnChannelContext) => void; @@ -188,6 +189,7 @@ function makeConversation(): TestConversation { ensureActorScopedHistory: async () => {}, setChannelCapabilities: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, setCommandIntent: () => {}, setTurnChannelContext: (ctx: TurnChannelContext) => { diff --git a/assistant/src/__tests__/secret-ingress-http.test.ts b/assistant/src/__tests__/secret-ingress-http.test.ts index e9ce064975c..0887ebadc99 100644 --- a/assistant/src/__tests__/secret-ingress-http.test.ts +++ b/assistant/src/__tests__/secret-ingress-http.test.ts @@ -213,6 +213,7 @@ function makeSendMessageDeps() { hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; diff --git a/assistant/src/__tests__/send-endpoint-busy.test.ts b/assistant/src/__tests__/send-endpoint-busy.test.ts index 5998b9a591c..c67d086c50d 100644 --- a/assistant/src/__tests__/send-endpoint-busy.test.ts +++ b/assistant/src/__tests__/send-endpoint-busy.test.ts @@ -160,6 +160,7 @@ function makeCompletingConversation(): Conversation { updateClient: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => false, hasPendingConfirmation: () => false, @@ -216,6 +217,7 @@ function makeHangingConversation(): Conversation { updateClient: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => false, hasPendingConfirmation: () => false, @@ -300,6 +302,7 @@ function makePendingApprovalConversation( updateClient: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, + setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => pending.size > 0, hasPendingConfirmation: (candidateRequestId: string) => diff --git a/assistant/src/daemon/process-message.ts b/assistant/src/daemon/process-message.ts index 74853022b1b..9f5050290ca 100644 --- a/assistant/src/daemon/process-message.ts +++ b/assistant/src/daemon/process-message.ts @@ -45,6 +45,7 @@ import { mergeConversationOptions, } from "./conversation-store.js"; import type { ConversationCreateOptions } from "./handlers/shared.js"; +import { HostAppControlProxy } from "./host-app-control-proxy.js"; import { HostCuProxy } from "./host-cu-proxy.js"; const log = getLogger("process-message"); @@ -160,6 +161,20 @@ async function prepareConversationForMessage( } else if (!conversation.isProcessing()) { conversation.setHostCuProxy(undefined); } + // App-control mirrors CU's per-conversation lifecycle. The proxy attaches + // unconditionally when the client supports the capability — feature-flag + // gating is enforced by the skill-projection layer via SKILL.md + // frontmatter, so an attached proxy is harmless when the flag is off. + if (supportsHostProxy(resolvedInterface, "host_app_control")) { + if (!conversation.isProcessing() || !conversation.hostAppControlProxy) { + conversation.setHostAppControlProxy( + new HostAppControlProxy(conversationId), + ); + } + conversation.addPreactivatedSkillId("app-control"); + } else if (!conversation.isProcessing()) { + conversation.setHostAppControlProxy(undefined); + } conversation.setCommandIntent(options?.commandIntent ?? null); conversation.setTurnChannelContext({ userMessageChannel: resolvedChannel, diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index e28b38b332b..eecedc68bb6 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -44,6 +44,7 @@ import { isWakeUpGreeting, } from "../../daemon/first-greeting.js"; import { renderHistoryContent } from "../../daemon/handlers/shared.js"; +import { HostAppControlProxy } from "../../daemon/host-app-control-proxy.js"; import { HostCuProxy } from "../../daemon/host-cu-proxy.js"; import type { ServerMessage } from "../../daemon/message-protocol.js"; import type { @@ -1404,6 +1405,24 @@ export async function handleSendMessage( } else if (!conversation.isProcessing()) { conversation.setHostCuProxy(undefined); } + // App-control mirrors CU's per-conversation lifecycle: the proxy owns a + // singleton lock plus per-session loop tracking. Instantiation is + // unconditional when the client supports the capability — feature-flag + // gating lives in the skill-projection layer (which reads the + // `feature-flag: app-control` declaration in SKILL.md frontmatter), so + // an attached proxy is harmless when the flag resolves to off. + if (supportsHostProxy(sourceInterface, "host_app_control")) { + if (!conversation.isProcessing() || !conversation.hostAppControlProxy) { + conversation.setHostAppControlProxy( + new HostAppControlProxy(mapping.conversationId), + ); + } + if (!conversation.isProcessing()) { + conversation.addPreactivatedSkillId("app-control"); + } + } else if (!conversation.isProcessing()) { + conversation.setHostAppControlProxy(undefined); + } // Wire sendToClient to the SSE hub so all subsystems can reach the HTTP client. // hasNoClient must remain `!isInteractive` so downstream tool gating // (`isToolActiveForContext` for HOST_TOOL_NAMES, `createToolExecutor`'s