Files
PaperClipAI/server/src/__tests__/chat-project-tools.test.ts
T
DottaandClaude Opus 5.5 7fd98682ca fix(workspaces): give every subtask its own execution workspace (#19860)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents split work into child tasks, and each task runs in an
execution workspace (a checkout, branch, or sandbox folder)
> - Since early 2026, a new child task used its parent's realized
workspace by default: the same checkout, branch and sandbox
> - A parent and its child often run at the same time. Two writers in
one folder lose each other's files, the waking parent is cancelled with
`workspace_busy`, and sandbox lease ownership crosses from the parent
into the child, so retained-file recovery refuses the workspace as
ambiguous
> - The product rule is simple: a subtask gets its own workspace,
independent of the parent's saved files. Paperclip never needs to carry
a parent's unmerged changes into a child
> - This pull request makes that the only behaviour on every creation
path, and moves existing shared children to their own workspace on their
next run, with no user or admin action
> - The benefit is that parents and children never block or overwrite
each other, and recovery ownership stays with one task

## Linked Issues or Issue Description

**What happened?**
A child task inherited its parent's realized execution workspace by
default (`createChild` defaulted to `executionWorkspaceInheritanceMode:
"linkage"`, and `issueService.create` fell back from
`inheritExecutionWorkspaceFromIssueId` to `parentId`). When the parent
woke while the child wrote to the shared workspace, the parent run was
cancelled with `workspace_busy`. In sandbox environments, the lease
ownership chain crossed from the parent into the child, and
retained-file recovery refused the workspace with
`allocation_ownership_ambiguous`.

**Expected behavior**
Every new child task gets its own execution workspace, realized from the
project base exactly as a top-level task in that project. It never uses
the parent's checkout, branch, saved snapshot or unmerged changes. A
parent and its descendant never share a workspace by any route.

**Steps to reproduce**
1. Run a parent task in a project with isolated (git worktree) or
sandbox workspaces, so the parent has a realized execution workspace.
2. From the parent run, create a child task with only `parentId` (or
through the runner `create_task` tool, chat suggestions, decision
effects, plugins, or the New sub-task dialog).
3. See that the child's `executionWorkspaceId` is the parent's, with
`executionWorkspacePreference: "reuse_existing"`.
4. Wake the parent while the child runs in a sandbox environment. The
parent run is cancelled with `workspace_busy`.

**Additional context**
Refs #9233 (the existing `strategy_only` opt-out).
The opt-out `executionWorkspaceInheritanceMode: "strategy_only"` already
existed for accepted-plan decomposition. This change makes that
behaviour (tightened to drop a pinned existing branch) the only
behaviour.

## What Changed

- **One rule for every creation path
(`server/src/services/issues.ts`).** `issueService.create` still uses
the parent (or `inheritExecutionWorkspaceFromIssueId`) as a source for
the project, project workspace and workspace *strategy*, but it never
copies the source's `executionWorkspaceId`, `reuse_existing` preference,
or pinned existing branch. All server paths that create a task go
through `create()`/`createChild()`: issue routes (`POST /issues`, `POST
/issues/:id/children`, the run-bound default parent and run workspace
source), runner `create_task`, chat suggestion acceptance, native status
decisions, accepted-plan decomposition, decision effects, plugins
(`ctx.issues.create`), external agents, watchdog follow-ups, monitor
recovery issues, case handoffs and email channels.
- **Explicit ancestor workspaces are dropped.** If a create names an
`executionWorkspaceId` (or an `existing` workspace selection) that an
ancestor of the new task created or is bound to, `create()` drops it and
keeps only the strategy. Older clients and skills that copied the
parent's id keep working and get an independent workspace.
- **`createChild` no longer has a "linkage" mode.**
`executionWorkspaceInheritanceMode` is still accepted from internal
callers and is ignored.
- **`inheritExecutionWorkspaceFromIssueId` decision.** I kept the field
for API compatibility, but it now copies only the source issue's project
and workspace strategy. I removed its workspace-sharing behaviour for
every pair, not only parent/child: the source task and its follow-up can
run at the same time (the runner, watchdog and status-decision callers
all create the follow-up while the source run is live), so sharing would
again let two tasks write one folder. Explicit sharing between unrelated
tasks stays available through an explicit `existing` workspace
selection.
- **New `server/src/services/task-workspace-lineage.ts`.**
`buildIndependentTaskWorkspaceSettings` (strategy only: mode,
environment, runtime, network egress, shared-workspace concurrency, base
ref, branch template, commands; no `reuse_existing`, no
`operator_branch`, no `existingBranch`), `loadTaskLineageIssueIds` (the
parent chain plus, for a task that depth placement put beside its
requester, the requester's chain via `privacyParentIssueId`) and
`isAncestorExecutionWorkspace` (the workspace's `sourceIssueId` is in
that lineage, or a lineage issue is bound to it). A child in another
project does not take the parent's strategy.
- **Upgrade-safe rebind at admission.**
`executionWorkspaceService.releaseAncestorWorkspaceBindingAtAdmission`
runs at the same admission point as the pending workspace selection
(`heartbeat.ts`). For a child (or a flattened task beside its requester)
that an older release bound to an ancestor's workspace, it clears the
child's binding (keeps the project workspace and strategy, bumps the
binding revision) so the run realizes the child's own workspace. It does
nothing while another run of the child is running, while the child's
earlier run still owns workspace finalization or recovery, for native
restart/lease-owner recovery, for a process-loss retry, or for a Retry
that keeps the current workspace. The parent and its workspace are never
changed.
- **Projects without a project workspace (per-task repository
acquisition).** A parent with no project workspace worked in its own
task files, so its settings (for example `isolated_workspace`) do not
describe the project base. A child of such a parent, and an older child
released from one, takes the project's defaults exactly like a top-level
task, instead of an isolated mode that would resolve to a git worktree
with no git base ("needs a project / project workspace"). When the
child's new workspace is created at admission,
`executionWorkspaceRepositoryService.prepareForAdmission` repeats the
parent's (or requester's) repository requests as the child's own pending
requests (identity, URL, catalog id and requested ref only; no pinned
commit, branch or files). The normal acquisition path then clones a
fresh copy into the child's task files with the child's own access
checks. This runs only for a workspace created by that admission, so
existing tasks never gain repositories.
- **UI.** The New sub-task dialog no longer pre-selects the parent's
workspace. `buildSubIssueDefaults` drops `executionWorkspaceId` and
`reuse_existing`, and derives the parent's independent mode
(`shared_workspace`, `isolated_workspace` or `agent_default`) from its
preference, saved settings or current workspace. The "This sub-task will
no longer use the parent task workspace" warning is removed.
- **Contracts and docs.** Shared validator and plugin SDK comments
describe the new meaning of `inheritExecutionWorkspaceFromIssueId`.
Updated `doc/execution-semantics.md` (new rule in §6 and a wording fix
in the retained-sandbox paragraph), `doc/SPEC-implementation.md` (child
workspace rule, requester keeps *strategy* inheritance, watchdog
follow-ups), `doc/SPEC.md` (task workspace ownership),
`docs/specs/external-task-protocol.md`, `skills/paperclip/SKILL.md`
(step 9 and the workspace bullet),
`skills/paperclip/references/issue-workspaces.md`, the CEO onboarding
`HEARTBEAT.md`, and the plugin SDK README.

### Previous-release story (`rules/upgrade-safe-run-state.mdc`)

- **Migrated on next admission.** A child that the previous release
bound to its parent's (or any ancestor's) execution workspace keeps that
binding until its next normal admission. Then the server clears the
binding and the run realizes the child's own workspace from the project
base. No schema migration is needed. No user or admin action is needed.
- **In-flight writers finish first.** A running child is not
interrupted. Its current run, its native restart recovery (admitted
native input or lease owner), a process-loss retry, and a Retry that
keeps the current workspace all finish in the shared workspace. While a
child's earlier run is still running or its workspace finalization or
recovery is unsettled, the binding stays and a later admission releases
it.
- **No files are dropped.** The release changes only the child's binding
row. The parent keeps its binding, and the shared workspace row, folder,
branch and saved snapshots are not touched. Anything the child left
there stays in the parent's workspace.
- **No new refusal codes.** Saved run identity (native execution input,
leases, checkpoints) is not changed or refused; the rebind only affects
new admissions.

## Verification

- New `server/src/__tests__/subtask-workspace-independence.test.ts`
(embedded Postgres): `create` with `parentId`, `createChild` with no
mode / `"linkage"` / `"strategy_only"`,
`inheritExecutionWorkspaceFromIssueId` pointing at the parent, explicit
parent and grandparent `executionWorkspaceId`, a non-child follow-up,
and unchanged top-level defaults and explicit top-level reuse. 7
creation cases fail on `master` and pass with this change. Admission
cases: an old-release child is rebound while its parent is running and
the parent keeps its workspace; a running child is not rebound (deploy
lands mid-run); an unrelated explicit share is kept; the parent is never
rebound.
- New heartbeat case in
`server/src/__tests__/heartbeat-workspace-busy.test.ts`: an old-release
child bound to its running parent's workspace in a sandbox environment
runs to `succeeded` with no `workspace_busy`, gets a new workspace, and
the parent run and binding stay unchanged. It fails on `master` (the
child waits with `workspace_busy`).
- Updated tests that asserted the old inheritance in
`issues-service.test.ts`, `task-workspace-binding.test.ts`,
`chat-project-tools.test.ts`, `task-workspace-child-trust.test.ts`,
`heartbeat-git-restore-point-resume.test.ts` (a child bound to a closed
parent's expired workspace now starts from the project base and leaves
the parent's restore point alone), and
`ui/src/lib/subIssueDefaults.test.ts`. Remote-workspace reservation,
continuation and writer-wait suites now seed the older-release shared
shape directly (or an unrelated explicit share), so those safety paths
stay covered.
- `npx vitest run` on the changed suites and about 45 related server
suites (heartbeat workspace, recovery admission, issue routes, routines,
plugins), `ui` `subIssueDefaults` and `NewIssueDialog` tests, `pnpm
check:token-gates`, `pnpm -r typecheck`.

- New
`server/src/__tests__/heartbeat-subtask-acquired-repository.test.ts`: a
project with no project workspace, a parent whose task files hold an
acquired repository with an unmerged change, and a child (new, and the
older-release shape bound to the parent's workspace) that runs to
`succeeded` in its own task files with a fresh clone at the project base
and its own ready repository receipt. The parent's checkout keeps its
change. Both cases fail on the previous head with "needs a project /
project workspace".

## Risks

- **Project-level configuration still applies to children exactly as to
top-level tasks.** In a project configured for `shared_workspace`, every
task (top-level or child) uses the project's one folder under the
existing shared-workspace concurrency policy. A branch that the project
itself pins applies to every task in the project. This PR removes only
the parent-to-child carry-over of the realized workspace; it does not
change those project defaults.
- **Standalone follow-ups that an older release bound through
`inheritExecutionWorkspaceFromIssueId`** are migrated only when the
source is in their recorded lineage (parent or privacy parent). Others
keep their explicit share and the existing writer-wait serialization.

- **Behaviour change for agents that relied on a shared checkout.** A
child or follow-up no longer sees the parent's unpushed or uncommitted
changes. Agents must push or merge code they want a subtask to build on.
The skill and onboarding text now say this.
- **Explicit ancestor workspace ids are silently dropped** instead of
rejected, so older clients do not break. The task gets an independent
workspace.
- **First run of a migrated child starts from the project base.** If an
older child had uncommitted work in the parent's workspace, that work
stays in the parent's workspace and is not copied into the child's new
workspace.
- **Admission adds one indexed read** for tasks with a parent and a
bound workspace; the locking transaction and ancestor query run only
when the bound workspace was created by another issue.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use (file edits, shell, test execution) and
extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:34:09 -06:00

510 lines
40 KiB
TypeScript

import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { projectService } from "../services/projects.js";
import { callProjectTool } from "../services/project-tools.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import { agents, issues, heartbeatRuns, projects, companyMemberships, executionWorkspaces } from "@paperclipai/db";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { issueService } from "../services/issues.js";
import { documentService } from "../services/documents.js";
import { activityService } from "../services/activity.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
(support.supported ? describe : describe.skip)("chat project tool handoff", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000);
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
const call = (fixture: Awaited<ReturnType<typeof server.fixture>>, tool: string, args: Record<string, unknown>) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() });
it("pages only visible projects and prefilters low-trust agent and run scopes", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const ids = Array.from({ length: 53 }, (_, i) => `abcdefab-0000-4000-8000-${String(i).padStart(12, "0")}`);
await server.db.insert(projects).values(ids.map((id, i) => ({
id, companyId: f.companyId, name: `Project ${i}`, status: "in_progress",
description: "日本語🦀".repeat(5_000), executionWorkspacePolicy: { oversized: "x".repeat(20_000) },
})));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const toolInput = { name: "list_projects", apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: false };
const first = await callProjectTool({ ...toolInput, arguments: {} });
expect(first.projects).toHaveLength(50);
expect(Buffer.byteLength(JSON.stringify(first))).toBeLessThan(256 * 1024);
expect(first.projects.find((p: { id: string }) => p.id === ids[0])).toMatchObject({ descriptionTruncated: true });
expect(first.projects.every((p: object) => !Object.hasOwn(p, "executionWorkspacePolicy") && !Object.hasOwn(p, "workspaces"))).toBe(true);
const last = await callProjectTool({ ...toolInput, arguments: { cursor: first.nextCursor } });
expect([...first.projects, ...last.projects].map((p: { id: string }) => p.id).sort()).toEqual([...ids, f.projectId].sort());
expect(last.nextCursor).toBeNull();
// A permitted project beyond the first database batch must remain discoverable.
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: ids.slice(-2) } } };
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
expect((await projectService(server.db).listSummaries(f.companyId, {
limit: 51, includeArchived: false, candidateIds: ids.slice(-2),
})).map(p => p.id)).toEqual(ids.slice(-2));
const restricted = await callProjectTool({ ...toolInput, arguments: { limit: 1 } });
expect(restricted.projects.map((p: { id: string }) => p.id)).toEqual([ids[51]]);
expect(restricted.nextCursor).toBe(ids[51]);
const restrictedLast = await callProjectTool({ ...toolInput, arguments: { limit: 1, cursor: restricted.nextCursor } });
expect(restrictedLast.projects.map((p: { id: string }) => p.id)).toEqual([ids[52]]);
expect(restrictedLast.nextCursor).toBeNull();
// A run-only boundary also restricts the database candidates.
await server.db.update(agents).set({ permissions: {} }).where(eq(agents.id, f.agentId));
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const runRestricted = await callProjectTool({ ...toolInput, arguments: {} });
expect(runRestricted.projects.map((p: { id: string }) => p.id)).toEqual(ids.slice(-2));
expect(runRestricted.nextCursor).toBeNull();
// Adding denied projects must not change either the visible page or cursor.
await server.db.insert(projects).values({ companyId: f.companyId, name: "Hidden" });
expect(await callProjectTool({ ...toolInput, arguments: {} })).toEqual(runRestricted);
// Project policy may contribute a root scope, so candidate narrowing must
// not suppress a project that the full authorization decision permits.
await server.db.update(projects).set({ executionWorkspacePolicy: {
authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId } },
} }).where(eq(projects.id, f.projectId));
const withProjectScope = await callProjectTool({ ...toolInput, arguments: {} });
expect(withProjectScope.projects.map((p: { id: string }) => p.id).sort()).toEqual([...ids.slice(-2), f.projectId].sort());
expect(withProjectScope.nextCursor).toBeNull();
});
it("allows a conversation reply to enter review without manufacturing a review interaction", async () => {
const f = await server.fixture({ conversation: true });
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}`, {
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ status: "in_review", comment: "The plan is ready for our next discussion." }),
});
const result = await response.json();
expect(response.status, JSON.stringify(result)).toBe(200);
expect(result.status).toBe("in_review");
});
it("creates an ordinary project task and its plan atomically, retaining the conversation plan", async () => {
const f = await server.fixture({ conversation: true });
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Full discussion plan" });
const input = { title: "Implement the clarified outcome", projectId: f.projectId, initialPlan: "# Execution plan\n\nBuild and verify the outcome.", idempotencyKey: "handoff" };
const first = await call(f, "create_task", input) as any;
const again = await call(f, "create_task", input) as any;
expect(again.task.id).toBe(first.task.id);
expect(first.task.parentId).toBeNull();
const [task] = await server.db.select().from(issues).where(eq(issues.id, first.task.id));
expect(task).toMatchObject({ projectId: f.projectId, assigneeAgentId: f.agentId, status: "todo" });
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.body).toBe(input.initialPlan);
expect((await documentService(server.db).getIssueDocumentByKey(f.issueId, "plan"))?.body).toBe("Full discussion plan");
await expect(call(f, "create_task", { ...input, title: "Different" })).rejects.toThrow(/idempotency/);
});
it.each([true, false])("defaults raw API creation only for a chat-bound run: %s", async (conversation) => {
const f = await server.fixture({ conversation, disableWakeOnDemand: true });
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
for (const assignment of [{}, { assigneeAgentId: null }]) {
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: `Requested task ${randomUUID()}`, projectId: f.projectId, status: "todo", ...assignment }),
});
const result = await response.json();
expect(response.status, JSON.stringify(result)).toBe(201);
expect(result).toMatchObject({ assigneeAgentId: conversation ? f.agentId : null, assigneeUserId: null });
}
});
it.each(["native", "mcp"])("preserves an explicit alternate agent or person through %s creation", async (transport) => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [otherAgent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Requested editor", status: "idle", adapterType: "process" }).returning();
for (const assignment of [{ assigneeActorId: otherAgent.id }, { assigneeUserId: f.responsibleUserId }]) {
const args = { title: `Explicitly assigned task ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), ...assignment };
const result = transport === "native" ? await call(f, "create_task", args) as any : await callProjectTool({
name: "create_task", arguments: args, apiUrl: server.apiUrl,
token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true,
});
const taskId = transport === "native" ? result.task.id : result.id;
const [task] = await server.db.select().from(issues).where(eq(issues.id, taskId));
expect(task).toMatchObject({ assigneeAgentId: assignment.assigneeActorId ?? null, assigneeUserId: assignment.assigneeUserId ?? null });
}
});
it.each(["native", "mcp"])("honors backlog status and parentTaskId through %s creation", async (transport) => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const create = async (args: Record<string, unknown>) => {
const result = transport === "native" ? await call(f, "create_task", args) as any : await callProjectTool({
name: "create_task", arguments: args, apiUrl: server.apiUrl,
token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true,
});
const [task] = await server.db.select().from(issues).where(eq(issues.id, transport === "native" ? result.task.id : result.id));
return task;
};
const parent = await create({ title: `Backlog parent ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog" });
expect(parent).toMatchObject({ status: "backlog", parentId: null });
const child = await create({ title: `Backlog child ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog", parentTaskId: parent.id });
expect(child).toMatchObject({ status: "backlog", parentId: parent.id });
});
it("persists required output review supplied through MCP creation", async () => {
const f = await server.fixture({ conversation: true });
const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] };
const result = await callProjectTool({ name: "create_task", arguments: {
title: "MCP review gate", projectId: f.projectId, idempotencyKey: "mcp-quality-gate", requiredQualityReview: requirement,
}, apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true }) as any;
const [task] = await server.db.select().from(issues).where(eq(issues.id, result.id));
expect(task.executionPolicy).toMatchObject({ requiredQualityReview: requirement });
});
it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => {
const f = await server.fixture({ conversation: true });
const svc = issueService(server.db);
await expect(svc.create(f.companyId, { title: "Invalid", parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
await expect(svc.createChild(f.issueId, { title: "Invalid" })).rejects.toThrow(/cannot have new subtasks/);
await expect(svc.importIssues(f.companyId, [{
id: randomUUID(), ref: "imported", title: "Imported child", parentId: f.issueId,
projectId: null, projectWorkspaceId: null, description: null, assigneeAgentId: null,
status: "backlog", priority: "medium", billingCode: null, assigneeAdapterOverrides: null,
executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null,
}])).rejects.toThrow(/cannot have new subtasks/);
const ordinary = await svc.create(f.companyId, { title: "Ordinary" });
await expect(svc.update(ordinary.id, { parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
await server.db.update(issues).set({ parentId: f.issueId }).where(eq(issues.id, ordinary.id));
expect(await svc.update(ordinary.id, { title: "Legacy edited", parentId: f.issueId })).toMatchObject({ title: "Legacy edited" });
expect(await svc.update(ordinary.id, { parentId: null })).toMatchObject({ parentId: null });
});
it("hands off through the same API used by Claude/Codex MCP with the plan present on return", async () => {
const f = await server.fixture({ conversation: true });
const result = await callProjectTool({ name: "create_task", arguments: { title: "MCP handoff", projectId: f.projectId, initialPlan: "# Plan\nImplement in the execution task.", idempotencyKey: "mcp" },
apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
expect(result).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId });
expect((await documentService(server.db).getIssueDocumentByKey(result.id, "plan"))?.body).toContain("Implement in the execution task");
});
it("allows low-trust chat handoffs and self-assigned subtasks through both creation endpoints", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const task = await callProjectTool({ name: "create_task", arguments: { title: "Contained MCP handoff", projectId: f.projectId, initialPlan: "Do the requested work", idempotencyKey: "contained-mcp" },
apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
expect(task).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId, sourceTrust: { disposition: "quarantined" } });
expect(task.executionPolicy.authorizationPolicy).toMatchObject({ trustPreset: "low_trust_review", ...policy.authorizationPolicy });
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.sourceTrust).toMatchObject({ disposition: "quarantined" });
const native = await call(f, "create_task", { title: "Contained native handoff", projectId: f.projectId, idempotencyKey: "contained-native" }) as any;
expect(native.task).toMatchObject({ parentId: null, projectId: f.projectId, assigneeActorId: f.agentId });
const [nativeTask] = await server.db.select().from(issues).where(eq(issues.id, native.task.id));
expect(nativeTask.executionPolicy).toMatchObject({ authorizationPolicy: { trustPreset: "low_trust_review", ...policy.authorizationPolicy } });
expect(nativeTask.sourceTrust).toMatchObject({ disposition: "quarantined" });
for (const path of [`/api/issues/${task.id}/children`, `/api/companies/${f.companyId}/issues`]) {
const response = await fetch(`${server.apiUrl}${path}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: `Self decomposition ${path}`, parentId: task.id, assigneeAgentId: f.agentId, status: "backlog" }),
});
const child = await response.json();
expect(response.status, JSON.stringify(child)).toBe(201);
expect(child).toMatchObject({ parentId: task.id, projectId: f.projectId, assigneeAgentId: f.agentId, sourceTrust: { disposition: "quarantined" } });
expect(child.executionPolicy.authorizationPolicy).toMatchObject({ trustPreset: "low_trust_review", ...policy.authorizationPolicy });
}
});
it("retains a run-only low-trust boundary on a new task even when standard policy is requested", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const executionPolicy = { authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy } }).where(eq(heartbeatRuns.id, f.runId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Retain containment", projectId: f.projectId, assigneeAgentId: f.agentId, executionPolicy: { authorizationPolicy: { trustPreset: "standard" } }, status: "backlog" }),
});
const task = await response.json();
expect(response.status, JSON.stringify(task)).toBe(201);
expect(task.executionPolicy).toMatchObject(executionPolicy);
});
it("enforces the proposed task scope and assignee before creating any low-trust work", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Outside boundary" }).returning();
const [otherAgent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Unauthorized assignee", status: "active" }).returning();
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const create = async (path: string, fields: Record<string, unknown>) => {
const response = await fetch(`${server.apiUrl}${path}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: `Boundary test ${randomUUID()}`, status: "backlog", assigneeAgentId: f.agentId, ...fields }),
});
return { status: response.status, body: await response.json() };
};
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
for (const fields of [
{ projectId: outside.id },
{ projectId: outside.id, assigneeAgentId: null },
{ projectId: f.foreignProjectId },
{ projectId: f.projectId, assigneeAgentId: otherAgent.id },
{ projectId: f.projectId, assigneeAgentId: null, assigneeUserId: "board-user" },
]) {
const result = await create(path, fields);
const hiddenProject = fields.projectId !== f.projectId && path.startsWith("/api/companies/");
expect(result.status, JSON.stringify(result.body)).toBe(hiddenProject ? 404 : 403);
expect(result.body.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary|cannot assign work to board users|different company/i);
}
}
const after = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
expect(after).toEqual(before);
for (const fields of [{ projectId: outside.id }, { projectId: f.projectId, assigneeActorId: otherAgent.id }]) {
await expect(call(f, "create_task", { title: "Native scope denial", idempotencyKey: randomUUID(), ...fields })).rejects.toThrow(/outside.*boundary/);
}
await server.db.update(agents).set({ permissions: { ...policy, authorizationPolicy: {
...policy.authorizationPolicy, assignmentPolicy: { mode: "protected" }, protectedAgent: { blockAssignment: true },
} } }).where(eq(agents.id, f.agentId));
const protectedResult = await create(`/api/companies/${f.companyId}/issues`, { projectId: f.projectId });
expect(protectedResult.status, JSON.stringify(protectedResult.body)).toBe(403);
expect(protectedResult.body.error).toContain("protected-agent policy");
});
it("allows low-trust root-scoped subtasks without granting descendants of an exact issue scope", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
const response = await fetch(`${server.apiUrl}${path}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: `Root subtask ${randomUUID()}`, parentId: f.issueId, assigneeAgentId: f.agentId, status: "backlog" }),
});
const child = await response.json();
expect(response.status, JSON.stringify(child)).toBe(201);
expect(child.executionPolicy.authorizationPolicy.trustBoundary).toMatchObject(boundary);
}
await expect(call(f, "create_task", { title: "Native root subtask", idempotencyKey: "root-child", status: "backlog" })).resolves.toMatchObject({ task: { parentId: f.issueId, assigneeActorId: f.agentId } });
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Unrelated project" }).returning();
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
const response = await fetch(`${server.apiUrl}${path}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Root cannot escape into another project", parentId: f.issueId,
projectId: outside.id, assigneeAgentId: f.agentId, status: "backlog" }),
});
const denied = await response.json();
const hiddenProject = path.startsWith("/api/companies/");
expect(response.status, JSON.stringify(denied)).toBe(hiddenProject ? 404 : 403);
expect(denied.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary/i);
}
await expect(call(f, "create_task", { title: "Native root cannot escape", projectId: outside.id,
idempotencyKey: "root-outside", status: "backlog" })).rejects.toThrow(/outside.*boundary/);
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", issueIds: [f.issueId] } } } }).where(eq(agents.id, f.agentId));
await expect(call(f, "create_task", { title: "Exact scope is not a subtree", idempotencyKey: "exact-child" })).rejects.toThrow(/outside.*boundary/);
});
it("authorizes workspace sources without assigning projects to children of a projectless root", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const settings = instanceSettingsService(server.db);
const previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
const [executionWorkspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared_workspace", strategyType: "project_primary", name: "Outside root" }).returning();
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
const paths = [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`];
const selections: Array<Record<string, string>> = [{ projectWorkspaceId: f.projectWorkspaceId }, { executionWorkspaceId: executionWorkspace.id },
{ inheritExecutionWorkspaceFromIssueId: f.blockerId }];
const create = async (path: string, selection: Record<string, string>) => {
const response = await fetch(`${server.apiUrl}${path}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: `Workspace scope ${randomUUID()}`, parentId: f.issueId,
assigneeAgentId: f.agentId, status: "backlog", ...selection }),
});
return { status: response.status, body: await response.json() };
};
for (const path of paths) {
for (const selection of selections) {
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
const response = await create(path, selection);
expect(response.status, JSON.stringify(response.body)).toBe(403);
}
}
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
await server.db.update(issues).set({ executionWorkspaceId: executionWorkspace.id }).where(eq(issues.id, f.issueId));
// A child never inherits its parent's workspace, so the parent's
// out-of-boundary files cannot leak into the child either.
await call(f, "create_task", { title: "Inherited source cannot escape", idempotencyKey: "inherited-source-denied", status: "backlog" });
const [inheritedChild] = await server.db.select({ executionWorkspaceId: issues.executionWorkspaceId }).from(issues)
.where(and(eq(issues.companyId, f.companyId), eq(issues.title, "Inherited source cannot escape")));
expect(inheritedChild?.executionWorkspaceId).toBeNull();
await server.db.delete(issues).where(and(eq(issues.companyId, f.companyId), eq(issues.title, "Inherited source cannot escape")));
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { ...boundary, projectIds: [f.projectId] },
} } }).where(eq(agents.id, f.agentId));
for (const path of paths) {
for (const selection of selections) {
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
const response = await create(path, selection);
expect(response.status, JSON.stringify(response.body)).toBe(201);
expect(response.body.projectId).toBe(selection.inheritExecutionWorkspaceFromIssueId ? f.projectId : null);
if (selection.projectWorkspaceId) {
expect(response.body.projectWorkspaceId).toBe(selection.projectWorkspaceId);
expect(response.body.executionWorkspaceId).toBeNull();
}
expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]);
}
}
} finally {
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
}
});
it("keeps the responsible user's assignment restrictions on low-trust self-assignment", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", projectIds: [f.projectId] } } } }).where(eq(agents.id, f.agentId));
await server.db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, f.responsibleUserId!));
await expect(call(f, "create_task", { title: "Viewer cannot assign", projectId: f.projectId, idempotencyKey: "viewer-denied" })).rejects.toThrow();
expect(await server.db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(2);
});
it("does not reuse an out-of-scope task through a title or idempotency collision", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Private project" }).returning();
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
for (const parentId of [null, f.issueId]) {
const key = randomUUID();
const title = `Existing private work ${randomUUID()}`;
const existing = await issueService(server.db).create(f.companyId, { projectId: outside.id, parentId,
title, description: "Private contents", assigneeAgentId: f.agentId, status: "backlog", idempotencyKey: key });
for (const allowed of [false, true]) {
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { mode: "low_trust_review", projectIds: [f.projectId, ...(allowed ? [outside.id] : [])] },
} } }).where(eq(agents.id, f.agentId));
for (const collision of [{ idempotencyKey: key, title: "Different proposed task" }, { title }]) {
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ projectId: f.projectId, parentId, assigneeAgentId: f.agentId, status: "backlog", ...collision }),
});
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(allowed ? 200 : 403);
if (allowed) expect(body).toMatchObject({ id: existing.id, projectId: outside.id, deduplicated: true });
else expect(JSON.stringify(body)).not.toContain("Private contents");
}
}
expect((await issueService(server.db).getById(existing.id))?.projectId).toBe(outside.id);
}
});
it("retains ordinary child delegation and projectless task creation", async () => {
const f = await server.fixture();
const result = await call(f, "create_task", { title: "Delegate ordinary work", idempotencyKey: "child" }) as any;
expect(result.task.parentId).toBe(f.issueId);
expect(await issueService(server.db).create(f.companyId, { title: "No project needed" })).toMatchObject({ projectId: null });
});
it("creates a project once through the production API and records it on the source feed", async () => {
const f = await server.fixture({ conversation: true });
const input = { name: "New non-code project", description: "A well-scoped outcome", idempotencyKey: "project" };
const results = await Promise.all(Array.from({ length: 4 }, () => call(f, "create_project", input))) as any[];
const project = results[0];
expect(new Set(results.map(result => result.id)).size).toBe(1);
expect(project.id).toBeTruthy();
expect((await call(f, "create_project", input) as any).id).toBe(project.id);
const feed = await activityService(server.db).forIssue(f.issueId);
expect(feed.filter(event => event.action === "project.created")).toHaveLength(1);
expect(feed.find(event => event.action === "project.created")).toMatchObject({ entityId: project.id, runId: f.runId, details: { sourceIssueId: f.issueId } });
await expect(call(f, "create_project", { ...input, name: "Changed" })).rejects.toThrow(/different inputs/);
});
it("recovers a committed project after losing its HTTP response without repeating the effect", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const request = { tool: "create_project", arguments: { name: "Surviving project", idempotencyKey: "lost-response" }, callId: randomUUID() };
const realFetch = globalThis.fetch;
const lostResponse = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => {
const response = await realFetch(url, options);
if (response.status === 201 && String(url).endsWith(`/companies/${f.companyId}/projects`)) {
await response.arrayBuffer();
throw new Error("Controller lost the committed response");
}
return response;
});
try {
await expect(f.authority.execute(request)).rejects.toThrow(/lost the committed response/);
} finally { lostResponse.mockRestore(); }
const committed = await f.snapshot();
const created = committed.projects.find(project => project.name === request.arguments.name)!;
expect(created).toBeDefined();
const recovered = await Promise.all(Array.from({ length: 4 }, () => new PaperclipRunnerToolAuthority(server.db, f).recover(request)));
expect(recovered).toEqual(Array.from({ length: 4 }, () => ({ result: expect.objectContaining({ id: created.id }) })));
await expect(new PaperclipRunnerToolAuthority(server.db, f).recover({ ...request, arguments: { ...request.arguments, name: "Changed" } })).rejects.toThrow(/different inputs/);
const after = await f.snapshot();
expect(after.projects.map(project => project.id).sort()).toEqual(committed.projects.map(project => project.id).sort());
expect(after.activity.filter(event => event.action === "project.created")).toHaveLength(1);
const foreign = await server.fixture({ disableWakeOnDemand: true });
expect(await foreign.authority.recover(request)).toBeNull();
await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
await expect(new PaperclipRunnerToolAuthority(server.db, f).recover(request)).rejects.toThrow();
});
it("holds an unknown project mutation without creating a project or audit event", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
const before = await f.snapshot();
const request = { tool: "create_project", arguments: { name: "Uncertain project", idempotencyKey: "unknown" }, callId: randomUUID() };
expect(await f.authority.recover(request)).toBeNull();
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/projects`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Receipt-Only": "true" },
body: JSON.stringify(request.arguments),
});
expect(response.status).toBe(204);
const missingKey = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/projects`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Receipt-Only": "true" },
body: JSON.stringify({ name: "Missing receipt key" }),
});
expect(missingKey.status).toBe(422);
const after = await f.snapshot();
expect(after.projects.map(project => project.id).sort()).toEqual(before.projects.map(project => project.id).sort());
expect(after.activity.filter(event => event.action === "project.created")).toEqual([]);
});
it("includes an explicit workspace repository in the committed project card", async () => {
const f = await server.fixture({ conversation: true });
const project = await call(f, "create_project", { name: "Workspace repo", workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "workspace" }) as any;
const feed = await activityService(server.db).forIssue(f.issueId);
expect(feed.find(event => event.entityId === project.id)?.details?.repositories).toEqual([
expect.objectContaining({ url: "https://github.com/example/web" }),
]);
});
it("registers multiple previously unknown GitHub URLs and deduplicates equivalent URLs", async () => {
const f = await server.fixture({ conversation: true });
const project = await call(f, "create_project", { name: "Across repos", repositoryUrls: ["https://github.com/example/web.git", "https://github.com/example/api", "https://github.com/example/web/"], idempotencyKey: "urls" }) as any;
expect(project.workspaces.map((w: any) => w.repoUrl).sort()).toEqual(["https://github.com/example/api", "https://github.com/example/web"]);
expect(project.workspaces.filter((w: any) => w.isPrimary)).toHaveLength(1);
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://github.com/example/api"], workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "conflict" })).rejects.toThrow(/either workspace/);
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://user:password@github.com/example/api"], idempotencyKey: "credentials" })).rejects.toThrow(/without credentials/);
});
it("allows planning documents while denying project/task creation in Plan and Ask mode", async () => {
for (const mode of ["planning", "ask"] as const) {
const f = await server.fixture({ conversation: true, mode });
await expect(call(f, "create_project", { name: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
await expect(call(f, "create_task", { title: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
if (mode === "planning") await call(f, "write_document", { key: "plan", title: "Plan", body: "Clarify and plan here", idempotencyKey: "plan" });
}
});
it("rejects invented repository IDs and cancelled runs without creating a project", async () => {
const f = await server.fixture({ conversation: true });
await expect(call(f, "create_project", { name: "Missing repo", repositoryIds: ["999999"], idempotencyKey: "missing" })).rejects.toThrow(/repository.*available/);
await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
await expect(call(f, "create_project", { name: "Cancelled", idempotencyKey: "cancelled" })).rejects.toThrow();
});
});