/** * Regression tests for app surface refresh and eventing side effects in * createToolExecutor (conversation-tool-setup.ts). * * Tests verify that app_refresh, app_update, app_create, app_delete, and * app_generate_icon hooks fire correctly — including recovering an omitted * app_id from the executor's typed `resolvedAppId` side channel — and that * non-hooked tools (app_file_edit, app_file_write) do not trigger side effects. * * File-change detection for file_write/file_edit is handled by * AppSourceWatcher (see app-source-watcher.test.ts). */ import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { Conversation } from "../daemon/conversation.js"; import { SYNC_TAGS } from "../daemon/message-types/sync.js"; import type { PermissionPrompter } from "../permissions/prompter.js"; import type { SecretPrompter } from "../permissions/secret-prompter.js"; import type { ToolExecutor } from "../tools/executor.js"; import type { ToolExecutionResult } from "../tools/types.js"; // --------------------------------------------------------------------------- // Spies for side-effect verification // --------------------------------------------------------------------------- const refreshSpy = mock(() => {}); const updatePublishedSpy = mock(() => Promise.resolve()); const broadcastSpy = mock(() => {}); mock.module("../runtime/assistant-event-hub.js", () => ({ broadcastMessage: broadcastSpy, })); // Mock session-surfaces so refreshSurfacesForApp is captured mock.module("../daemon/conversation-surfaces.js", () => ({ refreshSurfacesForApp: refreshSpy, surfaceProxyResolver: mock(() => Promise.resolve({ content: "", isError: false }), ), })); // Mock published-app-updater to prevent real deployment calls mock.module("../services/published-app-updater.js", () => ({ updatePublishedAppDeployment: updatePublishedSpy, })); // Mock browser-screencast registration (no-op) mock.module("../tools/browser/browser-screencast.js", () => ({ registerConversationSender: mock(() => {}), })); // Stub app-store functions used by other modules (e.g. app-source-watcher, // conversation-surfaces) so tool-side-effects' hooks can run without touching // the real app store during tests. mock.module("../apps/app-store.js", () => ({ getApp: mock(() => null), getAppDirPath: mock(() => "/tmp/test-apps/dummy"), getAppsDir: mock(() => "/tmp/test-apps"), resolveAppIdByDirName: mock(() => null), resolveAppIdFromPath: mock(() => null), })); // --------------------------------------------------------------------------- // Import createToolExecutor after mocks are in place // --------------------------------------------------------------------------- import { createSurfaceMutex } from "../daemon/conversation-surfaces.js"; import { createToolExecutor } from "../daemon/conversation-tool-setup.js"; import { asConversation } from "./helpers/mock-conversation.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function broadcastPayloads(): unknown[] { return (broadcastSpy.mock.calls as unknown[][]).map(([payload]) => payload); } function expectAppChangeBroadcast(appId: string): void { expect(broadcastPayloads()).toEqual( expect.arrayContaining([ { type: "app_files_changed", appId }, { type: "sync_changed", tags: [SYNC_TAGS.appsList] }, ]) as never, ); } /** Build a minimal Conversation stub. */ function makeCtx(overrides: Partial = {}): Conversation { return asConversation({ conversationId: "conv-test", currentRequestId: "req-1", workingDir: "/tmp/test", getTurnActorPrincipalId: () => undefined, abortController: null, emit: mock(() => {}), pendingSurfaceActions: new Map(), lastSurfaceAction: new Map(), surfaceState: new Map(), surfaceUndoStacks: new Map(), accumulatedSurfaceState: new Map(), surfaceActionRequestIds: new Set(), currentTurnSurfaces: [], isProcessing: () => false, enqueueMessage: () => ({ queued: false, requestId: "r" }), getQueueDepth: () => 0, processMessage: async () => "", withSurface: createSurfaceMutex(), ...overrides, }); } /** Fake ToolExecutor whose execute() returns a controlled result. */ function makeFakeExecutor( result: ToolExecutionResult = { content: "{}", isError: false }, ) { return { execute: mock(async () => result), }; } /** No-op prompter stubs. */ const noopPrompter = { prompt: mock(async () => ({ decision: "allow" as const })), } as unknown as PermissionPrompter; const noopSecretPrompter = { prompt: mock(async () => ({ cancelled: true })), } as unknown as SecretPrompter; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("session-tool-setup app refresh side effects", () => { beforeEach(() => { refreshSpy.mockClear(); broadcastSpy.mockClear(); updatePublishedSpy.mockClear(); }); // ── app_refresh ───────────────────────────────────────────────────── describe("app_refresh", () => { test("triggers refreshSurfacesForApp when result is not an error", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: '{"id":"app-1"}', isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_refresh", { app_id: "app-1" }); expect(refreshSpy).toHaveBeenCalledTimes(1); expect((refreshSpy.mock.calls as unknown[][])[0][0]).toBe(ctx); expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe("app-1"); }); test("broadcasts app_files_changed with correct appId", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_refresh", { app_id: "app-42" }); expect(broadcastSpy).toHaveBeenCalledTimes(2); expectAppChangeBroadcast("app-42"); }); test("calls updatePublishedAppDeployment", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_refresh", { app_id: "app-publish" }); // updatePublishedAppDeployment is called with void (fire-and-forget), // so just verify it was invoked. expect(updatePublishedSpy).toHaveBeenCalledTimes(1); expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe( "app-publish", ); }); test("skips side effects when result is an error", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "Error: not found", isError: true, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_refresh", { app_id: "app-err" }); expect(refreshSpy).not.toHaveBeenCalled(); expect(broadcastSpy).not.toHaveBeenCalled(); expect(updatePublishedSpy).not.toHaveBeenCalled(); }); test("recovers appId from resolvedAppId when input omits app_id", async () => { // app_id is optional: the skill script resolves the active app and the // executor reports the id it used via the typed `resolvedAppId` channel. // The hook must act on that id — without parsing `content` — so an // omitted-id refresh still refreshes surfaces and re-deploys. const ctx = makeCtx(); const executor = makeFakeExecutor({ content: '{"refreshed":true}', resolvedAppId: "app-resolved", isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_refresh", {}); expect(refreshSpy).toHaveBeenCalledTimes(1); expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe("app-resolved"); expectAppChangeBroadcast("app-resolved"); expect(updatePublishedSpy).toHaveBeenCalledTimes(1); expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe( "app-resolved", ); }); test("skips side effects when app_id is absent from input and result", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_refresh", {}); expect(refreshSpy).not.toHaveBeenCalled(); expect(broadcastSpy).not.toHaveBeenCalled(); }); }); // ── app_update ────────────────────────────────────────────────────── describe("app_update", () => { test("triggers refreshSurfacesForApp and broadcast on success", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: '{"updated":true,"appId":"app-7"}', isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_update", { app_id: "app-7" }); expect(refreshSpy).toHaveBeenCalledTimes(1); expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe("app-7"); expectAppChangeBroadcast("app-7"); expect(updatePublishedSpy).toHaveBeenCalledTimes(1); expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe( "app-7", ); }); test("skips side effects when result is an error", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "Error: not found", isError: true, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_update", { app_id: "app-err" }); expect(refreshSpy).not.toHaveBeenCalled(); expect(broadcastSpy).not.toHaveBeenCalled(); expect(updatePublishedSpy).not.toHaveBeenCalled(); }); test("recovers appId from resolvedAppId when input omits app_id", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: '{"updated":true}', resolvedAppId: "app-resolved-u", isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_update", {}); expect(refreshSpy).toHaveBeenCalledTimes(1); expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe( "app-resolved-u", ); expectAppChangeBroadcast("app-resolved-u"); expect(updatePublishedSpy).toHaveBeenCalledTimes(1); expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe( "app-resolved-u", ); }); test("skips side effects when app_id is absent from input and result", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_update", {}); expect(refreshSpy).not.toHaveBeenCalled(); expect(broadcastSpy).not.toHaveBeenCalled(); }); }); // ── app_create side effects ───────────────────────────────────────── describe("app_create side effects", () => { test("broadcasts app_files_changed immediately after app_create", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: JSON.stringify({ id: "new-app-1", name: "My App" }), resolvedAppId: "new-app-1", isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_create", { name: "My App", html: "

