diff --git a/lib/chat/runs/__tests__/provisionRunSession.test.ts b/lib/chat/runs/__tests__/provisionRunSession.test.ts new file mode 100644 index 00000000..3dd3a13b --- /dev/null +++ b/lib/chat/runs/__tests__/provisionRunSession.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { provisionRunSession } from "../provisionRunSession"; +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { discoverSkills } from "@/lib/skills/discoverSkills"; +import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; + +vi.mock("@/lib/sessions/createSessionWithInitialChat", () => ({ + createSessionWithInitialChat: vi.fn(), +})); +vi.mock("@/lib/sandbox/factory", () => ({ connectSandbox: vi.fn() })); +vi.mock("@/lib/sandbox/getSessionSandboxName", () => ({ + getSessionSandboxName: vi.fn(() => "sandbox-name"), +})); +vi.mock("@/lib/sandbox/resolveGitUser", () => ({ + resolveGitUser: vi.fn(async () => ({ name: "x", email: "y" })), +})); +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "gh-token"), +})); +vi.mock("@/lib/sandbox/markSessionSandboxActive", () => ({ + markSessionSandboxActive: vi.fn(), +})); +vi.mock("@/lib/skills/discoverSkills", () => ({ discoverSkills: vi.fn(async () => []) })); +vi.mock("@/lib/skills/getSandboxSkillDirectories", () => ({ + getSandboxSkillDirectories: vi.fn(async () => ["/skills"]), +})); +vi.mock("@/lib/sandbox/installSessionGlobalSkills", () => ({ + installSessionGlobalSkills: vi.fn(async () => undefined), +})); + +const session = { + id: "session-1", + clone_url: "https://github.com/org/repo", + sandbox_state: { type: "vercel" }, +}; +const updated = { ...session }; +const chat = { id: "chat-1" }; +const sandbox = { + getState: () => ({ type: "vercel" }), + workingDirectory: "/work", +}; + +describe("provisionRunSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createSessionWithInitialChat).mockResolvedValue({ + ok: true, + session, + chat, + } as never); + vi.mocked(connectSandbox).mockResolvedValue(sandbox as never); + vi.mocked(markSessionSandboxActive).mockResolvedValue(updated as never); + }); + + it("installs global skills into the sandbox before discovering them", async () => { + await provisionRunSession({ accountId: "account-1", title: "t" }); + + // Headless runs must PROVISION skills, not just discover them (chat#1822). + expect(installSessionGlobalSkills).toHaveBeenCalledWith({ + sessionRow: updated, + sandbox, + }); + + const installOrder = vi.mocked(installSessionGlobalSkills).mock.invocationCallOrder[0]; + const discoverOrder = vi.mocked(discoverSkills).mock.invocationCallOrder[0]; + expect(installOrder).toBeLessThan(discoverOrder); + }); + + it("still completes the run when skill install fails (best-effort)", async () => { + vi.mocked(installSessionGlobalSkills).mockRejectedValueOnce(new Error("install boom")); + + const result = await provisionRunSession({ accountId: "account-1", title: "t" }); + + expect(result.session).toEqual(updated); + expect(discoverSkills).toHaveBeenCalled(); + }); +}); diff --git a/lib/chat/runs/provisionRunSession.ts b/lib/chat/runs/provisionRunSession.ts index f1ed3bd9..df1b054f 100644 --- a/lib/chat/runs/provisionRunSession.ts +++ b/lib/chat/runs/provisionRunSession.ts @@ -5,6 +5,7 @@ import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; import { discoverSkills } from "@/lib/skills/discoverSkills"; import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; @@ -76,6 +77,18 @@ export async function provisionRunSession({ const updated = await markSessionSandboxActive(session, sandbox.getState() as Json); if (!updated) throw new Error("Failed to activate session sandbox"); + // Install global skills BEFORE discovery — the headless run path must + // provision skills, not just discover them. Without this the sandbox skills + // dir is empty, `discoverSkills` returns [], the agent gets no `skill` tool, + // and it falls back to guessing API endpoints + fabricating (chat#1822). + // Best-effort: a failed install must not block the run (mirrors + // `createSandboxHandler`). + try { + await installSessionGlobalSkills({ sessionRow: updated, sandbox }); + } catch (error) { + console.error("[provisionRunSession] installSessionGlobalSkills failed:", error); + } + // Best-effort skill + working-directory discovery from the live handle — // a failure falls back to defaults so the run can still start (tools surface // the underlying issue when they reconnect). Mirrors handleChatWorkflowStream.