mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
## Thinking Path > - Paperclip manages work for AI agents. > - Legacy agents use the Paperclip skill to save task status and comments. > - The skill names a script relative to the task workspace. > - That script exists only in the Paperclip source repository. > - Agents in other workspaces can hit a missing command or search for it. > - This PR ships the helper inside the skill and uses the installed skill path. > - The repository command remains available through a forwarding wrapper. ## Linked Issues or Issue Description Fixes #9527. Refs #15548 for the preceding runtime checkout guidance. Related: #6052 addresses LF line endings for the repository helper; this change addresses helper delivery and path resolution. ## What Changed - Bundle the existing issue update helper with the Paperclip skill. Preserve its HTTP checks, echoed-status check and two-attempt limit. - Resolve the command from the installed skill directory. Use a verified PATCH when that path is unavailable, without searching the filesystem. - Keep the repository command as a wrapper that works from any directory. - Test shell execution and exact status/comment payloads through both provider skill-home layouts, including paths with spaces. - Add helper sources and existing verification tests to stock-harness admission. Record an absent historical helper explicitly. Add the missing declaration for the admission fingerprint export. ## Verification - Complete directly affected source suites: 30 tests pass. They cover skill delivery, preserved multiline comments and links, authentication headers, empty responses, mismatched status, transient retries and definitive rejections. - Product E2E typecheck passes. Support suites: 1,835 Vitest tests pass, one is skipped; 128 Node tests pass. - Full local build and workspace typecheck pass. - Full local repository tests are not claimed as passed. Embedded PostgreSQL was unavailable in this worktree during the preceding task; Linux CI will run the repository gates. - The authorized matched Codex/Claude comparison is pending. It uses the existing assigned-skill case and original oracle, one initial attempt per profile and variant. - CI and a fresh Greptile review are pending. Keep this PR in draft until readiness gates complete. ## Risks - Correct path resolution depends on the harness supplying the installed skill path. The instructions use verified PATCH when that path is unavailable. - The helper still requires Bash, curl and jq. Its existing retry and response-verification behavior is unchanged. - Tests use the shared skill-directory symlink mechanism and an HTTP fixture. Real provider completion behavior still requires the bounded live comparison. - This fix does not redesign native completion, legacy recovery or ambiguous transport handling. ## Model Used OpenAI Codex, GPT-6 family. The exact model build and context window are not exposed in this session. Used code editing, shell tools and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
235 lines
9.3 KiB
TypeScript
235 lines
9.3 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import http from "node:http";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import { ensurePaperclipSkillSymlink } from "@paperclipai/adapter-utils/server-utils";
|
|
import type { AddressInfo } from "node:net";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
// End-to-end coverage for the skill-bundled issue update helper: the helper must
|
|
// only exit 0 when the server confirms the write by echoing the update, must
|
|
// classify failures (retry connection-level faults and 5xx, never retry a
|
|
// definitive 4xx), and must stop at two attempts total to honor the shared
|
|
// bounded-write-retry rule.
|
|
const HELPER_PATH = path.resolve("skills/paperclip/scripts/paperclip-issue-update.sh");
|
|
const REPO_HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh");
|
|
|
|
interface HelperResult {
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
interface RecordedRequest {
|
|
method: string;
|
|
url: string;
|
|
body: string;
|
|
authorization: string | undefined;
|
|
runId: string | string[] | undefined;
|
|
}
|
|
|
|
describe("paperclip issue update helper", () => {
|
|
const cleanupFns: Array<() => Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
while (cleanupFns.length > 0) {
|
|
const cleanup = cleanupFns.pop();
|
|
if (!cleanup) continue;
|
|
await cleanup().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
async function startServer(
|
|
respond: (request: RecordedRequest, attempt: number, res: http.ServerResponse) => void,
|
|
): Promise<{ baseUrl: string; requests: RecordedRequest[] }> {
|
|
const requests: RecordedRequest[] = [];
|
|
const server = http.createServer((req, res) => {
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
const recorded: RecordedRequest = {
|
|
method: req.method ?? "",
|
|
url: req.url ?? "",
|
|
body,
|
|
authorization: req.headers.authorization,
|
|
runId: req.headers["x-paperclip-run-id"],
|
|
};
|
|
requests.push(recorded);
|
|
respond(recorded, requests.length, res);
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
cleanupFns.push(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
}),
|
|
);
|
|
const { port } = server.address() as AddressInfo;
|
|
return { baseUrl: `http://127.0.0.1:${port}`, requests };
|
|
}
|
|
|
|
function runHelper(apiUrl: string, args: string[], options: { helper?: string; cwd?: string; input?: string } = {}): Promise<HelperResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn("bash", [options.helper ?? HELPER_PATH, ...args], {
|
|
cwd: options.cwd,
|
|
env: {
|
|
...process.env,
|
|
PAPERCLIP_API_URL: apiUrl,
|
|
PAPERCLIP_API_KEY: "test-key",
|
|
PAPERCLIP_RUN_ID: "test-run",
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
child.stdin.end(options.input);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
const doneArgs = ["--issue-id", "issue-1", "--status", "done", "--comment", "closing note"];
|
|
|
|
it("exits 0 and prints the issue JSON when the server echoes the requested status", async () => {
|
|
const { baseUrl, requests } = await startServer((request, _attempt, res) => {
|
|
const payload = JSON.parse(request.body) as { status?: string; comment?: string };
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ id: "issue-1", status: payload.status }));
|
|
});
|
|
|
|
const result = await runHelper(baseUrl, doneArgs);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({ id: "issue-1", status: "done" });
|
|
expect(requests).toHaveLength(1);
|
|
expect(requests[0]?.method).toBe("PATCH");
|
|
expect(requests[0]?.url).toBe("/api/issues/issue-1");
|
|
expect(JSON.parse(requests[0]?.body ?? "{}")).toEqual({ status: "done", comment: "closing note" });
|
|
});
|
|
|
|
it.each([".claude/skills", "codex-home/skills"])(
|
|
"delivers the bundled helper through %s from an unrelated workspace", async skillsHome => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip completion helper "));
|
|
cleanupFns.push(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, "bundle", "paperclip");
|
|
const installed = path.join(root, skillsHome, "paperclip");
|
|
const cwd = path.join(root, "unrelated project");
|
|
await fs.cp(path.dirname(path.dirname(HELPER_PATH)), source, { recursive: true });
|
|
await fs.mkdir(path.dirname(installed), { recursive: true });
|
|
await fs.mkdir(cwd);
|
|
await ensurePaperclipSkillSymlink(source, installed);
|
|
await expect(fs.access(path.join(cwd, "scripts/paperclip-issue-update.sh"))).rejects.toThrow();
|
|
const savedComments: string[] = [];
|
|
const { baseUrl, requests } = await startServer((request, _attempt, res) => {
|
|
const payload = JSON.parse(request.body);
|
|
savedComments.push(payload.comment);
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ id: "issue-1", status: payload.status, comment: { body: payload.comment } }));
|
|
});
|
|
const comment = "Done\n\n- Saved [report](/TST/issues/TST-1#document-report)\n- Literal `code`, $HOME and $(not-a-command)";
|
|
const result = await runHelper(baseUrl, ["--issue-id", "issue-1", "--status", "done"], {
|
|
helper: path.join(installed, "scripts/paperclip-issue-update.sh"), cwd, input: comment,
|
|
});
|
|
expect(result.code).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({ status: "done", comment: { body: comment } });
|
|
expect(savedComments).toEqual([comment]);
|
|
expect(requests).toHaveLength(1);
|
|
expect(requests[0]).toMatchObject({ method: "PATCH", url: "/api/issues/issue-1", authorization: "Bearer test-key", runId: "test-run" });
|
|
});
|
|
|
|
it("keeps the repository entrypoint working outside the repository", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-helper-wrapper-"));
|
|
cleanupFns.push(() => fs.rm(root, { recursive: true, force: true }));
|
|
const result = await runHelper("", [...doneArgs, "--dry-run"], { helper: REPO_HELPER_PATH, cwd: root });
|
|
expect(result.code).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ status: "done", comment: "closing note" });
|
|
});
|
|
|
|
it("fails an empty 2xx body instead of treating it as success", async () => {
|
|
const { baseUrl } = await startServer((_request, _attempt, res) => {
|
|
res.writeHead(200, { "content-length": "0" });
|
|
res.end();
|
|
});
|
|
|
|
const result = await runHelper(baseUrl, doneArgs);
|
|
|
|
expect(result.code).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("empty response body");
|
|
});
|
|
|
|
it("fails when the server echoes a different status than requested", async () => {
|
|
const { baseUrl } = await startServer((_request, _attempt, res) => {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ id: "issue-1", status: "in_progress" }));
|
|
});
|
|
|
|
const result = await runHelper(baseUrl, doneArgs);
|
|
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain("echoed status in_progress");
|
|
});
|
|
|
|
it("does not retry a definitive 4xx rejection", async () => {
|
|
const { baseUrl, requests } = await startServer((_request, _attempt, res) => {
|
|
res.writeHead(422, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ error: "validation" }));
|
|
});
|
|
|
|
const result = await runHelper(baseUrl, doneArgs);
|
|
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain("rejected (HTTP 422)");
|
|
expect(requests).toHaveLength(1);
|
|
});
|
|
|
|
it("retries a 5xx once and succeeds when the retry lands", async () => {
|
|
const { baseUrl, requests } = await startServer((request, attempt, res) => {
|
|
if (attempt === 1) {
|
|
res.writeHead(503, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ error: "unavailable" }));
|
|
return;
|
|
}
|
|
const payload = JSON.parse(request.body) as { status?: string };
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ id: "issue-1", status: payload.status }));
|
|
});
|
|
|
|
const result = await runHelper(baseUrl, doneArgs);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(requests).toHaveLength(2);
|
|
expect(result.stderr).toContain("retrying");
|
|
});
|
|
|
|
it("stops after two attempts on connection-level failure and reports the write as not saved", async () => {
|
|
// Bind and close a listener so the port is real but refuses connections.
|
|
const probe = http.createServer();
|
|
await new Promise<void>((resolve) => {
|
|
probe.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const { port } = probe.address() as AddressInfo;
|
|
await new Promise<void>((resolve) => {
|
|
probe.close(() => resolve());
|
|
});
|
|
|
|
const result = await runHelper(`http://127.0.0.1:${port}`, doneArgs);
|
|
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain("FAILED after 2 attempts");
|
|
expect(result.stderr).toContain("NOT saved");
|
|
}, 15_000);
|
|
});
|