| import fs from "node:fs/promises"; |
| import { randomUUID } from "node:crypto"; |
| import { describe, expect, it } from "vitest"; |
| import { |
| appendWithByteCap, |
| DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, |
| renderPaperclipWakePrompt, |
| runningProcesses, |
| runChildProcess, |
| stringifyPaperclipWakePayload, |
| } from "./server-utils.js"; |
|
|
| function isPidAlive(pid: number) { |
| try { |
| process.kill(pid, 0); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| async function waitForPidExit(pid: number, timeoutMs = 2_000) { |
| const deadline = Date.now() + timeoutMs; |
| while (Date.now() < deadline) { |
| if (!isPidAlive(pid)) return true; |
| await new Promise((resolve) => setTimeout(resolve, 50)); |
| } |
| return !isPidAlive(pid); |
| } |
|
|
| async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs = 1_000) { |
| const deadline = Date.now() + timeoutMs; |
| while (Date.now() < deadline) { |
| const value = read(); |
| const match = value.match(pattern); |
| if (match) return match; |
| await new Promise((resolve) => setTimeout(resolve, 25)); |
| } |
| return read().match(pattern); |
| } |
|
|
| describe("runChildProcess", () => { |
| it("does not arm a timeout when timeoutSec is 0", async () => { |
| const result = await runChildProcess( |
| randomUUID(), |
| process.execPath, |
| ["-e", "setTimeout(() => process.stdout.write('done'), 150);"], |
| { |
| cwd: process.cwd(), |
| env: {}, |
| timeoutSec: 0, |
| graceSec: 1, |
| onLog: async () => {}, |
| }, |
| ); |
|
|
| expect(result.exitCode).toBe(0); |
| expect(result.timedOut).toBe(false); |
| expect(result.stdout).toBe("done"); |
| }); |
|
|
| it("waits for onSpawn before sending stdin to the child", async () => { |
| const spawnDelayMs = 150; |
| const startedAt = Date.now(); |
| let onSpawnCompletedAt = 0; |
|
|
| const result = await runChildProcess( |
| randomUUID(), |
| process.execPath, |
| [ |
| "-e", |
| "let data='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>data+=chunk);process.stdin.on('end',()=>process.stdout.write(data));", |
| ], |
| { |
| cwd: process.cwd(), |
| env: {}, |
| stdin: "hello from stdin", |
| timeoutSec: 5, |
| graceSec: 1, |
| onLog: async () => {}, |
| onSpawn: async () => { |
| await new Promise((resolve) => setTimeout(resolve, spawnDelayMs)); |
| onSpawnCompletedAt = Date.now(); |
| }, |
| }, |
| ); |
| const finishedAt = Date.now(); |
|
|
| expect(result.exitCode).toBe(0); |
| expect(result.stdout).toBe("hello from stdin"); |
| expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs); |
| expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs); |
| }); |
|
|
| it.skipIf(process.platform === "win32")("kills descendant processes on timeout via the process group", async () => { |
| let descendantPid: number | null = null; |
|
|
| const result = await runChildProcess( |
| randomUUID(), |
| process.execPath, |
| [ |
| "-e", |
| [ |
| "const { spawn } = require('node:child_process');", |
| "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", |
| "process.stdout.write(String(child.pid));", |
| "setInterval(() => {}, 1000);", |
| ].join(" "), |
| ], |
| { |
| cwd: process.cwd(), |
| env: {}, |
| timeoutSec: 1, |
| graceSec: 1, |
| onLog: async () => {}, |
| onSpawn: async () => {}, |
| }, |
| ); |
|
|
| descendantPid = Number.parseInt(result.stdout.trim(), 10); |
| expect(result.timedOut).toBe(true); |
| expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); |
|
|
| expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true); |
| }); |
|
|
| it.skipIf(process.platform === "win32")("cleans up a lingering process group after terminal output and child exit", async () => { |
| const result = await runChildProcess( |
| randomUUID(), |
| process.execPath, |
| [ |
| "-e", |
| [ |
| "const { spawn } = require('node:child_process');", |
| "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'ignore'] });", |
| "process.stdout.write(`descendant:${child.pid}\\n`);", |
| "process.stdout.write(`${JSON.stringify({ type: 'result', result: 'done' })}\\n`);", |
| "setTimeout(() => process.exit(0), 25);", |
| ].join(" "), |
| ], |
| { |
| cwd: process.cwd(), |
| env: {}, |
| timeoutSec: 0, |
| graceSec: 1, |
| onLog: async () => {}, |
| terminalResultCleanup: { |
| graceMs: 100, |
| hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'), |
| }, |
| }, |
| ); |
|
|
| const descendantPid = Number.parseInt(result.stdout.match(/descendant:(\d+)/)?.[1] ?? "", 10); |
| expect(result.timedOut).toBe(false); |
| expect(result.exitCode).toBe(0); |
| expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); |
| expect(await waitForPidExit(descendantPid, 2_000)).toBe(true); |
| }); |
|
|
| it.skipIf(process.platform === "win32")("cleans up a still-running child after terminal output", async () => { |
| const result = await runChildProcess( |
| randomUUID(), |
| process.execPath, |
| [ |
| "-e", |
| [ |
| "process.stdout.write(`${JSON.stringify({ type: 'result', result: 'done' })}\\n`);", |
| "setInterval(() => {}, 1000);", |
| ].join(" "), |
| ], |
| { |
| cwd: process.cwd(), |
| env: {}, |
| timeoutSec: 0, |
| graceSec: 1, |
| onLog: async () => {}, |
| terminalResultCleanup: { |
| graceMs: 100, |
| hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'), |
| }, |
| }, |
| ); |
|
|
| expect(result.timedOut).toBe(false); |
| expect(result.signal).toBe("SIGTERM"); |
| expect(result.stdout).toContain('"type":"result"'); |
| }); |
|
|
| it.skipIf(process.platform === "win32")("does not clean up noisy runs that have no terminal output", async () => { |
| const runId = randomUUID(); |
| let observed = ""; |
| const resultPromise = runChildProcess( |
| runId, |
| process.execPath, |
| [ |
| "-e", |
| [ |
| "const { spawn } = require('node:child_process');", |
| "const child = spawn(process.execPath, ['-e', \"setInterval(() => process.stdout.write('noise\\\\n'), 50)\"], { stdio: ['ignore', 'inherit', 'ignore'] });", |
| "process.stdout.write(`descendant:${child.pid}\\n`);", |
| "setTimeout(() => process.exit(0), 25);", |
| ].join(" "), |
| ], |
| { |
| cwd: process.cwd(), |
| env: {}, |
| timeoutSec: 0, |
| graceSec: 1, |
| onLog: async (_stream, chunk) => { |
| observed += chunk; |
| }, |
| terminalResultCleanup: { |
| graceMs: 50, |
| hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'), |
| }, |
| }, |
| ); |
|
|
| const pidMatch = await waitForTextMatch(() => observed, /descendant:(\d+)/); |
| const descendantPid = Number.parseInt(pidMatch?.[1] ?? "", 10); |
| expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); |
|
|
| const race = await Promise.race([ |
| resultPromise.then(() => "settled" as const), |
| new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 300)), |
| ]); |
| expect(race).toBe("pending"); |
| expect(isPidAlive(descendantPid)).toBe(true); |
|
|
| const running = runningProcesses.get(runId) as |
| | { child: { kill(signal: NodeJS.Signals): boolean }; processGroupId: number | null } |
| | undefined; |
| try { |
| if (running?.processGroupId) { |
| process.kill(-running.processGroupId, "SIGKILL"); |
| } else { |
| running?.child.kill("SIGKILL"); |
| } |
| await resultPromise; |
| } finally { |
| runningProcesses.delete(runId); |
| if (isPidAlive(descendantPid)) { |
| try { |
| process.kill(descendantPid, "SIGKILL"); |
| } catch { |
| |
| } |
| } |
| } |
| }); |
| }); |
|
|
| describe("renderPaperclipWakePrompt", () => { |
| it("keeps the default local-agent prompt action-oriented", () => { |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Prefer the smallest verification that proves the change"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Create child issues directly when you know what needs to be done"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("POST /api/issues/{issueId}/interactions"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("kind suggest_tasks, ask_user_questions, or request_confirmation"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("confirmation:{issueId}:plan:{revisionId}"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Wait for acceptance before creating implementation subtasks"); |
| expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain( |
| "Respect budget, pause/cancel, approval gates, and company boundaries", |
| ); |
| }); |
|
|
| it("adds the execution contract to scoped wake prompts", () => { |
| const prompt = renderPaperclipWakePrompt({ |
| reason: "issue_assigned", |
| issue: { |
| id: "issue-1", |
| identifier: "PAP-1580", |
| title: "Update prompts", |
| status: "in_progress", |
| }, |
| commentWindow: { |
| requestedCount: 0, |
| includedCount: 0, |
| missingCount: 0, |
| }, |
| comments: [], |
| fallbackFetchNeeded: false, |
| }); |
|
|
| expect(prompt).toContain("## Paperclip Wake Payload"); |
| expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); |
| expect(prompt).toContain("use child issues instead of polling"); |
| expect(prompt).toContain("mark blocked work with the unblock owner/action"); |
| }); |
|
|
| it("renders dependency-blocked interaction guidance", () => { |
| const prompt = renderPaperclipWakePrompt({ |
| reason: "issue_commented", |
| issue: { |
| id: "issue-1", |
| identifier: "PAP-1703", |
| title: "Blocked parent", |
| status: "todo", |
| }, |
| dependencyBlockedInteraction: true, |
| unresolvedBlockerIssueIds: ["blocker-1"], |
| unresolvedBlockerSummaries: [ |
| { |
| id: "blocker-1", |
| identifier: "PAP-1723", |
| title: "Finish blocker", |
| status: "todo", |
| priority: "medium", |
| }, |
| ], |
| commentWindow: { |
| requestedCount: 1, |
| includedCount: 1, |
| missingCount: 0, |
| }, |
| commentIds: ["comment-1"], |
| latestCommentId: "comment-1", |
| comments: [{ id: "comment-1", body: "hello" }], |
| fallbackFetchNeeded: false, |
| }); |
|
|
| expect(prompt).toContain("dependency-blocked interaction: yes"); |
| expect(prompt).toContain("respond or triage the human comment"); |
| expect(prompt).toContain("PAP-1723 Finish blocker (todo)"); |
| }); |
|
|
| it("renders loose review request instructions for execution handoffs", () => { |
| const prompt = renderPaperclipWakePrompt({ |
| reason: "execution_review_requested", |
| issue: { |
| id: "issue-1", |
| identifier: "PAP-2011", |
| title: "Review request handoff", |
| status: "in_review", |
| }, |
| executionStage: { |
| wakeRole: "reviewer", |
| stageId: "stage-1", |
| stageType: "review", |
| currentParticipant: { type: "agent", agentId: "agent-1" }, |
| returnAssignee: { type: "agent", agentId: "agent-2" }, |
| reviewRequest: { |
| instructions: "Please focus on edge cases and leave a short risk summary.", |
| }, |
| allowedActions: ["approve", "request_changes"], |
| }, |
| fallbackFetchNeeded: false, |
| }); |
|
|
| expect(prompt).toContain("Review request instructions:"); |
| expect(prompt).toContain("Please focus on edge cases and leave a short risk summary."); |
| expect(prompt).toContain("You are waking as the active reviewer for this issue."); |
| }); |
|
|
| it("includes continuation and child issue summaries in structured wake context", () => { |
| const payload = { |
| reason: "issue_children_completed", |
| issue: { |
| id: "parent-1", |
| identifier: "PAP-100", |
| title: "Integrate child work", |
| status: "in_progress", |
| priority: "medium", |
| }, |
| continuationSummary: { |
| key: "continuation-summary", |
| title: "Continuation Summary", |
| body: "# Continuation Summary\n\n## Next Action\n\n- Integrate child outputs.", |
| updatedAt: "2026-04-18T12:00:00.000Z", |
| }, |
| livenessContinuation: { |
| attempt: 2, |
| maxAttempts: 2, |
| sourceRunId: "run-1", |
| state: "plan_only", |
| reason: "Run described future work without concrete action evidence", |
| instruction: "Take the first concrete action now.", |
| }, |
| childIssueSummaries: [ |
| { |
| id: "child-1", |
| identifier: "PAP-101", |
| title: "Implement helper", |
| status: "done", |
| priority: "medium", |
| summary: "Added the helper route and tests.", |
| }, |
| ], |
| }; |
|
|
| expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ |
| continuationSummary: { |
| body: expect.stringContaining("Continuation Summary"), |
| }, |
| livenessContinuation: { |
| attempt: 2, |
| maxAttempts: 2, |
| sourceRunId: "run-1", |
| state: "plan_only", |
| instruction: "Take the first concrete action now.", |
| }, |
| childIssueSummaries: [ |
| { |
| identifier: "PAP-101", |
| summary: "Added the helper route and tests.", |
| }, |
| ], |
| }); |
|
|
| const prompt = renderPaperclipWakePrompt(payload); |
| expect(prompt).toContain("Issue continuation summary:"); |
| expect(prompt).toContain("Integrate child outputs."); |
| expect(prompt).toContain("Run liveness continuation:"); |
| expect(prompt).toContain("- attempt: 2/2"); |
| expect(prompt).toContain("- source run: run-1"); |
| expect(prompt).toContain("- liveness state: plan_only"); |
| expect(prompt).toContain("- reason: Run described future work without concrete action evidence"); |
| expect(prompt).toContain("- instruction: Take the first concrete action now."); |
| expect(prompt).toContain("Direct child issue summaries:"); |
| expect(prompt).toContain("PAP-101 Implement helper (done)"); |
| expect(prompt).toContain("Added the helper route and tests."); |
| }); |
| }); |
|
|
| describe("appendWithByteCap", () => { |
| it("keeps valid UTF-8 when trimming through multibyte text", () => { |
| const output = appendWithByteCap("prefix ", "hello — world", 7); |
|
|
| expect(output).not.toContain("\uFFFD"); |
| expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output); |
| expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7); |
| }); |
| }); |
|
|