mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Add the external Runner foundation with distinct Dot/Grok V6, canonical home-mode V7 and Muse V8 contracts. Keep generated schemas and profiles synchronized. Bound default question-answer command IDs in a disjoint namespace while preserving existing IDs and durable replay. Preserve canonical migration history and add normally generated, replay-safe Muse schema milestones with literal preview-history fixtures. Refs #16444. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1528 lines
61 KiB
TypeScript
1528 lines
61 KiB
TypeScript
import { connectorToolDefinitionsForSkills } 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 { 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,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-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 { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js";
|
|
import { buildLowTrustSourceTrust, buildPromotedSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../source-trust.js";
|
|
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
describe("PaperclipRunnerToolAuthority", () => {
|
|
const credentialDocumentBody = "Use a secret manager for credential handling.\nAuthorization: Bearer intentional-document-credential";
|
|
let temporary: Awaited<
|
|
ReturnType<typeof startEmbeddedPostgresTestDatabase>
|
|
> | 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 startEmbeddedPostgresTestDatabase(
|
|
"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("registers pinned connector definitions without discovery and rejects revoked assignments at use", async () => {
|
|
const connectorAssignments = 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(48);
|
|
expect(authority.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([
|
|
"get_workspace", "list_workspaces", "select_workspace", "prepare_repository",
|
|
]));
|
|
expect(authority.definitions().map(tool => tool.name)).not.toContain("read_chat_attachment");
|
|
expect(authority.definitions().map(tool => tool.name)).not.toContain("read_current_wake_comments");
|
|
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");
|
|
});
|
|
|
|
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("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("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");
|
|
});
|
|
});
|