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
14 changes: 14 additions & 0 deletions apps/api/src/tools/sandbox/thread-start.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, spyOn } from "bun:test";
import type { StudioContext } from "../../core/studio-context";
import * as choices from "../../git-providers/repo-choices";
import type { RepoChoice } from "../../git-providers/repo-choices";
import * as chats from "../../harnesses/sandbox-only-chats";
import { COLLECTION_THREADS_CREATE } from "../thread/create";
Expand Down Expand Up @@ -93,6 +94,19 @@ describe("THREAD_SANDBOX_START without a threadId", () => {
expect(createSpy).not.toHaveBeenCalled();
});

it("refuses an unlinked repository before creating a chat", async () => {
const createSpy = create();
const listSpy = spyOn(choices, "listOrgRepoChoices").mockResolvedValue([]);
spies.push(createSpy, listSpy);
await expect(
THREAD_SANDBOX_START.handler(
{ wait: false, repository: "acme/missing" },
ctx,
),
).rejects.toThrow('No repository "acme/missing"');
expect(createSpy).not.toHaveBeenCalled();
});

it("takes a threadId or a virtualMcpId, not both", () => {
expect(
THREAD_SANDBOX_START.inputSchema.safeParse({
Expand Down
45 changes: 29 additions & 16 deletions apps/api/src/tools/sandbox/thread-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ export function findRepoChoice(choices: RepoChoice[], ref: string): RepoChoice {
);
}

async function resolveRepository(
ctx: StudioContext,
agentId: string,
organizationId: string,
ref: string,
) {
if (!isDecopilot(agentId)) {
throw new Error(
"Only chats on the default agent can load a repository; this agent uses the one it is configured with.",
);
}
const userId = getUserId(ctx);
if (!userId) throw new Error("User ID required to load a repository");
const choice = findRepoChoice(
await listOrgRepoChoices(ctx, organizationId),
ref,
);
return { choice, userId };
}

async function createSandboxChat(ctx: StudioContext, agentId: string) {
const { item } = await COLLECTION_THREADS_CREATE.execute(
{ data: { virtual_mcp_id: agentId, runtime: "sandbox" } },
Expand Down Expand Up @@ -121,27 +141,20 @@ export const THREAD_SANDBOX_START = defineTool({
existing?.virtual_mcp_id ??
input.virtualMcpId ??
getDecopilotId(organization.id);
// Same rule as `load_repo`: an agent with its own repository is scoped to
// it. Checked before a new chat is created for it.
if (input.repository && !isDecopilot(agentId)) {
throw new Error(
"Only chats on the default agent can load a repository; this agent uses the one it is configured with.",
);
}
// Every refusal comes before a new chat is created, so none leaves one
// behind. Same rule as `load_repo`: an agent with its own repository is
// scoped to it.
const repo = input.repository
? await resolveRepository(ctx, agentId, organization.id, input.repository)
: null;

let target = existing ?? (await createSandboxChat(ctx, agentId));
if (input.repository) {
const userId = getUserId(ctx);
if (!userId) throw new Error("User ID required to load a repository");
const repo = findRepoChoice(
await listOrgRepoChoices(ctx, organization.id),
input.repository,
);
if (repo) {
const { branch } = await bindThreadRepository(
ctx,
target.id,
repo,
userId,
repo.choice,
repo.userId,
);
target = { ...target, branch };
}
Expand Down
Loading