Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions lib/chat/__tests__/buildRunAgentInput.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
66 changes: 66 additions & 0 deletions lib/chat/buildRunAgentInput.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
},
};
}
46 changes: 14 additions & 32 deletions lib/chat/handleChatWorkflowStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -92,9 +91,6 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
void persistLatestUserMessage(validated.chatId, validated.messages as never);

const modelId = chat.model_id ?? DEFAULT_MODEL_ID;
const recoupOrgId = session.clone_url
? (extractOrgId(session.clone_url) ?? undefined)
: undefined;

// Connect the sandbox up-front so we can (a) read the real working
// directory and (b) discover project-level skills. The connected
Expand All @@ -119,40 +115,26 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
);
}

// Derive repo identifiers from `session.clone_url` so we have a
// single source of truth. The `sessions.repo_owner/repo_name`
// columns exist in the schema but were never populated; treating
// `clone_url` as canonical avoids a denormalization where the
// columns could drift from the URL.
const repoIds = parseGitHubRepoIdentifiers(session.clone_url);

// Build the durable workflow input via the shared builder so the
// interactive and headless (/api/chat/generate) callers stay in lockstep
// (chat#1813). Repo ids + recoup org id are derived from `clone_url` inside
// the builder — the single source of truth. The short-lived Privy JWT from
// the chat UI is forwarded as `recoupAccessToken`; x-api-key callers don't
// send it, so the long-lived service key never enters model-issued bash.
const run = await start(runAgentWorkflow, [
{
buildRunAgentInput({
messages: validated.messages,
chatId: validated.chatId,
sessionId: validated.sessionId,
accountId: validated.accountId,
modelId,
sessionTitle: session.title ?? undefined,
repoOwner: repoIds?.owner,
repoName: repoIds?.repo,
agentContext: {
sandbox: {
state: session.sandbox_state as VercelState,
workingDirectory,
},
recoupOrgId,
skills,
// Forward the short-lived Privy JWT from the chat UI when
// present. The `recoup-api` skill's curl examples authenticate
// against recoup-api with this as a Bearer header (via the
// `$RECOUP_ACCESS_TOKEN` env var injected by buildRecoupExecEnv).
// x-api-key auth callers don't send this field — the long-lived
// recoup_sk_ key is deliberately NOT forwarded (exfiltration
// risk from model-issued bash).
...(validated.recoupAccessToken ? { recoupAccessToken: validated.recoupAccessToken } : {}),
},
},
cloneUrl: session.clone_url,
sandboxState: session.sandbox_state as VercelState,
workingDirectory,
skills,
recoupAccessToken: validated.recoupAccessToken,
}),
]);

// Promote placeholder → real run id via CAS. If something asynchronously
Expand Down
Loading