mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents receive task constraints and completion tools from Paperclip. > - Completion tools already define the procedure for reporting a result. > - Repeated procedure text adds instructions to each full task turn. > - The final reply must still explain a blocker and link a saved document. > - This pull request removes repeated procedure text and keeps these visible outcome requirements explicit. > - A document receipt supplies the exact link, and stricter evals check the persisted reply and browser navigation. ## Linked Issues or Issue Description Refs: #14961. Related: #14948 and #15007. **What happened?** Native task envelopes repeat completion procedure text. A reduced envelope needs explicit final-reply requirements. The `write_document` receipt also lacks a canonical document link. **Expected behavior** Keep the completion tools as the source of procedure details. Require one accepted completion result before the final reply. A blocked reply must explain the reason, owner and unblock action. A document reply must contain a working link to the saved document. **Steps to reproduce** 1. Run the native assigned-skill document case and native blocker case. 2. Inspect the run-attributed provider final and its persisted comment. 3. Check the blocker explanation or open the final reply's document link. ## What Changed - Remove repeated completion procedure text from the native task constraints and backend instructions. - Keep explicit blocker and document-link requirements in full task turns. - Return a company/task-scoped `documentHref` from `write_document`. Preserve the link in the idempotent mutation receipt. - Repeat canonical links for this run's current saved revisions in accepted completion feedback. Give blocked providers final-response guidance for the cause, owner and unblock action. - Keep internal document/comment anchors when Markdown issue links load cached issue details. - Add a manual six-cell comparison suite with strict source, build, default-instruction and budget admission. - Capture eighteen shared runnerd RPC projections and six direct OpenCode HTTP projections across start, resume and continuation phases, using scripted local transports and no provider execution. - Apply v3 checks only to the manual instruction comparison; preserve v2 checks for the existing native completion suite. Check the actual persisted blocker reason and exact saved-document link. Click the rendered document link and check the original content marker in the classic document card or the new document tab. - Forward exact OpenCode finishing calls through the controller. Wait for acceptance, keep accepted feedback and concrete rejection text, and reject malformed responses. Preserve ordinary dynamic-tool response handling. - Settle the completion decision and tool response before mapping a racing idle/error/abort event or handling explicit close/interruption. Reject a concurrent finishing call before controller admission. - Add a provider-free regression through real runnerd, the OpenCode proxy and a fake provider. Reject the first completion, accept the corrected report in the same turn, and propose one result. - Keep all original verdicts unchanged. Treat replay under new checks as separate diagnostics. ## Verification - `pnpm -r typecheck` and `pnpm build` pass locally. - Native document-authority tests pass, including company/run authorization and idempotent replay. - Native runtime-context, backend and measurement tests pass. - Final-answer calibration, protocol scoring, source-admission and catalog tests pass. Wrong reasons, absent links and wrong link targets fail. - `pnpm test:e2e:runner:typecheck` passes. Discovery lists exactly six single-attempt local cells with the declared models. - Exported `prepareNativeInstructionPreflight` then `verifyNativeInstructionPreflight` pass on this clean committed source. They build locally and make zero provider calls. - Corrective live confirmation is incomplete. Source3a7349dpassed both Claude and both Codex cases. OpenCode saved the correct document but omitted its final link; its blocker case was canceled before paid execution. Preserve this failure. Thee171282confirmation was stopped during build after fresh review found a completion-settlement race; it executed zero providers. Sourcec3e0cb303fixes that race. Two affected OpenCode cases await fresh review and one bounded confirmation; earlier results remain attributed to their original source. - OpenCode proxy parsing, driver, factory and input tests: 81 pass across retained focused runs, including six settlement races. Evaluator/scoring/admission checks: 122 pass. The real proxy regression passes. Fresh local prepare then verify passes with 18 shared and 6 direct scripted captures, fresh SDK/Rust builds and zero providers. - The full local suite recorded two failures: a webhook timeout and a Git-scan load count mismatch. Both files pass in isolation with unchanged assertions/time budgets; preserve the original failure log. Freshc3e0cb303CI and review are pending. This PR remains draft. ## Risks - Final-answer wording can vary by provider. The checks cover the declared release-access blocker and saved document fixture, not general answer quality. - A single trial does not establish general equivalence, cause, speed, cost or live resume behavior. - `documentHref` is an additive receipt field. It points to the current saved document, not an immutable historical revision. Replaying an older receipt does not fabricate a new link. - The correction adds four production paths for document receipts, accepted completion feedback and UI navigation, plus four OpenCode controller/proxy paths, beyond the original three instruction paths. Completion rejection must remain repairable; the production-boundary regression covers it. - Preserve the frozen comparison context for live measurement. A merge-tree check against current master is clean. Do not relabel earlier live results as results from a later source tree. ## Model Used - OpenAI Codex, GPT-6 family. The exact serving model ID and context window are unavailable in this session. Capabilities used: reasoning, code editing, shell execution, test authoring and evidence review. ## 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 - [ ] 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 - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
132 lines
9.2 KiB
TypeScript
132 lines
9.2 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { runnerSuites, buildRunnerMatrix } from "./catalog.js";
|
|
import { gradeNativeDefault, NATIVE_MASTER_DEFAULT_SHA256 } from "./native-completion-defaults.js";
|
|
import { nativeCompletionTasks } from "./native-completion-cases.js";
|
|
import { assertNativeInstructionLineage, assertNativeInstructionSelection, nativeInstructionVariant, validateNativeInstructionMeasurement, NATIVE_INSTRUCTION_BASE_SHA, NATIVE_INSTRUCTION_DEFAULT_SHA256, NATIVE_INSTRUCTION_SUITE, NATIVE_INSTRUCTION_VARIANTS } from "./native-instruction-consolidation.js";
|
|
|
|
describe("native instruction comparison admission", () => {
|
|
it("proves real hosted ancestry when the source is more than eight commits from the base", () => {
|
|
const temporary = mkdtempSync(join(tmpdir(), "native-instruction-lineage-"));
|
|
const source = join(temporary, "source");
|
|
const checkout = join(temporary, "checkout");
|
|
const environment = { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid",
|
|
GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" };
|
|
const fixtureGit = (cwd: string, args: string[]) => execFileSync("git", args, {
|
|
cwd, env: environment, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
|
|
}).trim();
|
|
try {
|
|
fixtureGit(temporary, ["init", "--quiet", source]);
|
|
writeFileSync(join(source, "source.txt"), "Immutable source fixture\n");
|
|
fixtureGit(source, ["add", "source.txt"]);
|
|
const tree = fixtureGit(source, ["write-tree"]);
|
|
const base = fixtureGit(source, ["commit-tree", tree, "-m", "base"]);
|
|
let head = base;
|
|
for (let index = 0; index < 12; index++)
|
|
head = fixtureGit(source, ["commit-tree", tree, "-p", head, "-m", `source ${index}`]);
|
|
fixtureGit(source, ["update-ref", "refs/heads/fixture", head]);
|
|
fixtureGit(temporary, ["clone", "--quiet", "--depth=1", "--branch=fixture", pathToFileURL(source).href, checkout]);
|
|
const run = (...args: string[]) => fixtureGit(checkout, ["--no-replace-objects", ...args.map(value =>
|
|
value === NATIVE_INSTRUCTION_BASE_SHA ? base
|
|
: value === "https://github.com/paperclipai/paperclip.git" ? pathToFileURL(source).href : value)]);
|
|
expect(() => run("merge-base", "--is-ancestor", NATIVE_INSTRUCTION_BASE_SHA, head)).toThrow();
|
|
expect(() => assertNativeInstructionLineage(head, run, true)).not.toThrow();
|
|
expect(run("rev-parse", "HEAD")).toBe(head);
|
|
expect(() => run("merge-base", "--is-ancestor", NATIVE_INSTRUCTION_BASE_SHA, head)).not.toThrow();
|
|
} finally {
|
|
rmSync(temporary, { recursive: true, force: true });
|
|
}
|
|
});
|
|
it("hydrates bounded hosted history and still requires exact HEAD and real base ancestry", () => {
|
|
const head = "a".repeat(40);
|
|
for (const scenario of ["valid", "changed-head", "no-base", "local", "not-shallow"]) {
|
|
let hydrated = false;
|
|
const calls: string[][] = [];
|
|
const run = (...args: string[]) => {
|
|
calls.push(args);
|
|
if (args[0] === "merge-base") {
|
|
if (!hydrated || scenario === "no-base") throw new Error("no declared ancestor");
|
|
return "";
|
|
}
|
|
if (args[1] === "--is-shallow-repository") return scenario === "not-shallow" ? "false" : "true";
|
|
if (args.includes("fetch")) { hydrated = true; return ""; }
|
|
return scenario === "changed-head" ? "b".repeat(40) : head;
|
|
};
|
|
if (scenario === "valid") expect(() => assertNativeInstructionLineage(head, run, true)).not.toThrow();
|
|
else expect(() => assertNativeInstructionLineage(head, run, scenario !== "local")).toThrow();
|
|
const fetches = calls.filter(args => args.includes("fetch"));
|
|
const depths = ["local", "not-shallow"].includes(scenario) ? [] : scenario === "no-base" ? [8, 32, 128] : [8];
|
|
expect(fetches).toEqual(depths.map(depth => ["-c", "credential.helper=", "-c", "core.hooksPath=/dev/null",
|
|
"fetch", "--no-tags", `--depth=${depth}`, "https://github.com/paperclipai/paperclip.git", head]));
|
|
}
|
|
});
|
|
it("accepts each complete source variant and rejects a mixed variant", () => {
|
|
const files = Object.keys(NATIVE_INSTRUCTION_VARIANTS.baseline);
|
|
const baseline = new Map(files.map(file => [file, execFileSync("git", ["show", `${NATIVE_INSTRUCTION_BASE_SHA}:${file}`])]));
|
|
expect(nativeInstructionVariant(file => baseline.get(file)!)).toBe("baseline");
|
|
const candidate = new Map(files.map(file => [file, readFileSync(new URL(`../../${file}`, import.meta.url))]));
|
|
const currentVariant = nativeInstructionVariant(file => candidate.get(file)!);
|
|
expect(["baseline", "candidate", "corrected", "feedback", "opencodeFeedback", "opencodeFeedbackSettlement", "opencodeFeedbackResponses"]).toContain(currentVariant);
|
|
candidate.set(files[0]!, currentVariant === "candidate" ? baseline.get(files[0]!)! : Buffer.from("unknown source"));
|
|
expect(() => nativeInstructionVariant(file => candidate.get(file)!)).toThrow("Mixed or unknown");
|
|
baseline.set(files[0]!, Buffer.from("unknown source"));
|
|
expect(() => nativeInstructionVariant(file => baseline.get(file)!)).toThrow("Mixed or unknown");
|
|
});
|
|
|
|
it("declares six explicit single-attempt cells with the original task prompts and oracle", () => {
|
|
const suite = runnerSuites.find(value => value.id === NATIVE_INSTRUCTION_SUITE)!;
|
|
expect(suite.tasks).toEqual(nativeCompletionTasks);
|
|
expect(suite.manualOnly).toBe(true);
|
|
expect(suite.environments.map(value => value.id)).toEqual(["local"]);
|
|
const executions = buildRunnerMatrix([suite]);
|
|
expect(executions).toHaveLength(6);
|
|
expect(() => assertNativeInstructionSelection(executions)).not.toThrow();
|
|
for (const execution of executions) {
|
|
expect(execution.task.expectedRunCount).toBe(1);
|
|
expect(execution.task.automaticRetryPolicy).toBe("single_attempt");
|
|
expect(() => assertNativeInstructionSelection([{ ...execution, environment: { ...execution.environment, id: "daytona" } }])).toThrow();
|
|
expect(() => assertNativeInstructionSelection([{ ...execution, task: { ...execution.task, automaticRetryPolicy: undefined } }])).toThrow();
|
|
}
|
|
});
|
|
|
|
it("uses the new common production default without qualifying old default evidence", () => {
|
|
const receipt = { schema: "paperclip.native-completion-default.v1" as const, companyId: "company", agentId: "agent", entryFile: "AGENTS.md",
|
|
files: [{ path: "AGENTS.md", sha256: NATIVE_INSTRUCTION_DEFAULT_SHA256, bytes: 40 }], budgets: { company: 1000, agent: 1000 } };
|
|
expect(gradeNativeDefault(receipt, NATIVE_INSTRUCTION_DEFAULT_SHA256).passed).toBe(true);
|
|
expect(gradeNativeDefault(receipt).passed).toBe(false);
|
|
receipt.files[0]!.sha256 = NATIVE_MASTER_DEFAULT_SHA256;
|
|
expect(gradeNativeDefault(receipt, NATIVE_INSTRUCTION_DEFAULT_SHA256).passed).toBe(false);
|
|
receipt.files[0]!.sha256 = NATIVE_INSTRUCTION_DEFAULT_SHA256;
|
|
receipt.budgets.company = 0;
|
|
expect(gradeNativeDefault(receipt, NATIVE_INSTRUCTION_DEFAULT_SHA256).passed).toBe(false);
|
|
});
|
|
|
|
it("rejects duplicate or incomplete capture, dirty or paid source, and stale fixture evidence", () => {
|
|
const measurement = {
|
|
schema: "paperclip.native-instruction-measurement.v2", sourceSha: "frozen", sourceDirty: false, providerCalls: 0,
|
|
fixtureSha256: createHash("sha256").update(readFileSync(new URL("../../packages/paperclip-runner/src/backends/native-instruction-measurement.test.ts", import.meta.url))).digest("hex"),
|
|
sourceHashes: Object.fromEntries(Object.entries(NATIVE_INSTRUCTION_VARIANTS.baseline).map(([file, digest]) => [file.split('/').at(-1)!, digest])),
|
|
directOpenCodeReceipts: ["v4", "v5"].flatMap(schema => ["start", "resume", "continuation"].map(phase => ({ provider: "opencode", schema, phase }))),
|
|
receipts: ['codex', 'acpx', 'opencode'].flatMap(provider => ['v4', 'v5'].flatMap(schema => ['start', 'resume', 'continuation'].map(phase => ({ provider, schema, phase })))),
|
|
};
|
|
const source = { sourceSha: "frozen", variant: "baseline" };
|
|
expect(() => validateNativeInstructionMeasurement(measurement, source)).not.toThrow();
|
|
for (const invalid of [
|
|
{ ...measurement, receipts: measurement.receipts.slice(1) },
|
|
{ ...measurement, directOpenCodeReceipts: undefined },
|
|
{ ...measurement, directOpenCodeReceipts: measurement.directOpenCodeReceipts.slice(1) },
|
|
{ ...measurement, directOpenCodeReceipts: measurement.directOpenCodeReceipts.map(() => measurement.directOpenCodeReceipts[0]!) },
|
|
{ ...measurement, receipts: measurement.receipts.map(() => measurement.receipts[0]!) },
|
|
{ ...measurement, sourceDirty: true }, { ...measurement, providerCalls: 1 },
|
|
{ ...measurement, sourceSha: "other" }, { ...measurement, fixtureSha256: "stale" },
|
|
{ ...measurement, sourceHashes: { ...measurement.sourceHashes, "runtime-context.ts": "wrong" } },
|
|
{ ...measurement, sourceHashes: { ...measurement.sourceHashes, "opencode-app-server-proxy.ts": "wrong" } },
|
|
]) expect(() => validateNativeInstructionMeasurement(invalid, source)).toThrow();
|
|
});
|
|
});
|