hi

" }); expect(broadcastSpy.mock.calls.length).toBeGreaterThanOrEqual(1); expect((broadcastSpy.mock.calls as unknown[][])[0][0]).toEqual({ type: "app_files_changed", appId: "new-app-1", }); expectAppChangeBroadcast("new-app-1"); }); test("canonicalizes create_app skill_execute alias before hooks run", async () => { const ctx = makeCtx({ allowedToolNames: new Set(["app_create"]) }); const executor = makeFakeExecutor({ content: JSON.stringify({ id: "alias-app-1", name: "Alias App" }), resolvedAppId: "alias-app-1", isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("skill_execute", { tool: "create_app", input: { name: "Alias App" }, activity: "Building app", }); const calls = executor.execute.mock.calls as unknown[][]; expect(calls[0][0]).toBe("app_create"); expect(calls[0][1]).toEqual({ name: "Alias App" }); expect(broadcastSpy.mock.calls.length).toBeGreaterThanOrEqual(1); expect((broadcastSpy.mock.calls as unknown[][])[0][0]).toEqual({ type: "app_files_changed", appId: "alias-app-1", }); expectAppChangeBroadcast("alias-app-1"); }); test("canonicalizes legacy computer_use_press_key skill_execute alias before dispatch", async () => { const ctx = makeCtx({ allowedToolNames: new Set(["computer_use_key"]) }); const executor = makeFakeExecutor({ content: "ok", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("skill_execute", { tool: "computer_use_press_key", input: { key: "Space", modifiers: ["Command"], reasoning: "Open Spotlight", }, activity: "Opening Spotlight", }); const calls = executor.execute.mock.calls as unknown[][]; expect(calls[0][0]).toBe("computer_use_key"); expect(calls[0][1]).toEqual({ key: "cmd+space", reasoning: "Open Spotlight", }); }); test("preserves exact active create_app skill tool when app_create is also active", async () => { const ctx = makeCtx({ allowedToolNames: new Set(["create_app", "app_create"]), }); const executor = makeFakeExecutor({ content: JSON.stringify({ id: "custom-app-1", name: "Custom App" }), isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("skill_execute", { tool: "create_app", input: { name: "Custom App" }, activity: "Running custom app tool", }); const calls = executor.execute.mock.calls as unknown[][]; expect(calls[0][0]).toBe("create_app"); expect(broadcastSpy).not.toHaveBeenCalled(); }); test("skips side effects when app_create result is an error", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "Error", isError: true }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_create", { name: "Bad", html: "" }); expect(broadcastSpy).not.toHaveBeenCalled(); }); test("fires notify side effects regardless of compile outcome reported in payload", async () => { // The hook observes the tool result but does not branch on compile // status fields inside it. Whether the executor reports a successful // compile or returns compile_errors, the hook still refreshes // surfaces and broadcasts — compile retries are the LLM's // responsibility via a follow-up tool call, not the hook's. const ctx = makeCtx(); const executor = makeFakeExecutor({ content: JSON.stringify({ id: "new-app-err", name: "Busted", compiled: false, compile_errors: [{ text: "syntax error" }], }), resolvedAppId: "new-app-err", isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_create", { name: "Busted", html: "" }); expect(refreshSpy).toHaveBeenCalledTimes(1); expect(broadcastSpy.mock.calls.length).toBeGreaterThanOrEqual(1); expect((broadcastSpy.mock.calls as unknown[][])[0][0]).toEqual({ type: "app_files_changed", appId: "new-app-err", }); expectAppChangeBroadcast("new-app-err"); expect(updatePublishedSpy).toHaveBeenCalledTimes(1); }); }); // ── app_delete side effects ──────────────────────────────────────── describe("app_delete side effects", () => { test("broadcasts app_files_changed after app_delete", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_delete", { app_id: "del-app-1" }); expect(broadcastSpy).toHaveBeenCalledTimes(2); expectAppChangeBroadcast("del-app-1"); }); test("skips side effects when app_delete result is an error", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "Error", isError: true }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_delete", { app_id: "del-err" }); expect(broadcastSpy).not.toHaveBeenCalled(); }); }); // ── app_generate_icon side effects ────────────────────────────────── describe("app_generate_icon side effects", () => { test("broadcasts app_files_changed with explicit app_id", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: '{"generated":true,"appId":"icon-app"}', isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_generate_icon", { app_id: "icon-app" }); expect(broadcastSpy).toHaveBeenCalledTimes(2); expectAppChangeBroadcast("icon-app"); // Icon regen only rebroadcasts the app list — it neither refreshes open // surfaces nor re-deploys the published app. expect(refreshSpy).not.toHaveBeenCalled(); expect(updatePublishedSpy).not.toHaveBeenCalled(); }); test("recovers appId from resolvedAppId when input omits app_id", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: '{"generated":true}', resolvedAppId: "icon-resolved", isError: false, }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_generate_icon", {}); expect(broadcastSpy).toHaveBeenCalledTimes(2); expectAppChangeBroadcast("icon-resolved"); }); test("skips broadcast when app_id is absent from input and result", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); await toolFn("app_generate_icon", {}); expect(broadcastSpy).not.toHaveBeenCalled(); }); }); // ── Name-based hook targeting (skill-origin tools) ────────────────── describe("name-based hooks fire for skill-origin tools", () => { test("hooks fire purely on tool name, regardless of tool origin", async () => { // The key invariant: createToolExecutor uses `name === 'app_refresh'` // string comparison, not tool metadata or origin. This means skill- // projected tools with the same name trigger the same afterExecute // hooks as core tools. const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); // Simulate calling app_refresh by name (as the agent loop does) for (const toolName of ["app_refresh"]) { refreshSpy.mockClear(); broadcastSpy.mockClear(); broadcastSpy.mockClear(); updatePublishedSpy.mockClear(); await toolFn(toolName, { app_id: "skill-app", }); expect(refreshSpy).toHaveBeenCalledTimes(1); expect(broadcastSpy).toHaveBeenCalledTimes(2); expectAppChangeBroadcast("skill-app"); expect(updatePublishedSpy).toHaveBeenCalledTimes(1); } }); }); // ── Non-app tools do not trigger hooks ────────────────────────────── describe("non-app tools", () => { test("other tool names do not trigger app refresh side effects", async () => { const ctx = makeCtx(); const executor = makeFakeExecutor({ content: "{}", isError: false }); const toolFn = createToolExecutor( executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter, ctx, ); for (const toolName of [ "read_file", "write_file", "shell", "app_list", "app_file_edit", "app_file_write", ]) { refreshSpy.mockClear(); broadcastSpy.mockClear(); broadcastSpy.mockClear(); updatePublishedSpy.mockClear(); await toolFn(toolName, { app_id: "app-1" }); expect(refreshSpy).not.toHaveBeenCalled(); expect(broadcastSpy).not.toHaveBeenCalled(); expect(updatePublishedSpy).not.toHaveBeenCalled(); } }); }); });