Files
PaperClipAI/tests/runner-e2e/chat-stories.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

189 lines
12 KiB
TypeScript

import { expect } from "@playwright/test";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { sendChatMessage, type ChatFlowInput, type ChatIssue, type ChatRun } from "./chat-flow.js";
import { setSettingsToggle } from "./settings-toggle.js";
import { resolveDefaultAgentWorkspaceDir } from "../../server/src/home-paths.js";
type Comment = { id: string; body: string; authorAgentId?: string; createdByRunId?: string };
type Context = {
input: ChatFlowInput; marker: string; issue(): ChatIssue;
idle(count: number): Promise<void>; allRuns(): Promise<ChatRun[]>; comments(): Promise<Comment[]>;
};
/** Seed only the disabled setting; the browser performs the user's opt-in. */
export async function enableChatThroughSettings(input: ChatFlowInput) {
await input.api.patch("/api/instance/settings/experimental", { enableAgentChat: false, enableClassicTaskInterface: false });
await setChatEnabled(input, true);
}
async function setChatEnabled(input: ChatFlowInput, enabled: boolean) {
await input.page.goto(`/${input.fixtures.company.issuePrefix}/company/settings/instance/experimental`, { waitUntil: "domcontentloaded" });
const toggle = input.page.getByRole("switch", { name: "Toggle agent chat experimental setting" });
await setSettingsToggle(toggle, enabled);
await expect.poll(async () => (await input.api.get<{ enableAgentChat: boolean }>("/api/instance/settings/experimental")).enableAgentChat).toBe(enabled);
}
export function assertInterruptedChat(input: {
first: string; followup: string; reference: string; marker: string; issueId: string;
boundaryRun: ChatRun; activeAtFollowup: ChatRun; comments: Comment[]; runs: ChatRun[];
revisedPlan?: string;
}) {
expect(input.boundaryRun.status).toBe("running");
expect(input.activeAtFollowup).toMatchObject({ id: input.boundaryRun.id, status: "running" });
for (const message of [input.first, input.followup]) {
expect(input.comments.filter(comment => !comment.authorAgentId && comment.body === message)).toHaveLength(1);
}
const replies = input.comments.filter(comment => comment.authorAgentId);
const final = replies.at(-1)!;
expect(final).toBeTruthy();
expect(final.body).toContain(input.reference);
expect(final.body).toContain(input.marker);
expect(replies.filter(reply => reply.body.includes(input.marker))).toHaveLength(1);
expect(input.runs.length).toBeGreaterThanOrEqual(1);
expect(input.runs.length).toBeLessThanOrEqual(2);
expect(input.runs.every(run => run.status === "succeeded" && run.runtimeMode === "native")).toBe(true);
expect(new Set(input.runs.map(run => run.id)).size).toBe(input.runs.length);
expect(input.runs.every(run => run.agentId === input.boundaryRun.agentId && run.contextSnapshot?.issueId === input.issueId)).toBe(true);
expect(final.authorAgentId).toBe(input.boundaryRun.agentId);
const responseRun = input.runs.length === 1
? input.boundaryRun
: input.runs.find(run => run.id !== input.boundaryRun.id)!;
expect(final.createdByRunId).toBe(responseRun.id);
expect(input.runs.some(run => run.id === input.boundaryRun.id)).toBe(true);
if (input.revisedPlan !== undefined) {
const body = input.revisedPlan.trim().replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "");
expect(JSON.parse(body)).toEqual({ launchDay: "Friday", reference: input.reference, revision: input.marker });
}
}
export async function publishChatBrief(gate: string, content: string) {
const staging = `${gate}.${randomUUID()}.tmp`;
try {
await writeFile(staging, content, { encoding: "utf8", flag: "wx" });
await rename(staging, gate);
} finally {
await rm(staging, { force: true });
}
}
export async function prepareChatBrief(workspacePath: string, nonce: string, timeoutMs = 120_000) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000) throw new Error("Brief wait must be bounded between 1 and 300000ms");
await mkdir(workspacePath, { recursive: true });
const gate = path.join(workspacePath, `chat-brief-${nonce}.txt`);
const ready = `${gate}.waiting`;
const scriptPath = path.join(workspacePath, `wait-for-brief-${nonce}.cjs`);
const script = `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(ready)},"waiting");const end=Date.now()+${timeoutMs};const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(gate)})){console.log(fs.readFileSync(${JSON.stringify(gate)},"utf8"));clearInterval(timer)}else if(Date.now()>end){clearInterval(timer);process.exitCode=1}},100);`;
await writeFile(scriptPath, script, "utf8");
return { gate, ready, scriptPath };
}
/**
* A bounded brief wait that resolves only from the agent's working directory.
* Sandboxed native providers can read their own task directory but not agent
* homes or unrelated project roots, so the command names no host path. The
* fixture publishes to `at(<the run's workspace>)` once that directory is known.
*/
export function workspaceChatBrief(nonce: string, timeoutMs = 120_000) {
if (!/^[A-Za-z0-9_-]+$/.test(nonce)) throw new Error("Brief nonce must be a plain file name segment");
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 300_000) throw new Error("Brief wait must be bounded between 1 and 300000ms");
const name = `chat-brief-${nonce}.txt`;
const script = `const fs=require("node:fs"),g="${name}",end=Date.now()+${Math.floor(timeoutMs)};fs.writeFileSync(g+".waiting","waiting");const t=setInterval(()=>{if(fs.existsSync(g)){console.log(fs.readFileSync(g,"utf8"));clearInterval(t)}else if(Date.now()>end){clearInterval(t);process.exitCode=1}},100)`;
return {
command: `node -e '${script}'`,
at: (workspacePath: string) => ({ gate: path.join(workspacePath, name), ready: path.join(workspacePath, `${name}.waiting`) }),
};
}
export async function runChatInterruption(context: Context & { refreshIssue(): Promise<void> }) {
const { input, marker, idle, allRuns, comments } = context;
// Unprojected host /tmp files are intentionally hidden from native Codex.
// These projectless chats use the normal agent-home workspace, not the
// harness's separate project fixture directory. Keep the asset inside it.
const agentWorkspace = resolveDefaultAgentWorkspaceDir(input.fixtures.agent.id);
const relative = path.relative(path.dirname(input.workspacePath), agentWorkspace);
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Chat fixture workspace escaped the isolated instance");
const { gate, ready, scriptPath } = await prepareChatBrief(agentWorkspace, input.nonce);
const reference = `BRIEF${randomUUID().replaceAll("-", "")}`;
// Real provider tool execution waits on an ordinary fixture file. No runner
// hooks, provider results, database records, or task outcomes are fabricated.
// Keep JavaScript in a seeded fixture file: rich-text input serializes raw
// operators as Markdown escapes, which should not alter a timing fixture.
const command = `node ${scriptPath}`;
const first = `Our launch is planned for Monday. First run this bounded command to wait for the brief reference file I am supplying: ${command}\nAfter it returns, acknowledge the reference from the file here. This is discussion only; do not create projects or tasks.`;
const revise = input.execution.task.id === "revise-while-running";
const followup = revise
? `Change the launch day to Friday. Save the plan on this conversation as JSON with exactly launchDay, reference (from the brief file), and revision ("${marker}"). Then reply here with the brief reference and ${marker}. Do not create projects or execution tasks.`
: `After reading the brief, reply here with its reference and ${marker}. Keep this in the current conversation; do not create projects or tasks.`;
let boundaryRun: ChatRun | undefined;
let activeAtFollowup: ChatRun | undefined;
try {
await sendChatMessage(input.page, first);
await expect.poll(async () => {
if (await readFile(ready, "utf8").catch(() => "") !== "waiting") return false;
boundaryRun = (await allRuns()).find(run => run.status === "running");
return Boolean(boundaryRun);
}, { timeout: 120_000 }).toBe(true);
expect(boundaryRun!.contextSnapshot?.paperclipWorkspace).toMatchObject({ cwd: agentWorkspace });
await context.refreshIssue();
await sendChatMessage(input.page, followup);
await expect.poll(async () => (await comments()).filter(comment => !comment.authorAgentId && comment.body === followup).length,
{ timeout: 30_000 }).toBe(1);
activeAtFollowup = await input.api.get<ChatRun>(`/api/heartbeat-runs/${boundaryRun!.id}`);
await input.evidence("chat-interruption-boundary.json", { first, followup, boundaryRun, activeAtFollowup, comments: await comments() });
expect(activeAtFollowup.status, "Follow-up must persist while the first provider run is active").toBe("running");
await publishChatBrief(gate, reference);
await expect.poll(async () => (await comments()).filter(comment => comment.authorAgentId).at(-1)?.body ?? "", { timeout: 240_000 }).toContain(marker);
await idle(1); // Providers may queue a new run or steer the current run.
const plan = revise ? await input.api.get<{ body: string }>(`/api/issues/${context.issue().id}/documents/plan`) : undefined;
const evidence = { first, followup, reference, marker, issueId: context.issue().id, boundaryRun: boundaryRun!, activeAtFollowup,
comments: await comments(), runs: await allRuns(), ...(plan ? { revisedPlan: plan.body } : {}) };
await input.evidence("chat-interruption.json", evidence);
assertInterruptedChat(evidence);
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/projects`)).toEqual([]);
} finally {
// Always release a provider that is still waiting, including failed UI runs.
await publishChatBrief(gate, reference);
const chatPath = `/api/companies/${input.fixtures.company.id}/chats/${input.fixtures.agent.id}`;
const current = await input.api.get<ChatIssue | null>(chatPath).catch(() => null);
await input.evidence("chat-interruption-state.json", {
issue: current,
runs: await allRuns().catch(error => ({ readError: String(error) })),
comments: current ? await input.api.get(`/api/issues/${current.id}/comments?order=asc`).catch(error => ({ readError: String(error) })) : [],
plan: current && revise ? await input.api.get(`/api/issues/${current.id}/documents/plan`).catch(error => ({ readError: String(error) })) : null,
});
}
}
export async function runChatSettingsLifecycle(context: Context) {
const { input, idle, comments, allRuns, marker } = context;
const remembered = `REMEMBER${marker}`;
const route = `/${input.fixtures.company.issuePrefix}/chats/${input.fixtures.agent.id}`;
await sendChatMessage(input.page, `Remember ${remembered} in this conversation. Acknowledge only; no tasks or projects.`);
await idle(1);
const before = { issue: context.issue(), comments: await comments(), runs: await allRuns() };
await setChatEnabled(input, false);
// Full document navigation clears the client query cache. Unlike SPA
// navigation, this verifies the disabled entry page without cached history.
await input.page.goto(route, { waitUntil: "domcontentloaded" });
await expect(input.page.getByText(/Agent Chat is disabled/)).toBeVisible();
const blocked = await input.api.request.post(`/api/issues/${before.issue.id}/comments`, { data: { body: `DISABLED${marker}` } });
expect(blocked.status()).toBe(404);
expect(await comments()).toEqual(before.comments);
expect((await allRuns()).map(run => ({ id: run.id, status: run.status })))
.toEqual(before.runs.map(run => ({ id: run.id, status: run.status })));
await setChatEnabled(input, true);
await input.page.goto(route, { waitUntil: "domcontentloaded" });
await sendChatMessage(input.page, `What reference did I ask you to remember? Reply with it and ${marker}. No new work.`);
await idle(2);
expect(context.issue().id).toBe(before.issue.id);
expect(context.issue().conversationSessionGeneration).toBe(before.issue.conversationSessionGeneration);
const reply = (await comments()).filter(comment => comment.authorAgentId).at(-1)?.body ?? "";
expect(reply).toContain(remembered);
expect(reply).toContain(marker);
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
await input.evidence("chat-settings-lifecycle.json", { before, after: { issue: context.issue(), comments: await comments(), runs: await allRuns() } });
}