diff --git a/lib/chat/__tests__/buildRunAgentInput.test.ts b/lib/chat/__tests__/buildRunAgentInput.test.ts new file mode 100644 index 00000000..45af4f7c --- /dev/null +++ b/lib/chat/__tests__/buildRunAgentInput.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { extractOrgId } from "@/lib/recoupable/extractOrgId"; + +vi.mock("@/lib/github/parseGitHubRepoIdentifiers", () => ({ + parseGitHubRepoIdentifiers: vi.fn(() => ({ owner: "o", repo: "r" })), +})); +vi.mock("@/lib/recoupable/extractOrgId", () => ({ + extractOrgId: vi.fn(() => "org-1"), +})); + +const base = { + messages: [{ id: "m1", role: "user", parts: [] }] as never, + chatId: "chat-1", + sessionId: "sess-1", + accountId: "acc-1", + modelId: "anthropic/claude-haiku-4.5", + sessionTitle: "Weekly report", + cloneUrl: "https://github.com/o/r.git", + sandboxState: { type: "vercel", sandboxName: "sb-1" } as never, + workingDirectory: "/work", + skills: [] as never, +}; + +describe("buildRunAgentInput", () => { + beforeEach(() => vi.clearAllMocks()); + + it("assembles the workflow input, deriving repo ids + org id from cloneUrl", () => { + const input = buildRunAgentInput(base); + expect(input.chatId).toBe("chat-1"); + expect(input.sessionId).toBe("sess-1"); + expect(input.accountId).toBe("acc-1"); + expect(input.modelId).toBe("anthropic/claude-haiku-4.5"); + expect(input.sessionTitle).toBe("Weekly report"); + expect(input.repoOwner).toBe("o"); + expect(input.repoName).toBe("r"); + expect(input.agentContext.recoupOrgId).toBe("org-1"); + expect(input.agentContext.sandbox).toEqual({ + state: { type: "vercel", sandboxName: "sb-1" }, + workingDirectory: "/work", + }); + }); + + it("includes recoupAccessToken when provided", () => { + const input = buildRunAgentInput({ ...base, recoupAccessToken: "tok-123" }); + expect(input.agentContext.recoupAccessToken).toBe("tok-123"); + }); + + it("omits recoupAccessToken entirely when absent", () => { + const input = buildRunAgentInput(base); + expect("recoupAccessToken" in input.agentContext).toBe(false); + }); + + it("leaves recoupOrgId undefined when cloneUrl is null (no org derivation)", () => { + const input = buildRunAgentInput({ ...base, cloneUrl: null }); + expect(input.agentContext.recoupOrgId).toBeUndefined(); + expect(extractOrgId).not.toHaveBeenCalled(); + expect(parseGitHubRepoIdentifiers).toHaveBeenCalledWith(null); + }); +}); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts new file mode 100644 index 00000000..95b0df42 --- /dev/null +++ b/lib/chat/buildRunAgentInput.ts @@ -0,0 +1,66 @@ +import type { UIMessage } from "ai"; +import type { RunAgentWorkflowInput } from "@/app/lib/workflows/runAgentWorkflow"; +import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext"; +import type { VercelState } from "@/lib/sandbox/vercel/state"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { extractOrgId } from "@/lib/recoupable/extractOrgId"; + +export type BuildRunAgentInputParams = { + messages: UIMessage[]; + chatId: string; + sessionId: string; + accountId: string; + modelId: string; + sessionTitle?: string; + /** `session.clone_url` — the single source for repo ids + recoup org id. */ + cloneUrl: string | null; + sandboxState: VercelState; + workingDirectory: string; + skills: DurableAgentContext["skills"]; + /** + * Short-lived bearer for in-sandbox recoup-api calls: the user's Privy JWT + * (interactive `/api/chat/workflow`) or an ephemeral account key (headless + * `/api/chat/generate`). Omitted when absent so the service key never leaks. + */ + recoupAccessToken?: string; +}; + +/** + * Build the durable `RunAgentWorkflowInput` shared by the interactive and + * headless callers, so both construct workflow input identically + * (recoupable/chat#1813). Repo identifiers and the recoup org id are both + * derived from `cloneUrl` here — one source of truth, no caller duplication. + */ +export function buildRunAgentInput({ + messages, + chatId, + sessionId, + accountId, + modelId, + sessionTitle, + cloneUrl, + sandboxState, + workingDirectory, + skills, + recoupAccessToken, +}: BuildRunAgentInputParams): RunAgentWorkflowInput { + const repoIds = parseGitHubRepoIdentifiers(cloneUrl); + const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined; + + return { + messages, + chatId, + sessionId, + accountId, + modelId, + sessionTitle, + repoOwner: repoIds?.owner, + repoName: repoIds?.repo, + agentContext: { + sandbox: { state: sandboxState, workingDirectory }, + recoupOrgId, + skills, + ...(recoupAccessToken ? { recoupAccessToken } : {}), + }, + }; +} diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 8d61ce96..fab562e1 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -14,8 +14,7 @@ import { persistLatestUserMessage } from "@/lib/chat/persistLatestUserMessage"; import { errorResponse } from "@/lib/networking/errorResponse"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; -import { extractOrgId } from "@/lib/recoupable/extractOrgId"; -import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; import type { VercelState } from "@/lib/sandbox/vercel/state"; @@ -92,9 +91,6 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise