diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 2c79457474..e1f84045df 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -106,7 +106,7 @@ describe("runner E2E catalog", () => { daytonaWarmContinuityTask.buildFollowupMessages?.("nonce") ?? []; for (const prompt of [initialPrompt, ...followups]) { expect(prompt).toContain( - 'if [ -n "${PAPERCLIP_TASK_DIR:-}" ]; then cd "$PAPERCLIP_TASK_DIR"; fi', + '"${PAPERCLIP_TASK_DIR:-$PWD}/daytona-warm-nonce.txt"', ); } expect(initialPrompt).toContain('"kind":"request_confirmation"'); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index b3e6f28f27..db8184ffe0 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -777,7 +777,7 @@ function warmWorkspaceLine(turn: 1 | 2 | 3, nonce: string) { } function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) { - const file = `daytona-warm-${nonce}.txt`; + const file = `"\${PAPERCLIP_TASK_DIR:-$PWD}/daytona-warm-${nonce}.txt"`; const lines = Array.from({ length: turn }, (_, index) => warmWorkspaceLine((index + 1) as 1 | 2 | 3, nonce), ); @@ -787,12 +787,12 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) { ? `In a legacy runner, make exactly one public-API completion write after verification: PATCH /api/issues/$PAPERCLIP_TASK_ID with {"status":"done","comment":"${marker}"}. Include Authorization and X-Paperclip-Run-Id. Do not POST a separate comment.` : `In a legacy runner, after verification POST exactly one request_confirmation to /api/issues/$PAPERCLIP_TASK_ID/interactions with {"kind":"request_confirmation","idempotencyKey":"daytona-warm-review-T${turn}-${nonce}","resolverPolicy":"human_only","title":"Warm continuity turn ${turn}","summary":"Review completed warm continuity turn ${turn}.","continuationPolicy":"wake_assignee","payload":{"version":1,"prompt":"Is this warm continuity task ready to complete after turn ${turn}?","acceptLabel":"Approve completion","rejectLabel":"Continue work","rejectRequiresReason":true,"allowDeclineReason":true,"supersedeOnUserComment":false,"target":{"type":"custom","key":"daytona_warm_turn_${turn}","revisionId":"${nonce}-T${turn}","label":"Warm continuity turn ${turn}"}}}. Capture the returned interaction id. Then make exactly one issue PATCH with {"status":"in_review","comment":"${marker}","reviewInteractionId":""}. Include Authorization and X-Paperclip-Run-Id on both writes. If the issue PATCH fails, retry only that PATCH and never create another interaction. Do not POST a separate comment. After both writes succeed, end the response and heartbeat immediately; do not wait or poll because the reviewer action will start the next turn.`; return [ - `This is warm Daytona continuity turn ${turn} of 3. Before reading or writing the file, run: if [ -n "\${PAPERCLIP_TASK_DIR:-}" ]; then cd "$PAPERCLIP_TASK_DIR"; fi. Use the scoped task folder when supplied; otherwise keep the current execution workspace. Do this on every turn.`, + `This is warm Daytona continuity turn ${turn} of 3. Use ${file} for every read and write. Expand this shell path inside each tool invocation; do not rely on a directory change in a previous shell call. The host-supplied task folder takes precedence over the current directory.`, turn === 1 ? `Create ${file} with exactly this one line followed by a newline: ${lines[0]}` : `Before changing anything, read ${file} and verify its content is exactly ${lines.slice(0, -1).join("\\n")} followed by a newline. Then append exactly ${lines.at(-1)} followed by a newline.`, `After the write, verify ${file} contains exactly these lines, once each and in order: ${lines.join(" | ")}.`, - `In a native runner, call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:"1",objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},evidence:[],verification:[{commandOrCheck:"read ${file}",status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`, + `In a native runner, call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:"1",objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},evidence:[],verification:[{commandOrCheck:${JSON.stringify(`read ${file}`)},status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`, legacyCompletion, `In a legacy runner, the PATCH comment is the complete visible response. After its 2xx response, finish silently: do not print, echo, or emit ${marker} again as assistant text.`, `Do not include ${marker} in any other visible response or write. Do not recreate, truncate, reorder, or duplicate prior lines.`, diff --git a/tests/runner-e2e/warm-workspace.test.ts b/tests/runner-e2e/warm-workspace.test.ts index 60d0cce2e7..b0b824fbb7 100644 --- a/tests/runner-e2e/warm-workspace.test.ts +++ b/tests/runner-e2e/warm-workspace.test.ts @@ -1,7 +1,10 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { daytonaWarmContinuityTask } from "./catalog.js"; import { readWarmWorkspaceFile } from "./warm-workspace.js"; const temporaryDirectories: string[] = []; @@ -59,6 +62,46 @@ async function fixture() { } describe("warm workspace persistence observation", () => { + it.each([true, false])( + "keeps writes in the correct folder across separate shells (scoped=%s)", + async (scoped) => { + const { input } = await fixture(); + const taskDirectory = path.join(input.workspacePath, "task files"); + await mkdir(taskDirectory); + const env = { ...process.env }; + if (scoped) env.PAPERCLIP_TASK_DIR = taskDirectory; + else delete env.PAPERCLIP_TASK_DIR; + const prompts = [ + daytonaWarmContinuityTask.buildPrompt("nonce"), + ...daytonaWarmContinuityTask.buildFollowupMessages!("nonce"), + ]; + for (const [index, prompt] of prompts.entries()) { + const shellPath = prompt.match( + /Use ("[^"\n]+") for every read and write\./, + )?.[1]; + expect(shellPath).toBeTruthy(); + await promisify(execFile)( + "sh", + ["-c", `printf 'T${index + 1}-nonce\\n' >> ${shellPath}`], + { cwd: input.workspacePath, env }, + ); + } + expect( + await readFile( + path.join( + scoped ? taskDirectory : input.workspacePath, + input.filename, + ), + "utf8", + ), + ).toBe("T1-nonce\nT2-nonce\nT3-nonce\n"); + if (scoped) + await expect( + readFile(path.join(input.workspacePath, input.filename)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + it("reads the saved scoped file without requiring a mirrored host file", async () => { const { input } = await fixture(); expect(await readWarmWorkspaceFile(input)).toEqual({