mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 08:40:01 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents need to discover connections, obtain consent, and continue from saved decisions. > - We want to reduce repeated instructions only when measured behavior supports the change. > - The existing decline tasks tell the model not to retry. One provider-decline check can pass without an explanation or an observed service counter. > - This PR adds neutral tasks and stricter saved-evidence checks before any connection instruction reduction. > - Production instructions remain unchanged. The new cells are configured, not live-qualified. ## Linked Issues or Issue Description Refs #15218. Refs #15389. **What existing behavior does this improve?** The Product E2E connection workflow evaluation and its instruction measurement provenance. **Current behavior** Some decline prompts supply the policy they intend to test. The provider-decline workflow does not require a saved post-decision explanation. Its old no-call check can use a missing fixture counter as zero. OpenCode has no connection cases in the original Everyday matrix. **Proposed behavior** Add an explicit-only suite with five connection stories on native Codex, ACPX Claude, and OpenCode. Require an explanation attributed by exact run ID after a saved decline. Observe the provider fixture counter. Preserve the original cases and grades. ## What Changed - Add fifteen configured cells with one attempt, twelve-minute deadlines, and verified 1,000-cent company and agent budget stops. - Remove procedure hints from the three new decline prompts. Keep a user-permitted explanation fallback and the existing positive controls. - Require saved decline state, one decision, unchanged connections, observed zero service calls where applicable, and a post-decision explanation from a successful run on the same task. - Add negative grader calibration and test the actual fixture budget payloads. Exclude the suite from default and generic selection. - Extend the existing full-catalog measurement source manifest with connection descriptions and schemas. Add an audit of fixed text, tool descriptions, returned instructions, and unqualified behavior. - Rebase on master `a6306ba606eb87c89b9ef0344e9fe8e0025580f9` and preserve its new Cursor suites. No production, credential, workflow, or lockfile change. ## Verification - Before rebase: Product E2E support passed 1,424 TypeScript tests and 128 Node checks. Six catalog measurement tests, repository typecheck/build, Product E2E typecheck, and exact fifteen-cell discovery passed. - The full pre-rebase repository test run was stopped when master advanced. Its partial result is not a pass. - After rebase and the review correction: repository build/typecheck, Product E2E typecheck, 1,799 TypeScript support tests (one skipped), 128 Node checks, six measurement tests, and exact fifteen-cell discovery pass. The duplicate local full-suite run was stopped incomplete after about 20 minutes once complete CI passed; no local full-suite pass is claimed. - Review found that the initial grader read `runId` instead of public `createdByRunId`. A regression calibration reproduced both rejection of valid public comments and acceptance of the wrong alias. The fix uses the actual field and binds the evidence type to the shared `IssueComment` contract. A subsequent type-only import path correction passes Product E2E typecheck. - Final source `0de306b9664bfbdebb6709ddb54c95152740d1ad` passes [complete CI](https://github.com/paperclipai/paperclip/actions/runs/37560250545): 51 successful checks and two intentional Storybook skips, plus separate Snyk success. Fresh Greptile review is 5/5 with the single review thread resolved and no new findings. The PR is clean and mergeable. - Local commands: `pnpm build`, `pnpm -r typecheck`, `pnpm test:e2e:runner:unit`, `pnpm test:e2e:runner:typecheck`, and `pnpm test:e2e:runner -- --list --suite native-connection-guidance`. The measurement uses `PAPERCLIP_NATIVE_PROCEDURE_MEASUREMENT=/tmp/connection-measurement.json pnpm exec vitest run --project @paperclipai/server server/src/__tests__/native-procedure-measurement.test.ts`. Validation used pinned pnpm 9.15.4. - No paid provider campaign was started. There is no baseline/candidate behavior result for these new cells. - The audit records 654 UTF-8 bytes of fixed connection guidance. A clean capture at `a04b8c6a452315625014888335d45670a2094fb6` confirms 41 supplied tools, 53,341 normalized bytes at start/resume, 50,949 at compact continuation, and a 48,195-byte authenticated OpenCode MCP catalog. These are byte counts, not tokens, bills, vendor-private prompt sizes, or savings from this PR. ## Risks - This is eval preparation. Passing support tests do not establish live model behavior or qualify an instruction reduction. - The explanation oracle checks attributed saved output. It does not prove cognition or arbitrary prose truthfulness. One saved interaction also does not prove the absence of repeated idempotent tool calls. - Successful new authentication and tool refresh, existing-connection agent grants, independent work while waiting, explicit retry after decline, and blocking when mandatory work remains still need separate coverage. - Notion setup decline does not execute a real Notion service. Positive service approval uses an already installed deterministic service; it does not qualify new connection creation. - The original historical failures remain unchanged. Future comparisons must freeze source, fixture, model, input, and grading controls and retain every actual attempt. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, and code execution. The exact serving model ID and context-window size were not exposed in this session; they are not inferred. No model provider was invoked by the eval suite in this PR preparation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
165 lines
13 KiB
TypeScript
165 lines
13 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInput } from "../../../packages/paperclip-runner/src/contracts/native-execution.js";
|
|
import type { PersistedNativeSession } from "../../../packages/paperclip-runner/src/contracts/native-session-backend.js";
|
|
import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, canonicalNativeRuntimeContextDigest, nativeRuntimePromptDigest } from "../../../packages/paperclip-runner/src/contracts/runtime-context.js";
|
|
import { createRunnerdNativeSessionBackend } from "../../../packages/paperclip-runner/src/backends/codex-native-backend.js";
|
|
import { resolveQualifiedAcpxProfile } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
|
|
import { FakeCodexTransport, WORKSPACE } from "../../../packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test-support.js";
|
|
import { startOpenCodeMcpBridge } from "../../../packages/paperclip-runner/src/drivers/opencode/mcp-bridge.js";
|
|
|
|
// Production catalog selection, scripted provider transport: this measures what
|
|
// Paperclip delivers, not the vendor's private prompt, tokenizer or billing.
|
|
const root = new URL("../../../", import.meta.url);
|
|
const sha = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
|
const identity = { companyId: "company", agentId: "agent", issueId: "issue", runId: "run", sessionId: "session" };
|
|
const tools = new PaperclipRunnerToolAuthority({} as Db, {
|
|
...identity, apiToolsEnabled: true, workspaceRoot: WORKSPACE,
|
|
}).definitions();
|
|
const { qualificationModel: _model, reportedModelId: _reportedModel, permissionPolicy: _policy, ...profile } = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5");
|
|
const providers: NativeExecutionInput["provider"][] = [
|
|
{ kind: "codex", model: "gpt-5.6-sol", approvalPolicy: "never" },
|
|
{ kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionMode: "approve-all", profile },
|
|
{ kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "allow" },
|
|
];
|
|
const receipts: unknown[] = [];
|
|
let mcpReceipt: unknown;
|
|
function measure(value: unknown) {
|
|
const json = JSON.stringify(value);
|
|
const normalized = json.replaceAll(WORKSPACE, "/workspace");
|
|
return {
|
|
utf8Bytes: Buffer.byteLength(json), characters: json.length, sha256: sha(json),
|
|
normalizedComparison: { utf8Bytes: Buffer.byteLength(normalized), characters: normalized.length, sha256: sha(normalized) },
|
|
};
|
|
}
|
|
function execution(provider: NativeExecutionInput["provider"]): NativeExecutionInput {
|
|
const entry = readFileSync(new URL("server/src/onboarding-assets/default/AGENTS.md", root), "utf8");
|
|
writeFileSync(`${WORKSPACE}/AGENTS.md`, entry);
|
|
const context = {
|
|
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
|
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, rootPath: WORKSPACE, digest: sha(entry), manifestDigest: sha(entry), fileCount: 1, totalBytes: Buffer.byteLength(entry) } },
|
|
skills: [], mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null },
|
|
};
|
|
return parseNativeExecutionInput({
|
|
schema: "paperclip.native-execution-input.v5", binding: { companyId: identity.companyId, agentId: identity.agentId, issueId: identity.issueId, runId: identity.runId, executionWorkspaceId: "workspace" },
|
|
task: { identifier: "TASK-1", title: "Review delegated work", description: "Review delegated work", prompt: "Review delegated work.", workMode: "standard" },
|
|
workspace: { cwd: WORKSPACE, repoUrl: null, repoRef: null, branchName: null },
|
|
session: { normalizedSessionId: "session", driverKind: provider.kind === "acpx" ? "acpx_runtime" : provider.kind === "opencode" ? "opencode_server" : "codex_app_server", protocolVersion: 1 },
|
|
provider, executionMode: "default", planningContext: null,
|
|
completionContract: { id: "contract", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.completion-contract.v1", contract: { revision: "1", objective: "Review delegated work", criteria: [{ id: "objective", requirement: "Review complete" }] } },
|
|
interactionResponses: [], credentialBindings: [],
|
|
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
|
});
|
|
}
|
|
function transport(provider: NativeExecutionInput["provider"]) {
|
|
return new FakeCodexTransport("thread-1", "provider-session-1", provider.kind === "acpx" ? {
|
|
kind: "acpx", normalizedSessionId: "session", acpxRecordId: "fixture-record", backendSessionId: "fixture-backend",
|
|
agentSessionId: "fixture-agent", profileDigest: `sha256:${"a".repeat(64)}`, workspaceDigest: `sha256:${"b".repeat(64)}`,
|
|
requestedModel: provider.model, effectiveModel: provider.model, permissionMode: "approve-all",
|
|
providerLifetimeFenceCandidates: [60001, 60002, 60003],
|
|
} : undefined);
|
|
}
|
|
|
|
describe("production native procedure instruction measurement", () => {
|
|
it("retains delivered serialized bytes separately from path-normalized comparison", () => {
|
|
const value = { cwd: WORKSPACE, text: "λ" };
|
|
expect(measure(value).utf8Bytes).toBe(Buffer.byteLength(JSON.stringify(value)));
|
|
expect(measure(value).normalizedComparison.utf8Bytes).toBe(Buffer.byteLength(JSON.stringify({ ...value, cwd: "/workspace" })));
|
|
expect(measure(value).sha256).not.toBe(measure(value).normalizedComparison.sha256);
|
|
});
|
|
|
|
it("uses the real standard-mode authority, including optional and connection tools", () => {
|
|
expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([
|
|
"hire_agent", "list_agents", "create_task", "set_dependencies", "connections_search", "connection_request", "register_deliverable",
|
|
]));
|
|
expect(new Set(tools.map(tool => tool.name)).size).toBe(tools.length);
|
|
const withoutApi = new PaperclipRunnerToolAuthority({} as Db, { ...identity, apiToolsEnabled: false }).definitions();
|
|
expect(withoutApi.some(tool => tool.name === "hire_agent")).toBe(false);
|
|
});
|
|
|
|
for (const provider of providers) it(`captures the full ${provider.kind} start/resume/continuation payload`, async () => {
|
|
const input = execution(provider);
|
|
let checkpoint: PersistedNativeSession | undefined;
|
|
for (const phase of ["start", "resume", "continuation"] as const) {
|
|
const captured = transport(provider);
|
|
captured.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, turns: [] } };
|
|
const current = phase === "continuation" ? parseNativeExecutionInput({ ...input, continuationPrompt: "Review the teammate's saved result." }) : input;
|
|
const backend = createRunnerdNativeSessionBackend(current, { transportFactory: () => captured, dynamicTools: tools, environment: { PAPERCLIP_WORKSPACE_CWD: WORKSPACE } });
|
|
const recovery = checkpoint ? await backend.recoverSession!(checkpoint, { signal: new AbortController().signal }) : null;
|
|
if (recovery) expect(recovery.recovered).toBe(true);
|
|
const session = recovery?.session ?? await backend.openSession({ identity, workingDirectory: WORKSPACE });
|
|
try {
|
|
checkpoint ??= await session.snapshot();
|
|
const envelope = buildNativeModelEnvelope(current, phase === "continuation" ? { resumedSession: true } : undefined);
|
|
if ("requestedSkills" in envelope && backend.preparedTaskConstraints) envelope.constraints = [...backend.preparedTaskConstraints];
|
|
await session.startTurn({ message: { role: "user", text: JSON.stringify(envelope) } });
|
|
const setup = captured.calls.find(call => call.method === (phase === "start" ? "thread/start" : "thread/resume"))!.params;
|
|
const turn = captured.calls.find(call => call.method === "turn/start")!.params;
|
|
const instructions = setup.developerInstructions ?? setup.baseInstructions;
|
|
const delivered = setup.dynamicTools as Record<string, unknown>[];
|
|
expect(delivered.filter(tool => !["paperclip_finish", "paperclip_block"].includes(String(tool.name)))).toEqual(tools);
|
|
expect(delivered).toHaveLength(tools.length + 2);
|
|
expect(instructions).toContain(PAPERCLIP_EXECUTION_PROMPT);
|
|
expect(JSON.parse((turn.input as {text: string}[])[0]!.text)).toEqual(envelope);
|
|
receipts.push({ provider: provider.kind, phase, instructions: measure(instructions), tools: measure(delivered), input: measure(turn.input),
|
|
fullProjection: measure({ instructions, tools: delivered, input: turn.input }), deliveredToolCount: delivered.length });
|
|
} finally { await session.close({ reason: "measurement" }); }
|
|
}
|
|
});
|
|
|
|
it("preserves the full catalog through the actual OpenCode MCP tools/list bridge", async () => {
|
|
const bridge = await startOpenCodeMcpBridge({ tools, handler: async () => { throw new Error("No tool execution in measurement"); } });
|
|
try {
|
|
const response = await fetch(bridge.url, { method: "POST", headers: { authorization: `Bearer ${bridge.secret}`, "content-type": "application/json" },
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) });
|
|
expect(response.ok).toBe(true);
|
|
const result = await response.json() as { result: { tools: Record<string, unknown>[] } };
|
|
expect(result.result.tools.filter(tool => !["paperclip_finish", "paperclip_block"].includes(String(tool.name)))).toEqual(
|
|
tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
);
|
|
mcpReceipt = { boundary: "authenticated loopback MCP tools/list; no OpenCode/provider process", serverAnnotationsForwarded: false, tools: measure(result.result.tools), count: result.result.tools.length };
|
|
} finally { await bridge.close(); }
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
const output = process.env.PAPERCLIP_NATIVE_PROCEDURE_MEASUREMENT;
|
|
if (!output) return;
|
|
if (receipts.length !== 9 || !mcpReceipt) throw new Error("Refusing incomplete instruction measurement");
|
|
const paths = [
|
|
"server/src/onboarding-assets/default/AGENTS.md",
|
|
"packages/paperclip-runner/src/contracts/runtime-context.ts",
|
|
"packages/paperclip-runner/src/protocol-actions/hire-agent.ts",
|
|
"packages/paperclip-runner/src/protocol-actions/create-task.ts",
|
|
"packages/paperclip-runner/src/protocol-actions/set-dependencies.ts",
|
|
"packages/paperclip-runner/src/catalog/semantic-action-catalog.ts",
|
|
"packages/paperclip-runner/generated/semantic-action-catalog.json",
|
|
"packages/paperclip-runner/generated/capability/semantic-tool-contracts.json",
|
|
"packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json",
|
|
"packages/paperclip-runner/protocol/manifest.json",
|
|
"packages/paperclip-runner/src/backends/codex-native-backend.ts",
|
|
"packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts",
|
|
"server/src/services/native-runtime/paperclip-runner-tool-authority.ts",
|
|
"server/src/services/connection-tool-definitions.ts",
|
|
"packages/shared/src/connection-intent-guidance.ts",
|
|
"packages/shared/src/validators/connection-intent.ts",
|
|
"server/src/services/native-runtime/native-session-resume.ts",
|
|
"server/src/__tests__/native-procedure-measurement.test.ts",
|
|
];
|
|
writeFileSync(output, `${JSON.stringify({ schema: "paperclip.native-procedure-measurement.v2",
|
|
sourceSha: execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(),
|
|
sourceDirty: execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim().length > 0,
|
|
measuredInputsDirty: execFileSync("git", ["status", "--porcelain", "--", ...paths], { cwd: root, encoding: "utf8" }).trim().length > 0,
|
|
sourceHashes: Object.fromEntries(paths.map(path => [path, sha(readFileSync(new URL(path, root)))])),
|
|
boundary: "scripted runnerd RPC with production standard-mode authority, API tools enabled, local workspace, no assigned external apps",
|
|
byteAccounting: "utf8Bytes counts the unmodified JSON serialization of each extracted component/projection; normalizedComparison substitutes only the fixture workspace path. Neither is the entire transport request.",
|
|
providerCalls: 0, tokenCount: null, upstreamLoadingOrTruncation: "unverified", vendorStockPrompt: "unavailable",
|
|
toolDescriptions: tools.map(tool => ({ name: tool.name, characters: String(tool.description).length, utf8Bytes: Buffer.byteLength(String(tool.description)), inputSchema: measure(tool.inputSchema) })),
|
|
receipts, mcpReceipt,
|
|
}, null, 2)}\n`);
|
|
});
|