Files
PaperClipAI/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
T
DottaandClaude Opus 5.5 958f9aa9f3 feat(issues): limit agent-created task trees to parent and child (#19312)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents split work into tasks. A task can have a parent task.
> - Today agents can nest tasks to any depth: parent, child, grandchild,
and deeper.
> - Deep agent-made trees are hard to follow, and an agent that works on
a child task makes grandchildren by default.
> - This pull request limits agent-created trees to two levels: a
top-level task and its children.
> - A request for a grandchild does not fail. The server puts the new
task beside the requester and tells the agent.
> - Board users keep full control. They can still nest to any depth.
Existing deep trees stay valid.
> - The pull request also records which task created each new task, so
the audit trail stays complete when the parent changes.

## Linked Issues or Issue Description

No public issue exists. This is the feature description. Related: #16853
(budgets that include descendant work) also reasons about task trees;
this change does not alter its traversal.

**Subsystem affected**

Issues (tasks): create, child create and move in the issue service and
routes. Agent tools, chat suggestions and creation provenance use these
paths.

**Problem or motivation**

Agents create subtasks as deep as they want. An agent that runs on a
child task gets that child as the default parent, so its follow-ups
become grandchildren. The trees become deep and hard to read. The parent
link is also the only record of which task created a follow-up, so that
record is lost when the parent changes.

**Proposed solution**

Limit agent-created trees to a parent and its children. When an agent
asks for a grandchild, put the new task under the requester's parent and
return `parentAdjusted`. Do not reject the request. Keep board users
unrestricted. Record the originating task in a new server-set
`origin_issue_id` column. Keep `origin_run_id` as the originating run.

**Alternatives considered**

Reject grandchild requests with an error. We did not do this, because
agents then fail mid-task. Add a database constraint. We did not do
this, because existing deep trees must stay valid and nesting can return
later.

## What Changed

- Add `MAX_AGENT_TASK_HIERARCHY_DEPTH` (2) and the
`IssueParentAdjustment` type in `packages/shared`.
- Add `resolveNewTaskParent` and `planIssueParentChange` in
`server/src/services/issues.ts`. `create`, `createChild` and `update`
use the agent limit by default (`parentDepthPolicy: "flatten"`).
- The issue create, `/children` and PATCH routes pass `allow_nested` for
board users. For agents, the routes check authorization against the
parent that the server actually writes.
- The requester keeps its behavior when the server moves a task beside
it: `blockParentUntilDone` blocks the requester, and the new task
inherits the requester's workspace and privacy.
- The server never moves a task above a low-trust boundary root that
contains the requester.
- These subsystems keep any depth, because they find their work by
parent: watchdog reviews, monitor recovery, email-channel placement, and
routines that a person configured.
- An agent move of a task that has children skips only the parent change
and returns `reason: "has_children"`.
- Add `issues.origin_issue_id` (nullable, `on delete set null`, indexed)
in migration `0434`. Only the server sets it. The `createdFrom` detail
field, the `createdFromIssueId` filter and task-creation provenance read
it first.
- Chat prompts now let a chat agent create one task, a task with
children, or separate tasks. The conversation is never a parent.
- The runner `create_task` tool accepts an optional `parentTaskId`. A
chat agent can create a top-level task and then put subtasks under it.
The run must be able to read and change the parent. A conversation is
never a parent. The depth limit still applies. Project tools map the
field to `parentId` through the issue route. The protocol manifest,
semantic contracts and eval fixture are regenerated.
- Watchdog runs that have an active watchdog keep their requested parent
on all create paths, because their access is limited to the watched
subtree.
- Fix a bug that the test drive found: the chat (MCP) `create_task`
ignored the requested `status`, so a `backlog` task became `todo` and
started to run. It now uses the same rule as the native tool.
- Update `skills/paperclip`, `doc/SPEC-implementation.md`, and add the
plan `doc/plans/2026-11-05-two-level-task-hierarchy.md`.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-task-hierarchy-depth.test.ts
src/__tests__/agent-task-hierarchy-depth-routes.test.ts`: 19 new tests
for service and route behavior, all pass.
- Related suites (64 files: issues service, thread interactions,
privacy, watchdogs, provenance, runner tool authority, decisions,
routines, project tools): 1208 tests pass. Two tests changed because of
the intended behavior and pass now. Two `email-channels.integration`
tests fail locally because the test cannot find a bundled skill file
from this worktree path. This change does not touch that code.
- `packages/db`: migration safety, snapshot drift and journal tests
pass.
- Typecheck passes for `server`, `packages/shared`, `packages/db`, `cli`
and `ui` (local `ui` errors come only from generated Storybook catalog
files that are absent).
- Local test drive on an isolated instance with real Claude agents: 29
of 29 use cases pass, including an agent on a child task (follow-ups
placed beside it, with the notice) and a chat that creates a top-level
task with three subtasks.
- I did not run the full `pnpm test:run` or `pnpm build` locally. CI
runs them.

## Risks

- Behavior change for agents: some follow-ups that were grandchildren
are now siblings. The parent of the requester is woken when all its
children finish, which now includes these tasks.
- The runner `create_task` input contract adds one optional field, so
the protocol manifest and the pack contract change. The tool output does
not change: it shows the actual `parentId`, and the activity log records
`parentAdjusted`.
- Migration `0434` adds a nullable column, a foreign key and an index.
It does not backfill rows.
- `IssueCreationSource.run` is now nullable.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, local test runs).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] 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-05 21:22:33 -06:00

1910 lines
87 KiB
TypeScript

import * as connectorRuntime from "../connector-runtime.js";
import * as cloudIdentity from "../cloud-runtime-identity.js";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { createPaperclipHttpServer } from "../../http/server.js";
import { LOW_TRUST_REVIEW_PRESET, upsertIssueDocumentSchema } from "@paperclipai/shared";
import { and, eq, inArray } from "drizzle-orm";
import {
activityLog,
agents,
authUsers,
approvals,
companies,
companyMemberships,
createDb,
documents,
heartbeatRuns,
issueApprovals,
issueComments,
issueThreadInteractions,
issueWatchdogs,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { PaperclipRunnerSemanticAuthority } from "./runner-semantic-authority.js";
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
import type { ToolGatewayService } from "../tool-gateway.js";
import { CURRENT_WAKE_COMMENTS_BINDING_SCHEMA, READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js";
import { NativeChatAttachmentReadScope, READ_CHAT_ATTACHMENT_TOOL_NAME } from "./chat-attachment-read.js";
import { buildLowTrustSourceTrust, buildPromotedSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../source-trust.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
import { resolveLocalRuntimeApiUrl } from "../../runtime-api.js";
describe("PaperclipRunnerToolAuthority", () => {
const credentialDocumentBody = "Use a secret manager for credential handling.\nAuthorization: Bearer intentional-document-credential";
let temporary: Awaited<
ReturnType<typeof startRecoveryPostgres>
> | null = null;
let db: ReturnType<typeof createDb>;
const companyId = "00000000-0000-4000-8000-000000000101";
const agentId = "00000000-0000-4000-8000-000000000102";
const issueId = "00000000-0000-4000-8000-000000000103";
const runId = "00000000-0000-4000-8000-000000000104";
beforeEach(() => {
vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", undefined);
vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS", undefined);
});
afterEach(() => vi.unstubAllEnvs());
beforeAll(async () => {
temporary = await startRecoveryPostgres(
"paperclip-runner-tools-",
);
db = createDb(temporary.connectionString);
await db.insert(companies).values({
id: companyId,
name: "Runner tools",
issuePrefix: "RNT",
issueCounter: 1,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Runner agent",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", apiKey: "must-not-leak" },
runtimeConfig: { token: "must-not-leak" },
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1,
identifier: "RNT-1",
title: "Exercise real runner tools",
status: "in_progress",
workMode: "standard",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db
.update(issues)
.set({ executionRunId: runId })
.where(eq(issues.id, issueId));
});
afterAll(async () => {
await temporary?.cleanup();
});
it("routes call_api through the private controller instead of the public callback origin", async () => {
const privateRequests: Array<{ path: string | undefined; authenticated: boolean }> = [];
const callbackRequests: string[] = [];
const controller = createPaperclipHttpServer((request, response) => {
privateRequests.push({ path: request.url, authenticated: /^Bearer /.test(request.headers.authorization ?? "") });
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ workProducts: [] }));
}, { apiUrl: "https://advertised.example.test" });
const callback = createServer((request, response) => {
callbackRequests.push(request.url ?? "");
response.writeHead(404).end();
});
await Promise.all([controller, callback].map(server => new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))));
try {
const controllerAddress = controller.address();
const callbackAddress = callback.address();
if (!controllerAddress || typeof controllerAddress === "string" || !callbackAddress || typeof callbackAddress === "string") throw new Error("Expected owned TCP listeners");
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "private-controller-test-secret");
vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", "true");
vi.stubEnv("PAPERCLIP_API_URL", `http://127.0.0.1:${callbackAddress.port}`);
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://callback-only.example.test");
vi.stubEnv("PAPERCLIP_LISTEN_HOST", "127.0.0.1");
vi.stubEnv("PAPERCLIP_LISTEN_PORT", String(controllerAddress.port));
vi.stubEnv("PORT", "3100");
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId,
apiUrl: resolveLocalRuntimeApiUrl() });
const result = await authority.execute({ tool: "call_api", callId: "private-controller-read", arguments: {
operationId: "GET /api/issues/{id}/work-products", pathParams: { id: issueId },
} });
expect(result).toMatchObject({ ok: true, status: 200 });
expect(privateRequests).toEqual([{ path: `/api/issues/${issueId}/work-products`, authenticated: true }]);
expect(callbackRequests).toEqual([]);
expect(process.env.PAPERCLIP_API_URL).toBe(`http://127.0.0.1:${callbackAddress.port}`);
} finally {
await Promise.all([controller, callback].map(server => new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close(error => error ? reject(error) : resolve());
})));
}
});
it("registers pinned connector definitions without discovery and rejects revoked assignments at use", async () => {
const connectorAssignments = connectorRuntime.connectorToolDefinitionsForSkills(new Set([
"paperclipai/paperclip/slack", "paperclipai/paperclip/browser-use-cloud", "custom/agentmail",
]));
expect(connectorAssignments).toHaveLength(1);
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, connectorAssignments });
expect(authority.definitions().some(tool => tool.name === "slack_history")).toBe(true);
expect(authority.definitions().some(tool => String(tool.name).startsWith("agentmail_"))).toBe(false);
await expect(authority.execute({ tool: "slack_history", callId: randomUUID(), arguments: { channel: "CEXAMPLE" } }))
.rejects.toThrow("This connector is no longer assigned or authorized");
});
it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(51);
expect(authority.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([
"get_workspace", "list_workspaces", "select_workspace", "prepare_repository",
]));
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never fabricate answers");
expect(questions.description).toContain("resolve-from-comment");
expect(questions.description).toContain("Existing resolver permissions still apply");
expect(questions.description).toContain("Do not fabricate answer links");
expect(JSON.stringify(questions.inputSchema)).toContain("at least two meaningful options");
expect(questions.inputSchema).toMatchObject({ properties: { payload: { properties: { questionSet: {
properties: { questions: { items: { properties: { answerMode: { enum: ["single_select", "multi_select", "text"] } } } } },
} } } } });
expect(authority.definitions().map((tool) => tool.name)).toEqual(
expect.arrayContaining([
"connections_search",
"connection_request", "create_project", "list_project_repositories", "list_projects",
"search_api", "call_api", "hire_agent",
"get_task_context",
"get_task_history",
"search_tasks",
"report_progress",
"submit_complaint",
"submit_suggestion",
"request_human_input",
"create_task",
"set_dependencies",
"set_task_monitor",
"list_documents",
"read_document",
"list_document_revisions",
"write_document",
"create_skill",
"update_skill",
"list_agents",
"get_agent",
"list_approvals",
"get_approval",
"get_approval_context",
"list_chat_attachments",
"reuse_chat_attachment",
]),
);
const context = await authority.execute({
tool: "get_task_context",
callId: "context",
arguments: {},
});
expect(context).toMatchObject({
activeTask: { id: issueId, identifier: "RNT-1" },
actor: { id: agentId },
});
expect(JSON.stringify(context)).not.toContain("must-not-leak");
await expect(
authority.execute({
tool: "finish_task",
callId: "hidden",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_tool_not_advertised");
await expect(
authority.execute({
tool: "list_chat_attachments",
callId: "historical-list-without-chat-binding",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_chat_attachment_binding_denied");
await expect(
authority.execute({
tool: READ_CURRENT_WAKE_COMMENTS_TOOL_NAME,
callId: "reader-without-bound-wake",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_tool_not_advertised");
await expect(
authority.execute({
tool: READ_CHAT_ATTACHMENT_TOOL_NAME,
callId: "attachment-read-without-run-scope",
arguments: { sourceCommentId: randomUUID(), attachmentId: randomUUID() },
}),
).rejects.toThrow("paperclip_runner_chat_attachment_read_scope_unavailable");
});
it("keeps run-scoped reader declarations stable for resumed provider threads", () => {
// Codex thread/resume has no dynamicTools parameter: a resumed thread keeps
// the catalog from its original thread/start. A truncated chat wake that
// resumes a thread opened on an ordinary turn still needs both readers.
const ordinary = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const chatBinding = { companyId, agentId, issueId, runId };
const truncatedChat = new PaperclipRunnerToolAuthority(db, {
...chatBinding,
currentWakeComments: {
schema: CURRENT_WAKE_COMMENTS_BINDING_SCHEMA,
...chatBinding,
provider: "github",
commentIds: [randomUUID()],
attachmentOmissions: [],
bindingDigest: "binding-digest",
},
chatAttachmentReadScope: new NativeChatAttachmentReadScope({
db, binding: chatBinding, workspaceRoot: "/workspace", executionTargetKind: "local",
}),
});
expect(ordinary.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([
READ_CURRENT_WAKE_COMMENTS_TOOL_NAME, READ_CHAT_ATTACHMENT_TOOL_NAME,
]));
expect(ordinary.definitions()).toEqual(truncatedChat.definitions());
});
it("reads current tool authority without transferring saved profiles or output", async () => {
const binding = { companyId, agentId, issueId, runId };
const authority = new PaperclipRunnerToolAuthority(db, binding);
const contextCall = { tool: "get_task_context", callId: "context-read-width", arguments: {} };
const apiCall = { tool: "search_api", callId: "api-read-width", arguments: { query: "read task" } };
const expectedContext = await authority.execute(contextCall);
const expectedApi = await authority.execute(apiCall);
const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const [actor] = await db.select({ status: agents.status }).from(agents).where(eq(agents.id, agentId));
const payload = "saved-profile-and-output🙂".repeat(32_768);
await db.update(heartbeatRuns).set({
runnerProfileJson: { ...original.runnerProfileJson, retainedPayload: payload },
stdoutExcerpt: payload,
stderrExcerpt: payload,
}).where(eq(heartbeatRuns.id, runId));
const [expanded] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const observed = createDb(temporary!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns, issues, agents, companies }, capture);
try {
const reader = new PaperclipRunnerToolAuthority(observed, binding);
const context = await reader.execute(contextCall), api = await reader.execute(apiCall);
expect(context).toEqual(expectedContext);
expect(api).toEqual(expectedApi);
expect(JSON.stringify([context, api])).not.toContain("saved-profile-and-output");
capture.flush();
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
expect(reads.length).toBeGreaterThan(0);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(16 * 1024);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]).toEqual(expanded);
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
await expect(reader.execute(contextCall)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
await expect(reader.execute(apiCall)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
} finally {
capture.flush();
await observed.$client.end();
await db.update(agents).set({ status: actor.status }).where(eq(agents.id, agentId));
await db.update(heartbeatRuns).set({
runnerProfileJson: original.runnerProfileJson,
stdoutExcerpt: original.stdoutExcerpt,
stderrExcerpt: original.stderrExcerpt,
}).where(eq(heartbeatRuns.id, runId));
}
});
it("reads tool authority without transferring unused launch renderings or wake payloads", async () => {
const binding = { companyId, agentId, issueId, runId };
const authority = new PaperclipRunnerToolAuthority(db, binding);
const contextCall = { tool: "get_task_context", callId: "launch-context-read", arguments: {} };
const apiCall = { tool: "search_api", callId: "launch-api-read", arguments: { query: "read task" } };
const expectedContext = await authority.execute(contextCall), expectedApi = await authority.execute(apiCall);
const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const payload = "retained-launch-rendering🙂".repeat(8192);
const context = { ...original.contextSnapshot, executionContinuation: { savedInput: payload }, paperclipTaskMarkdown: payload,
paperclipTaskMarkdownCompact: payload, paperclipTaskMarkdownAssignment: payload, paperclipTaskMarkdownAssignmentCompact: payload,
paperclipWorkspace: { savedInput: payload }, paperclipEnvironment: { savedInput: payload },
paperclipContinuationSummary: payload, paperclipWakeComment: payload, paperclipWake: { retainedPayload: payload } };
await db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, runId));
const [expanded] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const observed = createDb(temporary!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns, issues, agents, companies }, capture);
try {
const reader = new PaperclipRunnerToolAuthority(observed, binding);
const actualContext = await reader.execute(contextCall), actualApi = await reader.execute(apiCall);
capture.flush();
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
expect(reads.length).toBeGreaterThan(0);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(16 * 1024);
expect(actualContext).toEqual(expectedContext);
expect(actualApi).toEqual(expectedApi);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]).toEqual(expanded);
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
await expect(reader.execute(contextCall)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
await expect(reader.execute(apiCall)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
} finally {
capture.flush(); await observed.$client.end();
await db.update(heartbeatRuns).set({ contextSnapshot: original.contextSnapshot, status: original.status }).where(eq(heartbeatRuns.id, runId));
}
});
it("checks the current full wake before mail actions while ordinary tools omit its payload", async () => {
const connectorAssignments = connectorRuntime.connectorToolDefinitionsForSkills(new Set(["paperclipai/paperclip/agentmail"]));
const name = connectorAssignments.flatMap(assignment => assignment.tools).find(tool => tool.name.startsWith("agentmail_"))!.name;
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, connectorAssignments });
const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const dispatch = vi.spyOn(connectorRuntime, "executeConnectorTool").mockResolvedValue({ outcome: "mail-dispatched" });
const wake = {
reason: "External chat message received", externalChatProvider: "github", checkedOutByHarness: true,
issue: { id: issueId, identifier: "RNT-1", title: "Conversation", description: "Task", descriptionTruncated: false,
status: "in_progress", workMode: "standard" },
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, commentIds: ["wake-comment"],
latestCommentId: "wake-comment", comments: [{ id: "wake-comment", issueId, body: "Reply here", bodyTruncated: false, authorType: "user" }],
fallbackFetchNeeded: false,
};
const call = { tool: name, callId: "current-mail-wake", arguments: {} };
try {
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, paperclipWake: wake } }).where(eq(heartbeatRuns.id, runId));
await expect(authority.execute(call)).rejects.toThrow("Restricted chat runs cannot use email actions");
expect(dispatch).not.toHaveBeenCalled();
await expect(authority.execute({ tool: "get_task_context", callId: "chat-task-context", arguments: {} }))
.resolves.toMatchObject({ activeTask: { id: issueId } });
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
await expect(authority.execute(call)).resolves.toEqual({ outcome: "mail-dispatched" });
expect(dispatch).toHaveBeenCalledTimes(1);
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, paperclipWake: { ...wake, checkedOutByHarness: false,
externalChatExecutionBound: true, issue: { ...wake.issue, status: "in_review" } } } }).where(eq(heartbeatRuns.id, runId));
await expect(authority.execute(call)).rejects.toThrow("Restricted chat runs cannot use email actions");
expect(dispatch).toHaveBeenCalledTimes(1);
for (const context of [{ issueId, source: "chat:github" }, { issueId, paperclipExternalChatQuestionResponse: true }]) {
await db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, runId));
await expect(authority.execute(call)).rejects.toThrow("Restricted chat runs cannot use email actions");
expect(dispatch).toHaveBeenCalledTimes(1);
}
} finally {
dispatch.mockRestore();
await db.update(heartbeatRuns).set({ contextSnapshot: original.contextSnapshot }).where(eq(heartbeatRuns.id, runId));
}
});
it("preserves platform receipt provenance in both mixed task history readers", async () => {
const at = new Date("2026-10-10T12:00:00Z");
const commentIds = Array.from({ length: 4 }, () => randomUUID());
const requestId = randomUUID();
await db.insert(issueComments).values([
{ id: commentIds[0], companyId, issueId, authorUserId: "board-user", authorType: "user",
body: "Check the configuration.", createdAt: at },
{ id: commentIds[1], companyId, issueId, authorAgentId: agentId, authorType: "agent",
origin: "fast_response", fastResponseRequestId: requestId, body: "I will check the configuration.",
createdAt: new Date(at.getTime() + 1000) },
{ id: commentIds[2], companyId, issueId, authorAgentId: agentId, authorType: "agent",
body: "Verified the configuration: two settings need updates.", createdAt: new Date(at.getTime() + 2000) },
{ id: commentIds[3], companyId, issueId, authorAgentId: agentId, authorType: "agent",
origin: "fast_response", body: "Deleted acknowledgement", createdAt: new Date(at.getTime() + 3000),
deletedAt: new Date(at.getTime() + 4000) },
]);
try {
const tool = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const semantic = new PaperclipRunnerSemanticAuthority(db, { companyId, agentId, issueId, runId });
const histories = [
async (limit: number) => tool.execute({ tool: "get_task_history", callId: `mixed-tool-history-${limit}`, arguments: { limit } }),
async (limit: number) => {
const result = await semantic.dispatch({ callId: `mixed-semantic-history-${limit}`, operationId: "get_task_history", input: { limit },
correlation: { runId, normalizedSessionId: randomUUID(), turnId: "history", itemId: "history" } });
expect(result).toMatchObject({ ok: true });
return result.ok ? result.value : null;
},
];
const expected = [
{ id: commentIds[0], authorAgentId: null, authorUserId: "board-user", origin: "comment", body: "Check the configuration." },
{ id: commentIds[1], authorAgentId: agentId, authorUserId: null, origin: "fast_response",
body: "[Paperclip acknowledgement; not agent work] I will check the configuration." },
{ id: commentIds[2], authorAgentId: agentId, authorUserId: null, origin: "comment",
body: "Verified the configuration: two settings need updates." },
];
for (const history of histories) {
expect(await history(3)).toMatchObject({ comments: expected });
expect(await history(2)).toMatchObject({ comments: expected.slice(1) });
}
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentIds[1])))[0])
.toMatchObject({ id: commentIds[1], origin: "fast_response", fastResponseRequestId: requestId,
authorAgentId: agentId, body: "I will check the configuration." });
} finally {
await db.delete(issueComments).where(inArray(issueComments.id, commentIds));
}
});
it.each([false, true])("legacy history preserves source trust for a current reader run (low trust: %s)", async (lowTrustReader) => {
const commentIds = Array.from({ length: 4 }, () => randomUUID());
const [originalRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const [originalIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
const quarantine = buildLowTrustSourceTrust({ issueId, runId, agentId });
const promotion = buildPromotedSourceTrust({
sourceIssueId: issueId, sourceArtifactKind: "comment", sourceArtifactId: commentIds[2],
promotedByActorType: "user", promotedByActorId: "board-user",
});
const at = new Date("2026-10-13T12:00:00Z");
await db.insert(issueComments).values([
{ id: commentIds[0], companyId, issueId, authorAgentId: agentId, authorType: "agent",
body: "QUARANTINED legacy history canary", sourceTrust: quarantine, createdAt: at },
{ id: commentIds[1], companyId, issueId, authorAgentId: agentId, authorType: "agent",
origin: "fast_response", body: "QUARANTINED acknowledgement canary", sourceTrust: quarantine,
createdAt: new Date(at.getTime() + 1000) },
{ id: commentIds[2], companyId, issueId, authorAgentId: agentId, authorType: "agent",
body: "Reviewed and promoted summary", sourceTrust: promotion, createdAt: new Date(at.getTime() + 2000) },
{ id: commentIds[3], companyId, issueId, authorUserId: "board-user", authorType: "user",
body: "Trusted coordination message", createdAt: new Date(at.getTime() + 3000) },
]);
try {
if (lowTrustReader) {
const executionPolicy = { authorizationPolicy: { trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET, companyId, rootIssueId: issueId,
} } };
await db.update(issues).set({ executionPolicy }).where(eq(issues.id, issueId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionPolicy } }).where(eq(heartbeatRuns.id, runId));
// Relaxing the live task must not relabel the already-admitted reader run.
await db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, issueId));
}
const semantic = new PaperclipRunnerSemanticAuthority(db, { companyId, agentId, issueId, runId });
const result = await semantic.dispatch({
callId: `legacy-trust-history-${lowTrustReader}`, operationId: "get_task_history", input: { limit: 4 },
correlation: { runId, normalizedSessionId: randomUUID(), turnId: "history", itemId: "history" },
});
expect(result).toMatchObject({ ok: true });
expect(result.ok ? result.value : null).toMatchObject({ comments: [
{ id: commentIds[0], sourceTrust: quarantine,
body: lowTrustReader ? "QUARANTINED legacy history canary" : LOW_TRUST_QUARANTINED_BODY },
{ id: commentIds[1], origin: "fast_response", sourceTrust: quarantine,
body: `[Paperclip acknowledgement; not agent work] ${lowTrustReader ? "QUARANTINED acknowledgement canary" : LOW_TRUST_QUARANTINED_BODY}` },
{ id: commentIds[2], sourceTrust: promotion, body: "Reviewed and promoted summary" },
{ id: commentIds[3], sourceTrust: null, body: "Trusted coordination message" },
] });
if (!lowTrustReader) expect(JSON.stringify(result)).not.toContain("QUARANTINED legacy history canary");
if (!lowTrustReader) expect(JSON.stringify(result)).not.toContain("QUARANTINED acknowledgement canary");
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentIds[0])))[0])
.toMatchObject({ body: "QUARANTINED legacy history canary", sourceTrust: quarantine });
} finally {
await db.update(heartbeatRuns).set({ contextSnapshot: originalRun!.contextSnapshot }).where(eq(heartbeatRuns.id, runId));
await db.update(issues).set({ executionPolicy: originalIssue!.executionPolicy }).where(eq(issues.id, issueId));
await db.delete(issueComments).where(inArray(issueComments.id, commentIds));
}
});
it("paginates complete native task inventories beyond one hundred results", async () => {
const ids = Array.from({ length: 205 }, () => randomUUID()).sort();
const foreignCompanyId = randomUUID();
const foreignId = randomUUID();
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign inventory", issuePrefix: "FINV" });
await db.insert(issues).values({ id: foreignId, companyId: foreignCompanyId, title: "synthetic-inventory-key foreign canary", status: "done" });
await db.insert(issues).values(ids.map((id, index) => ({
id, companyId, title: `Inventory pagination ${index}`,
description: "synthetic-inventory-key", status: index < 200 ? "done" : "blocked",
})));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const search = (arguments_: Record<string, unknown>) => authority.execute({
tool: "search_tasks", callId: randomUUID(), arguments: { query: "synthetic-inventory-key", ...arguments_ },
}) as Promise<{ tasks: Array<{ id: string }>; limit: number; returnedCount: number; hasMore: boolean; nextCursor: string | null }>;
try {
const first = await search({ limit: 200 });
expect(first.tasks).toHaveLength(200);
expect(JSON.stringify(first)).not.toContain(foreignId);
expect(first).toMatchObject({ limit: 200, returnedCount: 200, hasMore: true, nextCursor: ids[199] });
expect(first.tasks.map(task => task.id)).toEqual(ids.slice(0, 200));
const last = await search({ limit: 200, cursor: first.nextCursor });
expect(last.tasks.map(task => task.id)).toEqual(ids.slice(200));
expect(last).toMatchObject({ limit: 200, returnedCount: 5, hasMore: false, nextCursor: null });
expect(new Set([...first.tasks, ...last.tasks].map(task => task.id)).size).toBe(205);
expect(await search({ cursor: ids[204] })).toMatchObject({ tasks: [], limit: 50, returnedCount: 0, hasMore: false, nextCursor: null });
expect(await search({ statuses: ["blocked"], limit: 200 })).toMatchObject({ tasks: ids.slice(200).map(id => ({ id })), returnedCount: 5, hasMore: false });
expect(await search({})).toMatchObject({ limit: 50, returnedCount: 50, hasMore: true });
expect((await search({ limit: 200, cursor: ids[199]!.toUpperCase() })).tasks.map(task => task.id)).toEqual(ids.slice(200));
await expect(search({ cursor: "invalid" })).rejects.toThrow("paperclip_runner_tool_input_invalid");
} finally {
await db.delete(issues).where(inArray(issues.id, [...ids, foreignId]));
await db.delete(companies).where(eq(companies.id, foreignCompanyId));
}
});
it("returns public task URLs from the current claimed origin in context and search results", async () => {
const origin = vi.spyOn(cloudIdentity, "runtimeCanonicalOrigin").mockReturnValue("https://board.example");
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
try {
expect(await authority.execute({ tool: "get_task_context", callId: "public-task-url", arguments: {} }))
.toMatchObject({ activeTask: { id: issueId, url: `https://board.example/issues/${issueId}` } });
expect(await authority.execute({ tool: "search_tasks", callId: "search-public-task-url", arguments: { query: "Exercise real runner tools" } }))
.toMatchObject({ tasks: expect.arrayContaining([expect.objectContaining({ id: issueId, url: `https://board.example/issues/${issueId}` })]) });
origin.mockReturnValue("https://renamed.example");
expect(await authority.execute({ tool: "get_task_context", callId: "renamed-task-url", arguments: {} }))
.toMatchObject({ activeTask: { url: `https://renamed.example/issues/${issueId}` } });
origin.mockReturnValue("https://localhost");
expect(await authority.execute({ tool: "get_task_context", callId: "unsafe-task-url", arguments: {} }))
.toMatchObject({ activeTask: { url: null } });
} finally {
origin.mockRestore();
}
});
it("exposes existing child tasks on continuation without crossing company boundaries", async () => {
const completedChildId = randomUUID();
const activeChildId = randomUUID();
const unrelatedId = randomUUID();
const otherCompanyId = randomUUID();
const foreignChildId = randomUUID();
const hiddenChildIds = Array.from({ length: 101 }, () => randomUUID());
await db.insert(companies).values({ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" });
await db.insert(issues).values([
{ id: completedChildId, companyId, parentId: issueId, title: "Existing completed draft", status: "done", assigneeAgentId: agentId },
{ id: activeChildId, companyId, parentId: issueId, title: "Existing active review", status: "in_progress", assigneeAgentId: agentId },
{ id: unrelatedId, companyId, title: "Unrelated task", status: "todo" },
// Even inconsistent imported data cannot expose another company's task.
{ id: foreignChildId, companyId: otherCompanyId, parentId: issueId, title: "Foreign child", status: "todo" },
// Hidden rows must neither enter context nor consume the visible child limit.
...hiddenChildIds.map((id) => ({ id, companyId, parentId: issueId, title: "Hidden child",
hiddenAt: new Date(), createdAt: new Date(Date.now() + 1000) })),
]);
try {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const context = await authority.execute({ tool: "get_task_context", callId: "context-existing-children", arguments: {} });
expect(context).toMatchObject({
childTasks: expect.arrayContaining([
expect.objectContaining({ id: completedChildId, status: "done", parentId: issueId }),
expect.objectContaining({ id: activeChildId, status: "in_progress", assigneeAgentId: agentId }),
]),
childTasksTruncated: false,
delegationGuidance: expect.stringContaining("Reuse existing child tasks"),
});
expect(JSON.stringify(context)).not.toContain(foreignChildId);
expect(JSON.stringify(context)).not.toContain(unrelatedId);
for (const id of hiddenChildIds) expect(JSON.stringify(context)).not.toContain(id);
} finally {
await db.delete(issues).where(inArray(issues.id, [completedChildId, activeChildId, unrelatedId, foreignChildId, ...hiddenChildIds]));
await db.delete(companies).where(eq(companies.id, otherCompanyId));
}
});
it("advertises API tools by default and preserves direct-chat file tools when disabled", () => {
const previousEnabled = process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
const previousCompanies =
process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
const createAuthority = () =>
new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workspaceRoot: "/tmp/paperclip-runner-tools",
executionTargetKind: "local",
});
const requiredChatFileTools = [
"list_chat_attachments",
"reuse_chat_attachment",
"register_deliverable",
];
try {
delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
delete process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
const defaultNames = createAuthority()
.definitions()
.map((tool) => tool.name);
expect(defaultNames).toEqual(
expect.arrayContaining(requiredChatFileTools),
);
expect(defaultNames).toContain("search_api");
expect(defaultNames).toContain("call_api");
expect(defaultNames).toContain("hire_agent");
process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true";
process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS = companyId;
const enabledNames = createAuthority()
.definitions()
.map((tool) => tool.name);
expect(enabledNames).toEqual(
expect.arrayContaining([
...requiredChatFileTools,
"search_api",
"call_api",
"hire_agent",
]),
);
const hire = createAuthority().definitions().find((tool) => tool.name === "hire_agent")!;
const hireSchema = hire.inputSchema as { properties: Record<string, unknown> };
expect(hireSchema.properties).toEqual(expect.objectContaining({ name: expect.any(Object), role: expect.any(Object) }));
expect(hireSchema.properties).not.toHaveProperty("adapterConfig");
expect(hireSchema.properties).not.toHaveProperty("env");
process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "false";
const disabledNames = createAuthority().definitions().map((tool) => tool.name);
expect(disabledNames).toEqual(expect.arrayContaining(requiredChatFileTools));
for (const name of ["search_api", "call_api", "hire_agent"]) {
expect(disabledNames).not.toContain(name);
}
} finally {
if (previousEnabled === undefined) {
delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
} else {
process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = previousEnabled;
}
if (previousCompanies === undefined) {
delete process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
} else {
process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS = previousCompanies;
}
}
});
it("dispatches hire_agent with fixed caller context and replays its API receipt", async () => {
const previousEnabled = process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
const previousCompanies = process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
const previousSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
process.env.PAPERCLIP_AGENT_JWT_SECRET = "hire-agent-test-secret";
process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true";
process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS = companyId;
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ id: randomUUID(), status: "active" }), {
status: 201,
headers: { "content-type": "application/json" },
}),
);
try {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
apiUrl: "http://runner-test.invalid",
});
const call = {
tool: "hire_agent",
callId: "hire-agent-receipt",
arguments: {
name: "QA teammate",
role: "qa",
title: "Quality lead",
capabilities: "Test native workflows",
instructions: "Use the assigned workspace and report findings.",
},
} as const;
const first = await authority.execute(call);
expect(first).toMatchObject({ ok: true, status: 201, apiOperationId: "POST /api/companies/{companyId}/agent-hires" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe(`http://runner-test.invalid/api/companies/${companyId}/agent-hires`);
const body = JSON.parse(String(request.body));
expect(body).toMatchObject({
name: "QA teammate",
role: "qa",
title: "Quality lead",
capabilities: "Test native workflows",
adapterType: "paperclip_runner",
inheritRuntimeFrom: "caller",
reportsTo: agentId,
sourceIssueId: issueId,
instructionsBundle: { entryFile: "AGENTS.md", files: { "AGENTS.md": "Use the assigned workspace and report findings." } },
});
expect(body).not.toHaveProperty("adapterConfig");
expect(body).not.toHaveProperty("runtimeConfig");
expect(body).not.toHaveProperty("env");
await expect(authority.execute(call)).resolves.toEqual(first);
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
fetchMock.mockRestore();
if (previousEnabled === undefined) delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
else process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = previousEnabled;
if (previousCompanies === undefined) delete process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS;
else process.env.PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS = previousCompanies;
if (previousSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = previousSecret;
}
});
it("dispatches cross-task Markdown documents that pass route validation and persist revisions", async () => {
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "document-test-secret");
const taskId = randomUUID();
await db.insert(issues).values({ id: taskId, companyId, title: "Cross-task document target", status: "todo" });
const service = documentService(db);
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, request) => {
expect(String(url)).toBe(`http://runner-test.invalid/api/issues/${taskId}/documents/notes`);
expect(new Headers(request?.headers).get("authorization")).toMatch(/^Bearer /);
// Use the real route validator and persistence service, so a missing
// required format or stale base revision cannot be hidden by a spy.
const body = upsertIssueDocumentSchema.parse(JSON.parse(String(request?.body)));
const saved = await service.upsertIssueDocument({ issueId: taskId, key: "notes", ...body,
createdByAgentId: agentId, createdByRunId: runId });
return new Response(JSON.stringify(saved.document), { status: 200, headers: { "content-type": "application/json" } });
});
try {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId,
apiToolsEnabled: true, apiUrl: "http://runner-test.invalid" });
const first = { tool: "write_task_document", callId: "cross-task-document-create", arguments: {
taskId, key: "notes", title: "Notes", body: "# First revision", baseRevisionId: null,
} };
expect(await authority.execute(first)).toMatchObject({ ok: true, status: 200 });
const saved = await service.getIssueDocumentByKey(taskId, "notes");
expect(saved).toMatchObject({ format: "markdown", body: "# First revision" });
expect(await authority.execute({ ...first, callId: "cross-task-document-update", arguments: {
...first.arguments, body: "# Second revision", baseRevisionId: saved!.latestRevisionId,
} })).toMatchObject({ ok: true, status: 200 });
expect(await service.getIssueDocumentByKey(taskId, "notes")).toMatchObject({ body: "# Second revision" });
expect(await service.listIssueDocumentRevisions(taskId, "notes")).toHaveLength(2);
const tooLong = await authority.execute({ ...first, callId: "cross-task-document-long-title", arguments: {
...first.arguments, title: "x".repeat(201),
} });
expect(tooLong).toMatchObject({ outcome: "failed", code: "runner_bridge_invalid_arguments" });
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
fetchMock.mockRestore();
const saved = await service.getIssueDocumentByKey(taskId, "notes");
if (saved) await db.delete(documents).where(eq(documents.id, saved.id));
await db.delete(issues).where(eq(issues.id, taskId));
}
});
it("advertises structured human input in ask mode", () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode: "ask",
});
expect(authority.definitions().map((tool) => tool.name)).toContain(
"request_human_input",
);
expect(authority.definitions().map((tool) => tool.name)).not.toContain(
"create_task",
);
expect(authority.definitions().map((tool) => tool.name)).not.toContain(
"set_dependencies",
);
});
it.each(["standard", "ask"] as const)(
"advertises real task-bound questions and provider-dependent controls in %s mode",
(workMode) => {
const original = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(
(tool) => tool.operationId === "request_human_input",
)!;
const originalSnapshot = structuredClone(original);
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode,
});
const advertised = JSON.parse(
JSON.stringify(authority.definitions()),
).find((tool: { name: string }) => tool.name === "request_human_input");
expect(advertised.description).toContain(
"current Paperclip task bound to this run",
);
expect(advertised.description).toContain(
"one complete payload.questionSet",
);
expect(advertised.description).toContain(
"Paperclip renders it and authenticates the response",
);
expect(advertised.description).toContain(
"Preserve existing review gates",
);
expect(advertised.description).not.toContain("mock");
expect(advertised.description).not.toContain("questionSpec");
expect(advertised.inputSchema).toEqual(original.inputSchema);
expect(advertised.inputSchema.properties).toHaveProperty("payload");
expect(advertised.inputSchema.properties).not.toHaveProperty(
"questionSpec",
);
expect(original).toEqual(originalSnapshot);
},
);
it.each(["choice", "text", "mixed"] as const)("executes the advertised %s question once on the bound reviewed task", async (answerMode) => {
const binding = {
companyId: randomUUID(),
agentId: randomUUID(),
issueId: randomUUID(),
runId: randomUUID(),
};
await db.insert(companies).values({
id: binding.companyId,
name: "Question invocation",
issuePrefix: answerMode === "choice" ? "RQA" : answerMode === "text" ? "RQT" : "RQM",
});
await db.insert(agents).values({
id: binding.agentId,
companyId: binding.companyId,
name: "Question agent",
adapterType: "paperclip_runner",
status: "active",
});
await db.insert(issues).values({
id: binding.issueId,
companyId: binding.companyId,
title: "Question on reviewed task",
status: "in_review",
workMode: "standard",
reviewPolicy: "human_only",
assigneeAgentId: binding.agentId,
});
await db.insert(heartbeatRuns).values({
id: binding.runId,
companyId: binding.companyId,
agentId: binding.agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: binding.issueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId: binding.issueId },
});
await db
.update(issues)
.set({ executionRunId: binding.runId })
.where(eq(issues.id, binding.issueId));
const authority = new PaperclipRunnerToolAuthority(db, binding);
const advertised = authority
.definitions()
.find((tool) => tool.name === "request_human_input")!;
expect(advertised.description).toContain("payload.questionSet");
const questions = [
{
id: "color",
prompt: "Choose one color",
selectionMode: "single",
required: true,
options: [
{ id: "amber", label: "Amber" },
{ id: "cobalt", label: "Cobalt" },
],
},
];
const payloadDescription = (advertised.inputSchema as {
properties: { payload: { description: string } };
}).properties.payload.description;
expect(payloadDescription).toContain("at least two meaningful options");
expect(payloadDescription).toContain("questionSet");
expect(payloadDescription).not.toContain("use exactly");
const payload = answerMode === "choice"
? { version: 1, questions }
: {
version: 1,
questionSet: {
schema: "paperclip.question_set.v1",
questions: [
{ id: "goal", prompt: "What should we accomplish?", answerMode: "text", required: true },
...(answerMode === "mixed" ? [{ id: "color", prompt: "Choose one color", answerMode: "single_select", required: true, options: questions[0].options }] : []),
],
},
};
const call = {
tool: "request_human_input",
callId: "advertised-question",
arguments: {
idempotencyKey: "advertised-question",
interactionKind: "questions",
title: "Choose one color",
prompt: "Choose one color",
continuationPolicy: "wake_assignee",
payload,
},
};
const first = await authority.execute(call);
expect(first).toMatchObject({
disposition: "applied",
interaction: {
companyId: binding.companyId,
issueId: binding.issueId,
sourceRunId: binding.runId,
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
payload,
},
});
await expect(
authority.execute({ ...call, callId: "advertised-question-replay" }),
).resolves.toEqual(first);
const rows = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.issueId, binding.issueId));
expect(rows).toHaveLength(1);
expect((rows[0].payload as { questions: { id: string }[] }).questions.map((question) => question.id))
.toEqual(answerMode === "choice" ? ["color"] : answerMode === "text" ? ["goal"] : ["goal", "color"]);
const [task] = await db
.select()
.from(issues)
.where(eq(issues.id, binding.issueId));
expect(task).toMatchObject({
status: "in_review",
reviewPolicy: "human_only",
assigneeAgentId: binding.agentId,
executionRunId: binding.runId,
});
const entries = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, binding.issueId));
expect(
entries.filter(
(entry) => entry.action === "issue.thread_interaction_created",
),
).toHaveLength(1);
});
it("does not project a foreign-company task through approval context", async () => {
const foreignCompanyId = "00000000-0000-4000-8000-000000000211";
const foreignIssueId = "00000000-0000-4000-8000-000000000212";
const approvalId = "00000000-0000-4000-8000-000000000213";
await db.insert(companies).values({
id: foreignCompanyId,
name: "Foreign approval company",
issuePrefix: "FAC",
issueCounter: 1,
});
await db.insert(issues).values({
id: foreignIssueId,
companyId: foreignCompanyId,
issueNumber: 1,
identifier: "FAC-1",
title: "Must not cross the approval boundary",
status: "todo",
});
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "runner_review",
status: "pending",
payload: {},
});
// The schema deliberately stores companyId independently on the link. A
// corrupt or historical cross-tenant link must still fail closed at read.
await db.insert(issueApprovals).values({
companyId,
approvalId,
issueId: foreignIssueId,
linkedByAgentId: agentId,
});
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
await expect(
authority.execute({
tool: "get_approval_context",
callId: "foreign-approval-context",
arguments: { approvalId },
}),
).resolves.toMatchObject({ approval: { id: approvalId }, tasks: [] });
});
it("rejects Dot artifact publication from instance state before reading a file", async () => {
const [actor] = await db.select().from(agents).where(eq(agents.id, agentId));
await db.update(agents).set({ adapterConfig: { ...actor.adapterConfig, dotWorkspaceAccess: true } }).where(eq(agents.id, agentId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId,
dotRuntime: true, workspaceBridge: true, workspaceRoot: "/tmp/fixture-workspace" });
try {
for (const contentRef of [".paperclip/.env", "nested/.PaPeRcLiP/config.json"]) {
await expect(authority.execute({ tool: "register_deliverable", callId: contentRef, arguments: {
idempotencyKey: contentRef, filename: "config.txt", title: "Config", contentType: "text/plain",
byteSize: 10, sha256: "0".repeat(64), contentRef,
} })).rejects.toThrow("runner_workspace_instance_state_denied");
}
} finally {
await db.update(agents).set({ adapterConfig: actor.adapterConfig }).where(eq(agents.id, agentId));
}
});
it("fits large assigned catalogs alongside workspace and completion tools without dropping task tools", async () => {
const listToolsForNamedGateway = vi.fn().mockResolvedValue(Array.from({ length: 224 }, (_, i) => ({
name: `app.action_${i}`, displayName: `Action ${i}`, description: "Read a fixture",
parametersSchema: { type: "object", properties: {} }, risk: "read",
})));
const assignedMcpTools = await createAssignedMcpTools({
gateway: { listToolsForNamedGateway } as unknown as ToolGatewayService,
gatewayPublicId: "fixture", bearerToken: "fixture-token",
});
const binding = { companyId, agentId, issueId, runId, workspaceRoot: "/tmp/fixture-workspace" };
const baseline = new PaperclipRunnerToolAuthority(db, binding).definitions();
expect(runnerCodexDynamicToolsFit([...baseline, ...assignedMcpTools.definitions()])).toBe(false);
const authority = new PaperclipRunnerToolAuthority(db, { ...binding, assignedMcpTools });
const tools = authority.definitions();
expect(runnerCodexDynamicToolsFit(tools)).toBe(true);
expect(tools).toEqual(expect.arrayContaining(baseline));
expect(tools.filter(tool => String(tool.name).startsWith("app_"))).toEqual([]);
expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([
"paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "register_deliverable",
]));
const call = { tool: "paperclip_search_assigned_tools", callId: "discover", arguments: { query: "Action 223" } };
await expect(authority.execute(call)).resolves.toMatchObject({ tools: [expect.objectContaining({ description: "Action 223: Read a fixture" })] });
listToolsForNamedGateway.mockClear();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
try {
await expect(authority.execute(call)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
expect(listToolsForNamedGateway).not.toHaveBeenCalled();
} finally {
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId));
}
});
it("relays assigned MCP calls only while the native run still owns its task", async () => {
const tool = { name: "app_mem0_recall", description: "Recall memory", inputSchema: { type: "object" } };
const execute = vi.fn().mockResolvedValue({ content: "synthetic memory" });
const authority = new PaperclipRunnerToolAuthority(db, {
companyId, agentId, issueId, runId,
assignedMcpTools: { definitions: () => [tool], has: (name) => name === tool.name, execute },
});
expect(authority.definitions()).toContainEqual(tool);
const call = { tool: tool.name, callId: "assigned-mcp", arguments: { query: "compass" } };
await expect(authority.execute(call)).resolves.toEqual({ content: "synthetic memory" });
expect(execute).toHaveBeenCalledWith(call, "standard");
await db.update(issues).set({ workMode: "ask" }).where(eq(issues.id, issueId));
try {
await authority.execute(call);
expect(execute).toHaveBeenLastCalledWith(call, "ask");
} finally {
await db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, issueId));
}
execute.mockClear();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
try {
await expect(authority.execute(call)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
expect(execute).not.toHaveBeenCalled();
} finally {
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId));
}
});
it("does not advertise delegation tools during pre-acceptance planning", () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode: "planning",
});
expect(authority.definitions().map((tool) => tool.name)).not.toContain(
"create_task",
);
expect(authority.definitions().map((tool) => tool.name)).not.toContain(
"set_dependencies",
);
});
it("writes progress through the real issue service and replays idempotently", async () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
const call = {
tool: "report_progress",
callId: "progress",
arguments: { body: "Runner progress", idempotencyKey: "progress-1" },
};
const first = await authority.execute(call);
const replay = await authority.execute({
...call,
callId: "progress-replay",
});
expect(replay).toEqual(first);
expect(
await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId)),
).toHaveLength(1);
const progressActivity = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, "issue.comment_added")));
expect(progressActivity).toHaveLength(1);
expect(progressActivity[0]).toMatchObject({
action: "issue.comment_added",
actorType: "agent",
actorId: agentId,
agentId,
runId,
entityType: "issue",
entityId: issueId,
details: expect.objectContaining({
bodySnippet: "Runner progress",
identifier: "RNT-1",
issueTitle: "Exercise real runner tools",
source: "paperclip_runner_protocol",
}),
});
await expect(
authority.execute({
...call,
arguments: { body: "Changed", idempotencyKey: "progress-1" },
}),
).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("creates checkbox interactions through the real interaction service", async () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
const call = {
tool: "request_human_input",
callId: "ask-checkbox",
arguments: {
idempotencyKey: "favorite-animals",
interactionKind: "checkbox",
title: "Favorite zoo animals",
prompt: "Which zoo animals are your favorites?",
continuationPolicy: "wake_assignee",
payload: {
options: [
{ id: "giraffes", label: "Giraffes" },
{ id: "lions", label: "Lions" },
],
},
},
};
const first = await authority.execute(call);
await expect(
authority.execute({ ...call, callId: "ask-checkbox-replay" }),
).resolves.toEqual(first);
expect(first).toMatchObject({
interaction: { kind: "request_checkbox_confirmation", status: "pending" },
});
expect(
await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.issueId, issueId)),
).toHaveLength(1);
expect(
(
await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
).filter((entry) => entry.action === "issue.thread_interaction_created"),
).toHaveLength(1);
await expect(
authority.execute({
...call,
callId: "ask-checkbox-conflict",
arguments: {
...call.arguments,
prompt: "Use the same key for a different prompt.",
},
}),
).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("writes a real revisioned document and replays the mutation receipt", async () => {
const body = credentialDocumentBody;
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
const call = {
tool: "write_document",
callId: "write-plan",
arguments: {
idempotencyKey: "write-plan-1",
key: "plan",
title: "Execution plan",
body,
// Provider bridges may serialize nullable string inputs as the literal
// "null". The protocol boundary treats that as document creation.
baseRevisionId: "null",
changeSummary: "Initial plan",
},
};
const first = await authority.execute(call);
const replay = await authority.execute({
...call,
callId: "write-plan-replay",
});
expect(replay).toEqual(first);
expect(first).toMatchObject({
disposition: "applied",
created: true,
document: { key: "plan", body },
documentHref: "/RNT/issues/RNT-1#document-plan",
});
expect(
await db
.select()
.from(documents)
.where(eq(documents.companyId, companyId)),
).toHaveLength(1);
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan"))
.toMatchObject({ body: credentialDocumentBody });
const documentActivity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(
documentActivity.filter(
(entry) => entry.action === "issue.document_created",
),
).toEqual([
expect.objectContaining({
actorType: "agent",
actorId: agentId,
agentId,
runId,
entityType: "issue",
details: expect.objectContaining({
key: "plan",
source: "paperclip_runner_protocol",
}),
}),
]);
await expect(
authority.execute({
...call,
arguments: { ...call.arguments, body: "Conflicting retry." },
}),
).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("returns the exact accepted plan revision in task context", async () => {
const plan = await documentService(db).getIssueDocumentByKey(
issueId,
"plan",
);
expect(plan).not.toBeNull();
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
const requested = await authority.execute({
tool: "request_human_input",
callId: "approve-plan",
arguments: {
idempotencyKey: `confirmation:${issueId}:plan:${plan!.latestRevisionId}`,
interactionKind: "confirmation",
title: "Approve the plan",
prompt: "Approve this exact plan revision?",
payload: {
target: {
type: "issue_document",
issueId,
documentId: plan!.id,
key: "plan",
revisionId: plan!.latestRevisionId,
revisionNumber: plan!.latestRevisionNumber,
},
},
targetRevisionId: plan!.latestRevisionId,
continuationPolicy: "wake_assignee_on_accept",
},
});
expect(requested).toMatchObject({
interaction: {
kind: "request_confirmation",
status: "pending",
payload: {
target: {
type: "issue_document",
issueId,
key: "plan",
revisionId: plan!.latestRevisionId,
},
},
},
});
await db
.update(issueThreadInteractions)
.set({
status: "accepted",
resolvedByUserId: "test-user",
resolvedAt: new Date(),
result: { outcome: "accepted" } as never,
})
.where(
eq(
issueThreadInteractions.id,
(requested as { interaction: { id: string } }).interaction.id,
),
);
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
workspaceRefreshReason: "accepted_plan_confirmation",
planReviewInteraction: {
acceptedTargetRevision: {
issueId,
documentId: plan!.id,
key: "plan",
revisionId: plan!.latestRevisionId,
revisionNumber: plan!.latestRevisionNumber,
},
},
},
})
.where(eq(heartbeatRuns.id, runId));
await expect(
authority.execute({
tool: "get_task_context",
callId: "accepted-context",
arguments: {},
}),
).resolves.toMatchObject({
acceptedPlan: {
documentId: plan!.id,
revisionId: plan!.latestRevisionId,
revisionNumber: plan!.latestRevisionNumber,
markdown: credentialDocumentBody,
},
});
});
it("places create_task beside a child task, except for watchdog-scoped runs", async () => {
const rootId = randomUUID();
await db.insert(issues).values({ id: rootId, companyId, title: "Depth root", status: "todo", workMode: "standard" });
async function childRun(watchdog: boolean, watchdogStatus = "active") {
const childId = randomUUID();
const childRunId = randomUUID();
await db.insert(issues).values({
id: childId, companyId, parentId: rootId, title: `Depth child ${watchdog ? "watchdog" : "ordinary"}`,
status: "in_progress", workMode: "standard", assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: childRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: childId,
invocationSource: "assignment", triggerDetail: "system",
contextSnapshot: watchdog ? { issueId: childId, taskWatchdog: { watchedIssueId: childId } } : { issueId: childId },
});
await db.update(issues).set({ executionRunId: childRunId }).where(eq(issues.id, childId));
if (watchdog) await db.insert(issueWatchdogs).values({ companyId, issueId: childId, watchdogAgentId: agentId, status: watchdogStatus });
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: childId, runId: childRunId, workMode: "standard" });
const created = await authority.execute({
tool: "create_task",
callId: `depth-${childId}`,
arguments: { idempotencyKey: `depth-${childId}`, title: "Follow-up from the child", status: "backlog" },
}) as { task: { id: string; parentId: string | null } };
const [row] = await db.select({ originIssueId: issues.originIssueId }).from(issues).where(eq(issues.id, created.task.id));
return { childId, parentId: created.task.parentId, originIssueId: row?.originIssueId };
}
const ordinary = await childRun(false);
expect(ordinary).toMatchObject({ parentId: rootId, originIssueId: ordinary.childId });
const watchdog = await childRun(true);
expect(watchdog).toMatchObject({ parentId: watchdog.childId, originIssueId: watchdog.childId });
// A disabled watchdog no longer earns the exception.
const disabled = await childRun(true, "disabled");
expect(disabled).toMatchObject({ parentId: rootId, originIssueId: disabled.childId });
// An active watchdog may not name a parent outside its watched subtree.
const outsideId = randomUUID();
await db.insert(issues).values({ id: outsideId, companyId, title: "Outside the watched subtree", status: "todo", workMode: "standard" });
const [watchdogRun] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(eq(heartbeatRuns.nativeIssueId, watchdog.childId));
const scoped = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: watchdog.childId, runId: watchdogRun!.id, workMode: "standard" });
await expect(scoped.execute({
tool: "create_task", callId: "watchdog-outside",
arguments: { idempotencyKey: "watchdog-outside", title: "Elsewhere", status: "backlog", parentTaskId: outsideId },
})).rejects.toThrow(/watched issue subtree/);
});
it("authorizes the placed parent: a hidden root rejects a named child parent and makes the default standalone", async () => {
const hiddenRootId = randomUUID();
const openChildId = randomUUID();
const projectId = randomUUID();
const featureWorkspaceId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId, name: "Depth workspace project" });
await db.insert(projectWorkspaces).values([
{ companyId, projectId, name: "Default checkout", isPrimary: true },
{ id: featureWorkspaceId, companyId, projectId, name: "Feature checkout" },
]);
await db.insert(issues).values({
id: hiddenRootId, companyId, title: "Private root", status: "todo", workMode: "standard",
visibility: "private", privacyRootIssueId: hiddenRootId, responsibleUserId: "someone-else",
});
await db.insert(issues).values({
id: openChildId, companyId, parentId: hiddenRootId, title: "Open child", status: "in_progress",
workMode: "standard", assigneeAgentId: agentId, projectId, projectWorkspaceId: featureWorkspaceId,
});
const named = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, workMode: "standard" });
await expect(named.execute({
tool: "create_task", callId: "hidden-root-named",
arguments: { idempotencyKey: "hidden-root-named", title: "Under the open child", status: "backlog", parentTaskId: openChildId },
})).rejects.toThrow();
const childRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: childRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: openChildId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: openChildId },
});
await db.update(issues).set({ executionRunId: childRunId }).where(eq(issues.id, openChildId));
const fromChild = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: openChildId, runId: childRunId, workMode: "standard" });
const created = await fromChild.execute({
tool: "create_task", callId: "hidden-root-default",
arguments: { idempotencyKey: "hidden-root-default", title: "Default follow-up", status: "backlog" },
}) as { task: { id: string; parentId: string | null } };
expect(created.task.parentId).toBeNull();
const [row] = await db.select({ originIssueId: issues.originIssueId, projectWorkspaceId: issues.projectWorkspaceId })
.from(issues).where(eq(issues.id, created.task.id));
expect(row?.originIssueId).toBe(openChildId);
// Dropping the structural parent keeps the requester's workspace.
expect(row?.projectWorkspaceId).toBe(featureWorkspaceId);
});
it("lets a conversation create a top-level task and subtasks with parentTaskId", async () => {
const conversationId = randomUUID();
const conversationRunId = randomUUID();
await db.insert(issues).values({
id: conversationId, companyId, title: "Chat with runner agent", status: "in_progress", workMode: "standard",
assigneeAgentId: agentId, conversationAgentId: agentId, conversationUserId: "chat-user", conversationState: "active",
});
await db.insert(heartbeatRuns).values({
id: conversationRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: conversationId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: conversationId },
});
await db.update(issues).set({ executionRunId: conversationRunId }).where(eq(issues.id, conversationId));
const authority = new PaperclipRunnerToolAuthority(db, {
companyId, agentId, issueId: conversationId, runId: conversationRunId, workMode: "standard",
});
const create = async (key: string, args: Record<string, unknown>) => await authority.execute({
tool: "create_task", callId: key, arguments: { idempotencyKey: key, status: "backlog", ...args },
}) as { task: { id: string; parentId: string | null } };
const parent = await create("chat-parent", { title: "Referral launch" });
expect(parent.task.parentId).toBeNull();
const subtask = await create("chat-subtask", { title: "Landing page", parentTaskId: parent.task.id });
expect(subtask.task.parentId).toBe(parent.task.id);
const [row] = await db.select({ originIssueId: issues.originIssueId }).from(issues).where(eq(issues.id, subtask.task.id));
// Provenance names the chat whose run created it; the parent is structure.
expect(row?.originIssueId).toBe(conversationId);
// A child of a child is placed beside it.
const deeper = await create("chat-deeper", { title: "Landing copy", parentTaskId: subtask.task.id });
expect(deeper.task.parentId).toBe(parent.task.id);
await expect(create("chat-under-conversation", { title: "Under the chat", parentTaskId: conversationId }))
.rejects.toThrow(/conversation cannot be a parent/i);
});
it("creates ordinary children, preserves blockers, and deduplicates across runs", async () => {
const wakes: Array<{ agentId: string; options: Record<string, unknown> }> =
[];
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
workMode: "standard",
enqueueWakeup: async (wakeAgentId, options) => {
wakes.push({ agentId: wakeAgentId, options });
return null;
},
});
expect(authority.definitions().map((tool) => tool.name)).toContain(
"create_task",
);
const prerequisite = await authority.execute({
tool: "create_task",
callId: "create-prerequisite",
arguments: {
idempotencyKey: "ordinary-prerequisite",
title: "Prepare delegated input",
description:
"A self-contained prerequisite delegated from the active task.",
},
});
expect(prerequisite).toMatchObject({
disposition: "applied",
task: {
parentId: issueId,
status: "todo",
assigneeActorId: agentId,
},
});
expect(wakes).toHaveLength(1);
expect(wakes[0]).toMatchObject({
agentId,
options: {
reason: "issue_assigned",
payload: { parentIssueId: issueId },
},
});
const prerequisiteId = (prerequisite as { task: { id: string } }).task.id;
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, prerequisiteId)))
.toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.created", agentId, runId, companyId })]));
const dependent = await authority.execute({
tool: "create_task",
callId: "create-dependent",
arguments: {
idempotencyKey: "ordinary-dependent",
title: "Use delegated input",
blockedByTaskIds: [prerequisiteId],
},
});
expect(dependent).toMatchObject({
disposition: "applied",
scheduledWakeIds: [],
task: { parentId: issueId, status: "blocked", assigneeActorId: agentId },
});
expect(wakes).toHaveLength(1);
await expect(
issueService(db).getRelationSummaries(issueId),
).resolves.toMatchObject({
blockedBy: [],
});
await authority.execute({
tool: "set_dependencies",
callId: "wait-for-prerequisite",
arguments: {
idempotencyKey: "source-waits-for-prerequisite",
blockedByTaskIds: [prerequisiteId],
},
});
await expect(
issueService(db).getRelationSummaries(issueId),
).resolves.toMatchObject({
blockedBy: [expect.objectContaining({ id: prerequisiteId })],
});
await issueService(db).update(prerequisiteId, {
status: "done",
actorAgentId: agentId,
});
await expect(
authority.execute({
tool: "create_task",
callId: "create-dependency-ready-child",
arguments: {
idempotencyKey: "ordinary-ready-dependent",
title: "Start after completed delegated input",
blockedByTaskIds: [prerequisiteId],
},
}),
).resolves.toMatchObject({
disposition: "applied",
task: { parentId: issueId, status: "todo", assigneeActorId: agentId },
scheduledWakeIds: [expect.any(String)],
});
expect(wakes).toHaveLength(2);
const nextRunId = "00000000-0000-4000-8000-000000000106";
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, runId));
await db.insert(heartbeatRuns).values({
id: nextRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: issueId,
invocationSource: "automation",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db
.update(issues)
.set({ executionRunId: nextRunId })
.where(eq(issues.id, issueId));
const retryWakes: Array<unknown> = [];
const retryAuthority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId: nextRunId,
workMode: "standard",
enqueueWakeup: async (_wakeAgentId, options) => {
retryWakes.push(options);
return null;
},
});
await expect(
retryAuthority.execute({
tool: "create_task",
callId: "cross-run-retry",
arguments: {
idempotencyKey: "ordinary-prerequisite",
title: "Prepare delegated input",
description:
"A self-contained prerequisite delegated from the active task.",
},
}),
).resolves.toMatchObject({
disposition: "duplicate",
task: { id: prerequisiteId },
});
await expect(
retryAuthority.execute({
tool: "create_task",
callId: "cross-run-conflicting-retry",
arguments: {
idempotencyKey: "ordinary-prerequisite",
title: "Conflicting title for the same caller key",
},
}),
).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
const creationEvents = (await db.select().from(activityLog).where(eq(activityLog.entityId, prerequisiteId)))
.filter(event => event.action === "issue.created");
expect(creationEvents).toHaveLength(1);
const foreignCompanyId = "00000000-0000-4000-8000-000000000201";
const foreignAgentId = "00000000-0000-4000-8000-000000000202";
const foreignIssueId = "00000000-0000-4000-8000-000000000203";
await db.insert(companies).values({
id: foreignCompanyId,
name: "Foreign company",
issuePrefix: "FGN",
issueCounter: 1,
});
await db.insert(agents).values({
id: foreignAgentId,
companyId: foreignCompanyId,
name: "Foreign agent",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
runtimeConfig: {},
status: "active",
});
await db.insert(issues).values({
id: foreignIssueId,
companyId: foreignCompanyId,
issueNumber: 1,
identifier: "FGN-1",
title: "Foreign blocker",
status: "todo",
});
await expect(
retryAuthority.execute({
tool: "create_task",
callId: "foreign-assignee",
arguments: {
idempotencyKey: "foreign-assignee",
title: "Invalid foreign assignment",
assigneeActorId: foreignAgentId,
},
}),
).rejects.toThrow("paperclip_runner_agent_not_found");
await expect(
retryAuthority.execute({
tool: "create_task",
callId: "foreign-blocker",
arguments: {
idempotencyKey: "foreign-blocker",
title: "Invalid foreign blocker",
blockedByTaskIds: [foreignIssueId],
},
}),
).rejects.toThrow();
expect(retryWakes).toHaveLength(0);
expect(
await db.select().from(issues).where(eq(issues.parentId, issueId)),
).toHaveLength(3);
});
it("rejects mutations after reassignment, run replacement, or terminalization", async () => {
const guardedIssueId = "00000000-0000-4000-8000-000000000107";
const guardedRunId = "00000000-0000-4000-8000-000000000108";
const guardedReplacementRunId = "00000000-0000-4000-8000-000000000109";
await db.insert(issues).values({
id: guardedIssueId,
companyId,
issueNumber: 999,
identifier: "RNT-999",
title: "Guard mutation authorization",
status: "in_progress",
workMode: "standard",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: guardedRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: guardedIssueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId: guardedIssueId },
});
await db.insert(heartbeatRuns).values({
id: guardedReplacementRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
nativeIssueId: guardedIssueId,
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId: guardedIssueId },
});
await db
.update(issues)
.set({ executionRunId: guardedRunId })
.where(eq(issues.id, guardedIssueId));
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId: guardedIssueId,
runId: guardedRunId,
});
const mutation = {
tool: "report_progress",
callId: "guarded-progress",
arguments: {
body: "Must remain authorized",
idempotencyKey: "guarded-progress",
},
};
await db
.update(issues)
.set({ assigneeAgentId: null })
.where(eq(issues.id, guardedIssueId));
await expect(authority.execute(mutation)).rejects.toThrow(
"paperclip_runner_tool_binding_not_authorized",
);
await db
.update(issues)
.set({
assigneeAgentId: agentId,
executionRunId: guardedReplacementRunId,
})
.where(eq(issues.id, guardedIssueId));
await expect(
authority.execute({ ...mutation, callId: "replaced-run" }),
).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
await db
.update(issues)
.set({ executionRunId: guardedRunId })
.where(eq(issues.id, guardedIssueId));
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, guardedRunId));
await expect(
authority.execute({ ...mutation, callId: "terminal-run" }),
).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
expect(
await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, guardedIssueId)),
).toHaveLength(0);
});
it("captures delegation and approval origins before steering and preserves replay identity", async () => {
const issueId = "00000000-0000-4000-8000-000000000120";
const runId = "00000000-0000-4000-8000-000000000121";
await db.insert(issues).values({ id: issueId, companyId, title: "Identity delegation",
status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId,
status: "running", runtimeMode: "native", nativeIssueId: issueId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
await db.insert(authUsers).values(["person-a", "person-b"].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
await db.insert(companyMemberships).values(["person-a", "person-b"].map(principalId => ({ companyId, principalType: "user", principalId, status: "active", membershipRole: "operator" })));
const origin = await initializeRunIdentity(db, {
companyId, runId, issueId, responsibleUserId: "person-a", cause: "instruction",
});
const [message] = await db.insert(issueComments).values({
companyId, issueId, body: "New instruction", authorUserId: "person-b",
}).returning();
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const call = { tool: "create_task", callId: "identity-child", arguments: {
idempotencyKey: "identity-child", title: "Keep the initiating identity",
responsibleUserId: "forged-user", originIdentityContextId: "forged-context",
} };
const first = await authority.execute(call) as { task: { id: string } };
const pending = await reserveSteeredIdentity(db, { companyId, runId, issueId, messageId: message.id });
await reconcileSteeredIdentity(db, pending!);
await expect(authority.execute({ ...call, callId: "identity-child-replay" })).resolves.toEqual(first);
const [child] = await db.select().from(issues).where(eq(issues.id, first.task.id));
expect(child).toMatchObject({ originRunId: runId, originIdentityContextId: origin.id,
continuationIdentityContextId: origin.id });
const second = await authority.execute({ ...call, callId: "identity-child-b", arguments: {
idempotencyKey: "identity-child-b", title: "Use the next instruction identity",
} }) as { task: { id: string } };
const [nextChild] = await db.select().from(issues).where(eq(issues.id, second.task.id));
expect(nextChild.originIdentityContextId).toBe(pending!.id);
await authority.execute({ tool: "request_human_input", callId: "identity-approval", arguments: {
idempotencyKey: "identity-approval", interactionKind: "confirmation", title: "Approve continuation",
prompt: "Continue?", continuationPolicy: "wake_assignee", payload: {},
} });
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
expect(interactions.find((row) => row.title === "Approve continuation")?.sourceIdentityContextId).toBe(pending!.id);
});
it("fails closed once the run is no longer active", async () => {
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, runId));
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId,
});
await expect(
authority.execute({
tool: "get_task_context",
callId: "late",
arguments: {},
}),
).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
});
});
describe("deliverable tool location guidance", () => {
const binding = { companyId: "company", agentId: "agent", issueId: "issue", runId: "run", workspaceRoot: "/task-workspace", apiToolsEnabled: false };
const db = new Proxy({} as ConstructorParameters<typeof PaperclipRunnerToolAuthority>[0], {
get() { throw new Error("Tool definitions must not query the database"); },
});
it.each(["local", "remote"] as const)("directs %s workspace-only deliverables to the task cwd", (executionTargetKind) => {
const authority = new PaperclipRunnerToolAuthority(db, { ...binding, executionTargetKind,
...(executionTargetKind === "remote" ? { readRemoteWorkspaceFile: async () => Buffer.alloc(0) } : {}),
});
const tool = authority.definitions().find(tool => tool.name === "register_deliverable")!;
const description = (tool.inputSchema as { properties: { contentRef: { description: string } } }).properties.contentRef.description;
expect(description).toContain("get_workspace.cwd");
expect(description).toContain("AGENT_HOME");
expect(description).toContain("Create or copy");
});
it("preserves the granted-location reader contract", () => {
const authority = new PaperclipRunnerToolAuthority(db, { ...binding, executionTargetKind: "remote", readRemoteTaskFile: async () => Buffer.alloc(0) });
const tool = authority.definitions().find(tool => tool.name === "register_deliverable")!;
const description = (tool.inputSchema as { properties: { contentRef: { description: string } } }).properties.contentRef.description;
expect(description).toContain("absolute within a location granted to this task");
expect(description).toContain("including its agent home");
});
});