Files
PaperClipAI/tests/runner-e2e/chat-stories.test.ts
T
DottaandClaude Opus 5.5 2b2004bf50 test(runner-e2e): gate completion briefs inside the run's own task directory (#19475)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent Chat returns delegated-task completions to the chat that
started them, and the runner E2E `completion-updates` suite checks this
with real providers
> - The restart cell
(`completion-updates.runner-codex.local.handoff-completion-restart`)
failed before its server restart with "Timed out waiting for worker
waiting while originating chat is idle"
> - Since project-optional task workspaces (#15749), a task in a project
without a workspace runs in its isolated task directory. A native chat
runs in its isolated conversation directory. Native Codex can read only
that directory.
> - The fixture still wrote its brief wait scripts into the managed
project workspace and the agent home. Codex got EPERM on both scripts,
wrote the note from the task record, and finished. The worker never
reached the gate, so the setup poll timed out.
> - This pull request makes the waits name only cwd-relative files and
publishes each brief into the run's own isolated task directory
> - The benefit is that the restart cell reaches its real boundary again
(durable Done, source provider held at the gate, server restart) and
tests restart recovery instead of a stale fixture path

## Linked Issues or Issue Description

**What happened?**
The runner E2E cell
`completion-updates.runner-codex.local.handoff-completion-restart` timed
out in setup. The run ledger shows both agents report
`permission_denied` (EPERM) for `node .../wait-for-brief-*.cjs`. The
worker run cwd was `<instance>/isolated-workspaces/<company>/<issue>`.
The fixture wrote the script to the managed project workspace and the
agent home.

**What did you expect to happen?**
The worker waits at the brief gate while the chat is idle. Then the cell
releases the brief, holds the completion reply at its gate, and restarts
the server.

**Steps to reproduce**
`pnpm test:e2e:runner -- --max-automatic-retries 0 --id
completion-updates.runner-codex.local.handoff-completion-restart` on
master `6cafcaf89b`.

**Classification**
Test defect. The failure happens before the restart. Workspace isolation
is the intended product behavior.

## What Changed

- `tests/runner-e2e/chat-stories.ts`: add `workspaceChatBrief(nonce,
timeoutMs)`. It returns a bounded `node -e` command that reads and
writes only cwd-relative files, plus `at(dir)` to find the gate and
ready files in a workspace.
- `tests/runner-e2e/completion-update-flow.ts`: the worker and
completion instructions use that command. The fixture reads readiness
from, and publishes briefs to, `isolated-workspaces/<company>/<issue>`
for each delegated task and the source chat. The setup poll state now
records the worker run cwd. Cleanup releases every wait that is still
open.
- `tests/runner-e2e/chat-stories.test.ts`: regression test. It runs the
command from a temporary cwd, checks that the command names no host
path, and checks the nonce and timeout bounds. It was red before the
helper existed.
- `tests/runner-e2e/catalog.ts`: bump the `completion-updates`
definition to version 28 and set `workerBriefWorkspace:
"run-isolated-task-directory"`.
- `tests/runner-e2e/README.md`: describe the new brief location.

## Verification

- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-stories.test.ts
tests/runner-e2e/completion-updates.test.ts
tests/runner-e2e/catalog.test.ts`: 108 passed.
- Live cell, native Codex `gpt-5.6-sol`, local runner: `pnpm
test:e2e:runner -- --max-automatic-retries 0 --id
completion-updates.runner-codex.local.handoff-completion-restart`.
Results are in the Verification comment below.
- Two earlier local attempts in this worktree failed in setup with
`harness_test_failed`. The worktree did not have a built
`paperclip-runnerd`. After `pnpm --filter @paperclipai/paperclip-runner
build`, the cell passed.

## Risks

- Low risk. The change is in test fixtures only. No product code
changes.
- The idle, busy, and multiple cells share this flow. They now also use
the isolated task directory. That is the same directory the product
selects for these tasks.
- The agent must run the inline command from its cwd. The instructions
say this, and the command fails closed (no gate) if the agent runs it
elsewhere.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits). Default context window. Extended reasoning
on.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:50 -06:00

154 lines
9.1 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { assertInterruptedChat, prepareChatBrief, publishChatBrief, workspaceChatBrief } from "./chat-stories.js";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { runnerMatrix } from "./catalog.js";
import { buildRunnerE2EProcessEnvironment } from "./harness-env.js";
vi.mock("node:fs/promises", async importOriginal => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, writeFile: vi.fn(actual.writeFile) };
});
const run = { id: "run", companyId: "company", agentId: "agent", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "chat" } };
const valid = {
first: "Read the brief", followup: "Change the launch day", reference: "BRIEF123", marker: "UPDATED123", issueId: "chat",
boundaryRun: { ...run, status: "running" }, activeAtFollowup: { ...run, status: "running" },
comments: [{ id: "first", body: "Read the brief" }, { id: "followup", body: "Change the launch day" },
{ id: "answer", authorAgentId: "agent", createdByRunId: "run", body: "BRIEF123 UPDATED123" }],
runs: [run], revisedPlan: JSON.stringify({ launchDay: "Friday", reference: "BRIEF123", revision: "UPDATED123" }),
};
describe("active chat follow-up oracle", () => {
it("creates the missing fixture directory and waits for the host-supplied brief", async () => {
const root = await mkdtemp(path.join(tmpdir(), "chat-brief-test-"));
try {
const { gate, ready, scriptPath } = await prepareChatBrief(path.join(root, "new workspace"), "fixture");
const command = promisify(execFile)(process.execPath, [scriptPath], { timeout: 5_000 });
try {
await expect.poll(() => readFile(ready, "utf8").catch(() => "")).toBe("waiting");
} finally {
await publishChatBrief(gate, "BRIEF fixture result");
expect((await command).stdout.trim()).toBe("BRIEF fixture result");
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("waits for a brief inside the agent's own working directory without host paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "chat-brief-relative-"));
try {
const brief = workspaceChatBrief("fixture-1", 5_000);
// A sandboxed agent can read only its own workspace. The command must
// not name the fixture host directory, and must resolve from its cwd.
expect(brief.command).not.toContain(root);
expect(brief.command).not.toContain(path.sep + "tmp");
const { gate, ready } = brief.at(root);
const command = promisify(execFile)("/bin/sh", ["-c", brief.command], { cwd: root, timeout: 5_000 });
try {
await expect.poll(() => readFile(ready, "utf8").catch(() => "")).toBe("waiting");
} finally {
await publishChatBrief(gate, "BRIEF relative result");
expect((await command).stdout.trim()).toBe("BRIEF relative result");
}
expect(() => workspaceChatBrief("../escape")).toThrow();
expect(() => workspaceChatBrief("ok", 0)).toThrow("bounded");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("accepts either steering or one queued successor, with the saved correction", () => {
expect(() => assertInterruptedChat(valid)).not.toThrow();
expect(() => assertInterruptedChat({ ...valid, runs: [run, { ...run, id: "successor" }], comments: [...valid.comments.slice(0, 2), { ...valid.comments[2]!, createdByRunId: "successor" }] })).not.toThrow();
});
it("rejects follow-ups sent after the active boundary", () => {
expect(() => assertInterruptedChat({ ...valid, boundaryRun: run })).toThrow();
expect(() => assertInterruptedChat({ ...valid, activeAtFollowup: run })).toThrow();
expect(() => assertInterruptedChat({ ...valid, activeAtFollowup: { ...valid.activeAtFollowup, id: "other" } })).toThrow();
});
it("rejects lost or duplicated input, missing file evidence, and stale plans", () => {
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments, { ...valid.comments[2]!, id: "duplicate-reply" }] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments.slice(0, 2), { ...valid.comments[2]!, createdByRunId: "unrelated" }] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, comments: valid.comments.filter(c => c.id !== "followup") })).toThrow();
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments, { id: "duplicate", body: valid.followup }] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, comments: [...valid.comments.slice(0, 2), { id: "answer", authorAgentId: "agent", body: "UPDATED123" }] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, revisedPlan: valid.revisedPlan.replace("Friday", "Monday") })).toThrow();
expect(() => assertInterruptedChat({ ...valid, revisedPlan: "The updated plan is saved." })).toThrow();
});
it("rejects failed, unfinished, duplicated, or unrelated execution", () => {
for (const status of ["running", "queued", "failed", "cancelled"])
expect(() => assertInterruptedChat({ ...valid, runs: [{ ...run, status }] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, runs: [] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, runs: [run, run, run] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, runs: [run, run] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, runs: [{ ...run, id: "other" }] })).toThrow();
expect(() => assertInterruptedChat({ ...valid, runs: [{ ...run, contextSnapshot: { issueId: "other" } }] })).toThrow();
});
it("keeps setup and interruption stories native, local, opt-in, and outside API-tool overrides", () => {
const cells = runnerMatrix.filter(cell => cell.suite.id === "agent-chat-stories");
expect(cells).toHaveLength(6);
for (const cell of cells) {
expect(cell.suite.manualOnly).toBe(true);
expect(cell.profile.generation).toBe("native");
expect(cell.environment.id).toBe("local");
expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_API_TOOLS_ENABLED).toBeUndefined();
}
});
});
describe("bounded chat brief fixture", () => {
it("publishes the complete brief atomically while a real worker is waiting", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "chat-brief-atomic-"));
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
let releaseWrite!: () => void;
const pausedWrite = new Promise<void>(resolve => { releaseWrite = resolve; });
let exposedEmptyWrite!: (file: Parameters<typeof writeFile>[0]) => void;
const emptyWrite = new Promise<Parameters<typeof writeFile>[0]>(resolve => { exposedEmptyWrite = resolve; });
let running: Promise<{ stdout: string; stderr: string }> | undefined;
let publishing: Promise<void> | undefined;
try {
const brief = await prepareChatBrief(directory, "atomic", 5_000);
running = promisify(execFile)(process.execPath, [brief.scriptPath]);
await expect.poll(() => readFile(brief.ready, "utf8").catch(() => "")).toBe("waiting");
vi.mocked(writeFile).mockImplementationOnce(async (file, data, options) => {
await actual.writeFile(file, "");
exposedEmptyWrite(file);
await pausedWrite;
await actual.writeFile(file, data, typeof options === "object" ? { ...options, flag: "w" } : options);
});
publishing = publishChatBrief(brief.gate, "A complete multiword brief.");
const stagedFile = await emptyWrite;
expect(await readFile(stagedFile, "utf8")).toBe("");
await expect(readFile(brief.gate, "utf8")).rejects.toHaveProperty("code", "ENOENT");
releaseWrite();
await publishing;
expect((await running).stdout.trim()).toBe("A complete multiword brief.");
} finally {
releaseWrite();
await publishing;
await running?.catch(() => undefined);
vi.mocked(writeFile).mockImplementation(actual.writeFile);
await rm(directory, { recursive: true, force: true });
}
});
it("releases a waiting worker and rejects an unbounded wait", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "chat-brief-bounded-"));
try {
for (const timeout of [0, -1, Infinity, 300_001])
await expect(prepareChatBrief(directory, "invalid", timeout)).rejects.toThrow("bounded");
const brief = await prepareChatBrief(directory, "release", 2_000);
const running = promisify(execFile)(process.execPath, [brief.scriptPath]);
await expect.poll(() => readFile(brief.ready, "utf8").catch(() => "")).toBe("waiting");
await publishChatBrief(brief.gate, "Meet at half past ten.");
expect((await running).stdout.trim()).toBe("Meet at half past ten.");
const expires = await prepareChatBrief(directory, "expires", 1);
await expect(promisify(execFile)(process.execPath, [expires.scriptPath])).rejects.toMatchObject({ code: 1 });
} finally { await rm(directory, { recursive: true, force: true }); }
});
});