mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +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>
602 lines
25 KiB
TypeScript
602 lines
25 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
listPaperclipSkillEntries,
|
|
removeMaintainerOnlySkillSymlinks,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
async function makeTempDir(prefix: string): Promise<string> {
|
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const artifactHelperPath = path.resolve(
|
|
"skills/paperclip/scripts/paperclip-upload-artifact.sh",
|
|
);
|
|
|
|
async function makeArtifactHelperHarness(
|
|
cleanupDirs: Set<string>,
|
|
options: {
|
|
apiUrl?: string;
|
|
ambiguousHttpStatusAfterCommit?: "408" | "409" | "502";
|
|
commitAfterDropDelaySeconds?: string;
|
|
dropFirstUpload?: boolean;
|
|
dropWithoutCommit?: boolean;
|
|
existingOriginatingRunId?: string;
|
|
malformedSuccessAfterCommit?: boolean;
|
|
uploadDelaySeconds?: string;
|
|
} = {},
|
|
) {
|
|
const root = await makeTempDir("paperclip-artifact-helper-");
|
|
cleanupDirs.add(root);
|
|
const binDir = path.join(root, "bin");
|
|
const stateDir = path.join(root, "fake-api");
|
|
const lockDir = path.join(root, "helper-locks");
|
|
const filePath = path.join(root, "result.txt");
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
await fs.mkdir(stateDir, { recursive: true });
|
|
if (options.existingOriginatingRunId) {
|
|
await fs.writeFile(path.join(stateDir, "upload-committed"), "", "utf8");
|
|
await fs.writeFile(
|
|
path.join(stateDir, "originating-run-id"),
|
|
options.existingOriginatingRunId,
|
|
"utf8",
|
|
);
|
|
}
|
|
await fs.writeFile(filePath, "stable generated result\n", "utf8");
|
|
const sha256 = createHash("sha256")
|
|
.update(await fs.readFile(filePath))
|
|
.digest("hex");
|
|
const fakeCurlPath = path.join(binDir, "curl");
|
|
await fs.writeFile(
|
|
fakeCurlPath,
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
method=GET
|
|
output_file=""
|
|
url=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-X) method="$2"; shift 2 ;;
|
|
-o) output_file="$2"; shift 2 ;;
|
|
-w|-H|-F|--data-binary) shift 2 ;;
|
|
-sS) shift ;;
|
|
http://*|https://*) url="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
printf '%s %s\n' "$method" "$url" >>"$FAKE_CURL_STATE_DIR/request-log"
|
|
|
|
respond() {
|
|
printf '%s' "$1" >"$output_file"
|
|
printf '%s' "$2"
|
|
}
|
|
|
|
attachment_json() {
|
|
originating_run_id="$PAPERCLIP_RUN_ID"
|
|
if [[ -f "$FAKE_CURL_STATE_DIR/originating-run-id" ]]; then
|
|
originating_run_id="$(<"$FAKE_CURL_STATE_DIR/originating-run-id")"
|
|
fi
|
|
jq -nc \
|
|
--arg runId "$originating_run_id" \
|
|
--arg sha256 "$FAKE_ATTACHMENT_SHA" \
|
|
'{
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
companyId: "company-1",
|
|
issueId: "issue-1",
|
|
originatingRunId: $runId,
|
|
contentType: "text/plain",
|
|
byteSize: 24,
|
|
sha256: $sha256,
|
|
originalFilename: "result.txt",
|
|
contentPath: "/api/attachments/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/content",
|
|
downloadPath: "/api/attachments/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/content?download=1"
|
|
}'
|
|
}
|
|
|
|
if [[ "$method" == "GET" && "$url" == */work-products ]]; then
|
|
if [[ -f "$FAKE_CURL_STATE_DIR/upload-committed" ]]; then
|
|
work_product_json="$(
|
|
jq -nc \
|
|
--arg runId "$PAPERCLIP_RUN_ID" \
|
|
'{
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
type: "artifact",
|
|
provider: "paperclip",
|
|
createdByRunId: $runId,
|
|
externalId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
metadata: { attachmentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }
|
|
}'
|
|
)"
|
|
respond "[$work_product_json]" 200
|
|
else
|
|
respond '[]' 200
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$method" == "GET" && "$url" == */attachments ]]; then
|
|
if [[ -f "$FAKE_CURL_STATE_DIR/upload-committed" ]]; then
|
|
respond "[$(attachment_json)]" 200
|
|
else
|
|
respond '[]' 200
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$method" == "POST" && "$url" == */attachments ]]; then
|
|
count=0
|
|
[[ -f "$FAKE_CURL_STATE_DIR/upload-count" ]] && count="$(<"$FAKE_CURL_STATE_DIR/upload-count")"
|
|
count=$((count + 1))
|
|
printf '%s' "$count" >"$FAKE_CURL_STATE_DIR/upload-count"
|
|
if [[ -n "\${FAKE_CURL_COMMIT_AFTER_DROP_DELAY:-}" && ! -f "$FAKE_CURL_STATE_DIR/upload-drop-used" ]]; then
|
|
: >"$FAKE_CURL_STATE_DIR/upload-drop-used"
|
|
(
|
|
sleep "$FAKE_CURL_COMMIT_AFTER_DROP_DELAY"
|
|
: >"$FAKE_CURL_STATE_DIR/upload-committed"
|
|
printf '%s' "$PAPERCLIP_RUN_ID" >"$FAKE_CURL_STATE_DIR/originating-run-id"
|
|
) >/dev/null 2>&1 &
|
|
exit 56
|
|
fi
|
|
if [[ "\${FAKE_CURL_DROP_WITHOUT_COMMIT:-0}" == "1" && ! -f "$FAKE_CURL_STATE_DIR/upload-drop-used" ]]; then
|
|
: >"$FAKE_CURL_STATE_DIR/upload-drop-used"
|
|
exit 56
|
|
fi
|
|
: >"$FAKE_CURL_STATE_DIR/upload-committed"
|
|
printf '%s' "$PAPERCLIP_RUN_ID" >"$FAKE_CURL_STATE_DIR/originating-run-id"
|
|
if [[ -n "\${FAKE_CURL_AMBIGUOUS_STATUS:-}" && ! -f "$FAKE_CURL_STATE_DIR/upload-status-used" ]]; then
|
|
: >"$FAKE_CURL_STATE_DIR/upload-status-used"
|
|
respond '{"error":"ambiguous upstream response","outcome":"indeterminate","retryable":false}' "$FAKE_CURL_AMBIGUOUS_STATUS"
|
|
exit 0
|
|
fi
|
|
if [[ "\${FAKE_CURL_MALFORMED_SUCCESS:-0}" == "1" && ! -f "$FAKE_CURL_STATE_DIR/malformed-success-used" ]]; then
|
|
: >"$FAKE_CURL_STATE_DIR/malformed-success-used"
|
|
respond '{malformed' 201
|
|
exit 0
|
|
fi
|
|
if [[ -n "\${FAKE_CURL_UPLOAD_DELAY:-}" ]]; then
|
|
sleep "$FAKE_CURL_UPLOAD_DELAY"
|
|
fi
|
|
if [[ "\${FAKE_CURL_DROP_FIRST_UPLOAD:-0}" == "1" && ! -f "$FAKE_CURL_STATE_DIR/upload-drop-used" ]]; then
|
|
: >"$FAKE_CURL_STATE_DIR/upload-drop-used"
|
|
exit 56
|
|
fi
|
|
respond "$(attachment_json)" 201
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$method" == "POST" && "$url" == */work-products ]]; then
|
|
respond "$(jq -nc --arg runId "$PAPERCLIP_RUN_ID" '{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", createdByRunId: $runId }')" 201
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$method" == "POST" && "$url" == */comments ]]; then
|
|
respond '{"id":"cccccccc-cccc-4ccc-8ccc-cccccccccccc"}' 201
|
|
exit 0
|
|
fi
|
|
|
|
printf 'Unexpected fake curl request: %s %s\n' "$method" "$url" >&2
|
|
exit 2
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
const env = {
|
|
...process.env,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
PAPERCLIP_API_KEY: "test-run-key",
|
|
PAPERCLIP_API_URL: options.apiUrl ?? "http://paperclip.invalid",
|
|
PAPERCLIP_COMPANY_ID: "company-1",
|
|
PAPERCLIP_HELPER_STATE_DIR: lockDir,
|
|
PAPERCLIP_RUN_ID: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
PAPERCLIP_TASK_ID: "issue-1",
|
|
FAKE_ATTACHMENT_SHA: sha256,
|
|
FAKE_CURL_AMBIGUOUS_STATUS:
|
|
options.ambiguousHttpStatusAfterCommit ?? "",
|
|
FAKE_CURL_COMMIT_AFTER_DROP_DELAY:
|
|
options.commitAfterDropDelaySeconds ?? "",
|
|
FAKE_CURL_STATE_DIR: stateDir,
|
|
FAKE_CURL_DROP_FIRST_UPLOAD: options.dropFirstUpload ? "1" : "0",
|
|
FAKE_CURL_DROP_WITHOUT_COMMIT: options.dropWithoutCommit ? "1" : "0",
|
|
FAKE_CURL_MALFORMED_SUCCESS: options.malformedSuccessAfterCommit
|
|
? "1"
|
|
: "0",
|
|
FAKE_CURL_UPLOAD_DELAY: options.uploadDelaySeconds ?? "",
|
|
};
|
|
const run = (
|
|
chatComment?: string,
|
|
selectedFilePath = filePath,
|
|
extraArgs: string[] = [],
|
|
) =>
|
|
execFileAsync(
|
|
"bash",
|
|
[
|
|
artifactHelperPath,
|
|
selectedFilePath,
|
|
"--title",
|
|
"Stable result",
|
|
...(chatComment ? ["--chat-comment", chatComment] : []),
|
|
...extraArgs,
|
|
],
|
|
{ cwd: root, env },
|
|
);
|
|
|
|
return { env, filePath, lockDir, root, run, sha256, stateDir };
|
|
}
|
|
|
|
describe("paperclip skill utils", () => {
|
|
const cleanupDirs = new Set<string>();
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
cleanupDirs.clear();
|
|
});
|
|
|
|
it("lists bundled runtime skills from ./skills without pulling in .agents/skills", async () => {
|
|
const root = await makeTempDir("paperclip-skill-roots-");
|
|
cleanupDirs.add(root);
|
|
|
|
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
|
await fs.mkdir(moduleDir, { recursive: true });
|
|
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
|
|
await fs.mkdir(path.join(root, "skills", "paperclip-create-agent"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "diagnose-why-work-stopped"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "paperclip-create-plugin"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "terminal-bench-loop"), { recursive: true });
|
|
|
|
const entries = await listPaperclipSkillEntries(moduleDir);
|
|
|
|
expect(entries.map((entry) => entry.key)).toEqual([
|
|
"paperclipai/paperclip/paperclip",
|
|
"paperclipai/paperclip/paperclip-create-agent",
|
|
]);
|
|
expect(entries.map((entry) => entry.runtimeName)).toEqual([
|
|
"paperclip",
|
|
"paperclip-create-agent",
|
|
]);
|
|
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
|
|
expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent"));
|
|
});
|
|
|
|
it("documents artifact uploads in the installed Paperclip skill", async () => {
|
|
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
|
|
const referenceBody = await fs.readFile(path.resolve("skills/paperclip/references/artifacts.md"), "utf8");
|
|
const helperBody = await fs.readFile(path.resolve("skills/paperclip/scripts/paperclip-upload-artifact.sh"), "utf8");
|
|
const normalizedReferenceBody = referenceBody.replace(/\s+/g, " ");
|
|
|
|
expect(skillBody).toContain("Generated Artifacts and Work Products");
|
|
expect(skillBody).toContain("references/artifacts.md");
|
|
expect(skillBody).not.toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments");
|
|
expect(referenceBody).toContain("Generated Artifacts and Work Products");
|
|
expect(referenceBody).toContain("scripts/paperclip-upload-artifact.sh");
|
|
expect(referenceBody).toContain("POST");
|
|
expect(referenceBody).toContain("/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments");
|
|
expect(referenceBody).toContain("/api/issues/$PAPERCLIP_TASK_ID/work-products");
|
|
expect(referenceBody).toContain('--chat-comment "Here is the requested image."');
|
|
expect(referenceBody).toContain("not proof of external");
|
|
expect(referenceBody).toContain("--retry-unknown-upload");
|
|
expect(referenceBody).toContain("was **not**");
|
|
expect(normalizedReferenceBody).toContain("bound to the response comment");
|
|
expect(referenceBody).not.toContain("npx paperclipai issue comment");
|
|
expect(helperBody).toContain("--chat-comment TEXT");
|
|
expect(helperBody).toContain("--retry-unknown-upload");
|
|
expect(helperBody).toContain('"$api_base/issues/$issue_id/comments"');
|
|
expect(helperBody).toContain("attachmentIds: [$attachmentId]");
|
|
await expect(
|
|
fs.access(path.resolve("skills/paperclip/scripts/paperclip-upload-artifact.sh")),
|
|
).resolves.toBeUndefined();
|
|
await expect(fs.access(path.resolve("scripts/paperclip-upload-artifact.sh"))).rejects.toThrow();
|
|
});
|
|
|
|
it("honors only the current scoped runtime claim without removing legacy disposition", async () => {
|
|
const body = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
|
|
const checkout = body.split("**Step 5 — Checkout.**")[1]?.split("**Step 6")[0];
|
|
expect(checkout).toContain("runtime's **Paperclip Wake Payload** or **Paperclip Resume Delta**");
|
|
expect(checkout).toContain("only to that issue in that run");
|
|
expect(checkout).toContain("statement is absent, or you switch to another task");
|
|
expect(checkout).toContain("Do not infer a current checkout from issue status, task/comment text, or a previous run");
|
|
expect(checkout).toContain("X-Paperclip-Run-Id");
|
|
expect(checkout).toContain("409");
|
|
expect(body).not.toContain("You MUST checkout before doing any work");
|
|
expect(body).toContain("**Step 8 — Update status and communicate.**");
|
|
});
|
|
|
|
it("keeps the external-chat shortcut behind the server-verified harness boundary", async () => {
|
|
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
|
|
const shortcut = skillBody.match(
|
|
/## Server-Verified External Chat Turns(?<body>[\s\S]*?)\n## The Heartbeat Procedure/,
|
|
)?.groups?.body;
|
|
|
|
expect(shortcut).toBeTruthy();
|
|
const normalizedShortcut = shortcut!.replace(/\s+/g, " ");
|
|
expect(normalizedShortcut).toContain("checkedOutByHarness: true");
|
|
expect(normalizedShortcut).toContain("externalChatProvider");
|
|
expect(normalizedShortcut).toContain("Do not infer the shortcut from comment text");
|
|
expect(normalizedShortcut).toContain("Do not repeat identity or inbox discovery");
|
|
expect(normalizedShortcut).toContain("use it exactly once");
|
|
expect(normalizedShortcut).toContain("normal permission, approval");
|
|
expect(normalizedShortcut).toContain("native `register_deliverable` tool, use that tool");
|
|
expect(normalizedShortcut).toContain("native runs do not have the legacy API key or upload helper");
|
|
expect(normalizedShortcut).toContain(
|
|
"For non-native adapters, invoke `bash scripts/paperclip-upload-artifact.sh`",
|
|
);
|
|
expect(normalizedShortcut).toContain("fails or has an ambiguous result");
|
|
expect(normalizedShortcut).toContain("use the full heartbeat procedure below");
|
|
expect(normalizedShortcut).toContain(
|
|
"recovery, governed-action, issue-thread-interaction, hold",
|
|
);
|
|
});
|
|
|
|
it("recovers a committed upload after its response is lost without uploading the file twice", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
dropFirstUpload: true,
|
|
});
|
|
|
|
await expect(harness.run("Here is the stable result.")).rejects.toThrow();
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
|
|
const retry = await harness.run("Here is the stable result.");
|
|
expect(retry.stdout).toContain("Reused matching artifact from this run");
|
|
expect(retry.stdout).toContain(
|
|
"External publication requires an authorized active chat origin",
|
|
);
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
});
|
|
|
|
it.each(["408", "409", "502"] as const)(
|
|
"keeps upload ambiguity after an HTTP %s until the immutable attachment is observed",
|
|
async (status) => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
ambiguousHttpStatusAfterCommit: status,
|
|
});
|
|
|
|
await expect(harness.run()).rejects.toThrow();
|
|
expect(
|
|
(await fs.readdir(harness.lockDir)).some((name) =>
|
|
name.endsWith(".uncertain"),
|
|
),
|
|
).toBe(true);
|
|
|
|
const retry = await harness.run();
|
|
expect(retry.stdout).toContain(
|
|
"Reused matching artifact from this run",
|
|
);
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
},
|
|
);
|
|
|
|
it("keeps the ambiguity marker when a successful upload response is malformed", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
malformedSuccessAfterCommit: true,
|
|
});
|
|
|
|
await expect(harness.run()).rejects.toThrow();
|
|
expect(
|
|
(await fs.readdir(harness.lockDir)).some((name) =>
|
|
name.endsWith(".uncertain"),
|
|
),
|
|
).toBe(true);
|
|
|
|
const retry = await harness.run();
|
|
expect(retry.stdout).toContain("Reused matching artifact from this run");
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
});
|
|
|
|
it("waits for a disconnected upload to commit before deciding whether to retry", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
commitAfterDropDelaySeconds: "0.4",
|
|
});
|
|
|
|
await expect(harness.run("Here is the stable result.")).rejects.toThrow();
|
|
|
|
const retry = await harness.run("Here is the stable result.");
|
|
expect(retry.stdout).toContain("Reused matching artifact from this run");
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
});
|
|
|
|
it("fails closed on an unresolved upload unless duplicate-risk retry is explicit", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
dropWithoutCommit: true,
|
|
});
|
|
|
|
await expect(harness.run()).rejects.toThrow();
|
|
await expect(harness.run()).rejects.toMatchObject({
|
|
stderr: expect.stringContaining(
|
|
"previous matching upload ended without a definitive response",
|
|
),
|
|
});
|
|
|
|
const retry = await harness.run(undefined, harness.filePath, [
|
|
"--retry-unknown-upload",
|
|
]);
|
|
expect(retry.stdout).toContain("Uploaded artifact");
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("2");
|
|
});
|
|
|
|
it("serializes concurrent matching uploads and reuses the first run-scoped attachment", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
uploadDelaySeconds: "0.2",
|
|
});
|
|
|
|
const secondDir = path.join(harness.root, "other-directory");
|
|
const secondFilePath = path.join(secondDir, "result.txt");
|
|
await fs.mkdir(secondDir);
|
|
await fs.copyFile(harness.filePath, secondFilePath);
|
|
const results = await Promise.all([
|
|
harness.run("Here is the stable result."),
|
|
harness.run("Here is the stable result.", secondFilePath),
|
|
]);
|
|
|
|
expect(results.map((result) => result.stdout)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("Uploaded artifact"),
|
|
expect.stringContaining("Reused matching artifact from this run"),
|
|
]),
|
|
);
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
});
|
|
|
|
it("does not recover an attachment whose immutable originating run differs", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
existingOriginatingRunId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
});
|
|
|
|
const result = await harness.run("Here is the stable result.");
|
|
|
|
expect(result.stdout).toContain("Uploaded artifact");
|
|
expect(result.stdout).not.toContain(
|
|
"Reused matching artifact from this run",
|
|
);
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
});
|
|
|
|
it("reclaims one dead-owner lock safely when matching helpers contend", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
uploadDelaySeconds: "0.2",
|
|
});
|
|
const operationIdentity = [
|
|
"http://paperclip.invalid/api",
|
|
"company-1",
|
|
"issue-1",
|
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
|
"result.txt",
|
|
harness.sha256,
|
|
"text/plain",
|
|
].join("|");
|
|
const operationKey = createHash("sha256")
|
|
.update(operationIdentity)
|
|
.digest("hex");
|
|
const staleLockPath = path.join(harness.lockDir, `${operationKey}.lock`);
|
|
await fs.mkdir(harness.lockDir, { recursive: true });
|
|
await fs.symlink("2147483647|stale process", staleLockPath);
|
|
|
|
const results = await Promise.all([harness.run(), harness.run()]);
|
|
|
|
expect(results.map((result) => result.stdout)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("Uploaded artifact"),
|
|
expect.stringContaining("Reused matching artifact from this run"),
|
|
]),
|
|
);
|
|
expect(await fs.readFile(path.join(harness.stateDir, "upload-count"), "utf8")).toBe("1");
|
|
await expect(fs.lstat(staleLockPath)).rejects.toThrow();
|
|
});
|
|
|
|
it("accepts PAPERCLIP_API_URL with an existing trailing API path", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs, {
|
|
apiUrl: "http://paperclip.invalid/api/",
|
|
});
|
|
|
|
await harness.run();
|
|
|
|
const requestLog = await fs.readFile(
|
|
path.join(harness.stateDir, "request-log"),
|
|
"utf8",
|
|
);
|
|
expect(requestLog).toContain(
|
|
"GET http://paperclip.invalid/api/issues/issue-1/attachments",
|
|
);
|
|
expect(requestLog).not.toContain("/api/api/");
|
|
});
|
|
|
|
it("does not describe an ordinary artifact upload as prepared for provider delivery", async () => {
|
|
const harness = await makeArtifactHelperHarness(cleanupDirs);
|
|
|
|
const result = await harness.run();
|
|
|
|
expect(result.stdout).toContain("Uploaded artifact");
|
|
expect(result.stdout).not.toContain("provider delivery");
|
|
expect(result.stdout).not.toContain("bound for delivery");
|
|
});
|
|
|
|
it("documents governed agent interaction resolution invariants", async () => {
|
|
const apiReference = await fs.readFile(path.resolve("skills/paperclip/references/api-reference.md"), "utf8");
|
|
const issueDocs = await fs.readFile(path.resolve("docs/api/issues.md"), "utf8");
|
|
for (const body of [apiReference, issueDocs]) {
|
|
expect(body).toContain('resolverPolicy: "anyone" | "not_creator" | "human_only"');
|
|
expect(body).toContain("requestedResolverPolicy");
|
|
expect(body).toContain("effectiveResolverPolicy");
|
|
expect(body).toContain("toolAction");
|
|
expect(body).toContain("watchdog");
|
|
expect(body).toContain("low-trust");
|
|
expect(body).toContain("addresseeAgentId");
|
|
expect(body).toContain("interaction_pending");
|
|
expect(body).toContain("attention feed");
|
|
}
|
|
});
|
|
|
|
it("uses the authoritative PATCH response to confirm monitor scheduling", async () => {
|
|
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
|
|
|
|
expect(skillBody).toContain("Use that request's default full response");
|
|
expect(skillBody).toContain("do not issue a confirming GET");
|
|
expect(skillBody).toContain("`monitorNextCheckAt` is non-null");
|
|
expect(skillBody).toContain("`assigneeAgentId` is set");
|
|
expect(skillBody).toContain("`assigneeUserId` is null");
|
|
});
|
|
|
|
it("requires issue-update writes to be verified, not inferred", async () => {
|
|
const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8");
|
|
|
|
expect(skillBody).toContain("Verify writes — never infer them");
|
|
expect(skillBody).toContain("An empty response body means the write FAILED");
|
|
expect(skillBody).toContain("Never pipe a disposition write through `head`/`tail`");
|
|
expect(skillBody).toContain("resolved relative to this installed `SKILL.md`, not the task workspace");
|
|
expect(skillBody).toContain("do not search the filesystem for it");
|
|
expect(skillBody).toContain('bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh"');
|
|
expect(skillBody).not.toMatch(/^scripts\/paperclip-issue-update\.sh/m);
|
|
// The helper's verification behavior (HTTP status parsing, retry
|
|
// classification, attempt bound, exit codes) is exercised end-to-end in
|
|
// paperclip-issue-update-helper.test.ts against a live local server.
|
|
});
|
|
|
|
it("keeps the create-issue-interaction-ui guide as a maintainer-only skill", async () => {
|
|
const skillPath = path.resolve(".agents/skills/create-issue-interaction-ui/SKILL.md");
|
|
const skillBody = await fs.readFile(skillPath, "utf8");
|
|
const normalizedSkillBody = skillBody.replace(/\s+/g, " ");
|
|
const normalizedLowerSkillBody = normalizedSkillBody.toLowerCase();
|
|
|
|
expect(skillBody).toContain("name: create-issue-interaction-ui");
|
|
expect(normalizedLowerSkillBody).toContain("developer/maintainer skill");
|
|
expect(normalizedLowerSkillBody).toContain(
|
|
"not the operational agents that run inside a deployed paperclip company",
|
|
);
|
|
expect(skillBody).toContain("packages/shared/src/constants.ts");
|
|
expect(skillBody).toContain("server/src/services/issue-thread-interactions.ts");
|
|
expect(skillBody).toContain("ui/src/components/IssueThreadInteractionCard.tsx");
|
|
expect(skillBody).toContain("packages/plugins/sdk/src/testing.ts");
|
|
await expect(fs.access(path.resolve("skills/create-issue-interaction-ui/SKILL.md"))).rejects.toThrow();
|
|
});
|
|
|
|
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
|
const root = await makeTempDir("paperclip-skill-cleanup-");
|
|
cleanupDirs.add(root);
|
|
|
|
const skillsHome = path.join(root, "skills-home");
|
|
const runtimeSkill = path.join(root, "skills", "paperclip");
|
|
const customSkill = path.join(root, "custom", "release-notes");
|
|
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
|
|
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
await fs.mkdir(runtimeSkill, { recursive: true });
|
|
await fs.mkdir(customSkill, { recursive: true });
|
|
|
|
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
|
|
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
|
|
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
|
|
|
|
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
|
|
|
|
expect(removed).toEqual(["release"]);
|
|
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
|
|
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
|
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
|
|
});
|
|
});
|