Files
PaperClipAI/server/src/__tests__/native-plan-proposal-sync.test.ts
T
DottaandClaude Opus 5.5 22281d4b3a feat(task-chat): approve harness-proposed plans (Codex plan mode, Claude ExitPlanMode) (#19291)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through the native runner. Codex and Claude Code each
have their own plan mode, and each produces a proposed-plan document.
> - Paperclip already has a planning work mode, an issue `plan`
document, and a `request_confirmation` approval gate for plans.
> - These parts were not connected. The Codex proposed-plan item was
transcript text only. The server path never put Codex into plan mode.
Claude was never put into plan mode, and its ExitPlanMode plan was
dropped. A planning run could close its task as done with no approval.
> - This pull request turns a harness's own proposed plan into the
issue's Plan document and the existing approval gate, for Codex and
Claude.
> - The benefit is that users approve or send back the agent's real plan
from the task chat. Approve resumes the agent. Request changes sends the
reason back to the agent.

## Linked Issues or Issue Description

Refs #19252

**Subsystem affected**
Native runner provider events (Codex and ACPX Claude), the runner Codex
provider (Rust), server native plan synchronization, and the task chat
UI.

**Problem or motivation**
Codex and Claude both produce a proposed-plan document in their native
plan mode. Paperclip did not use it. In live tests on the server path, a
Codex planning run was not in Codex plan mode, saved its own
`implementation-plan` document, and marked the task done without
approval. Claude planning runs were not in Claude plan mode, and the
ExitPlanMode plan text never reached Paperclip.

**Proposed solution**
Add a canonical `plan.proposed` event for a harness's proposed plan. Put
Codex and Claude into their native plan mode for planning runs. Save a
completed proposal as the issue `plan` document with the existing
`request_confirmation` gate. Agents can also propose a plan on a
standard task; the work mode stays standard.

**Alternatives considered**
Reuse `plan.updated`. Its schema is the within-turn checklist and is not
the durable Plan document, so a separate event keeps the two clear.
Stronger prompt text without native plan mode. Live tests showed that
the model kept its own document workflow.

**Roadmap alignment**
This reuses the issue plan document, planning work mode and approval
gate that already exist. It adds one canonical provider event.

## What Changed

- `packages/paperclip-runner/protocol/schemas/*`: add the
`plan.proposed` event (`paperclip.plan.proposed.v1`: `proposalId`,
`status`, `markdown` up to 60k characters, `source`). Regenerate the
schema bundle, validators and manifest.
- `packages/paperclip-runner/src/provider-events.ts`: map the Codex
`plan` item and a `<proposed_plan>` block in a completed Codex message
to `plan.proposed`. Add `claudePlanProposalEvent`.
-
`packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs`:
send Codex's native `collaborationMode: plan` on `turn/start` for a
read-only (planning) Codex run. Before this change the server path never
sent it.
- `runner-core/src/acpx_event_payload.rs`: admit `plan.proposed` as ACPX
rich display activity.
- `drivers/acpx/codex-runtime-adapter.ts`: set Claude Code to native
plan mode for a read-only run. Capture Claude's ExitPlanMode plan from
its permission request, record it, and decline the native "Ready to
code?" prompt.
- `drivers/acpx/codex-acpx-driver.ts`, `cli/acpx-runtime-sidecar.ts`:
emit the captured proposal as a rich event. Complete that turn instead
of reporting it as cancelled.
- `backends/codex-native-backend.ts`: per-harness plan-mode
instructions, and a short opt-in hint for Claude on standard tasks.
- `server/src/services/native-runtime/native-session-executor.ts`:
synchronize a completed `plan.proposed` into the `plan` document and a
`request_confirmation` in planning and standard mode. Do not change the
issue status during the run (that revoked the run's lease). The run
finalizer turns the pending confirmation into the review wait.
- `server/src/services/heartbeat-run-summary.ts`: keep a
`<proposed_plan>` block out of the posted reply.
- UI: new `plan_proposal` provider family. The task chat shows a live
"Writing plan" card while a proposal streams and a one-line record when
it completes. Resolved plan reviews keep a receipt ("Approved Plan v1",
"Requested changes to Plan v1: reason"). Plan previews skip section
headings.
- Storybook: `Tasks and conversations/.../Task chat/Proposed plan
approval` shows the journey.
- `doc/SPEC-implementation.md`: describe harness-proposed plan approval.

## Verification

- `cargo test -p paperclip-runner-core --lib` (new:
`read_only_codex_turns_use_native_plan_collaboration_mode`)
- `cd packages/paperclip-runner && npx vitest run
src/provider-events.test.ts
src/drivers/acpx/codex-runtime-adapter.test.ts`
- `cd server && npx vitest run
src/__tests__/native-plan-proposal-sync.test.ts
src/__tests__/heartbeat-run-summary.test.ts`
- `cd ui && npx vitest run
src/components/transcript/native-run-events.test.ts
src/components/task-chat`
- Live test on a local worktree instance with real harnesses:
- Codex (`gpt-5.6-sol`), planning task: Codex asked one question, then
produced a native plan item. The task showed Plan v1 with Approve plan /
Request changes and moved to in review. Approve changed the work mode to
standard. Codex then implemented the plan and its tests passed.
- Codex, Request changes with a reason: Codex woke in planning mode and
produced Plan v2 that includes the change.
- Claude (`claude-opus-5-5`), planning task: Claude was in plan mode.
ExitPlanMode became Plan v1 and the turn completed. Approve resumed
Claude in standard mode, and Claude implemented the plan.
- Claude, standard task that asks for a plan first: Claude stopped
before code changes and asked for plan approval. The work mode stayed
standard.
- Storybook: open `Proposed plan approval` and check each stage.

## Risks

- Codex planning runs now really use Codex plan mode on the server path.
Codex can ask a clarifying question before it proposes. This matches
Codex plan mode.
- Claude planning runs now use Claude plan mode. A declined ExitPlanMode
ends the Claude turn; the runner reports it as completed. Claude's tool
row for that request records a rejection from Claude's side.
- Codex does not stream the proposed plan on the server path yet, so the
live card shows a placeholder until the plan completes.
- A `<proposed_plan>` block in any completed Codex message on a standard
task now asks for approval. This is the documented opt-in path.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with
tool use (shell, file edits, browser). Long context. No extended
thinking mode was used.

## 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
- [ ] 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-05 20:53:33 +00:00

201 lines
9.2 KiB
TypeScript

import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
createDb,
documentRevisions,
documents,
goals,
heartbeatRuns,
issueDocuments,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import { synchronizeCompletedProviderPlan } from "../services/native-runtime/native-session-executor.js";
import { documentService } from "../services/documents.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_PLAN_PROPOSAL_TEST_DATABASE_URL");
const embeddedPostgresSupport = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const PLAN = "# Stop duplicate webhook deliveries\n\n1. Add a unique index.\n2. Upsert on the event id.";
describeEmbeddedPostgres("harness-proposed plan synchronization", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plan-proposal-sync-");
db = createDb(external ?? tempDb!.connectionString, { maxConnections: 6 });
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(goals);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
async function seed(workMode: "standard" | "planning" | "ask") {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: "PPS", requireBoardApprovalForNewAgents: false });
await db.insert(goals).values({ id: goalId, companyId, title: "Ship", level: "task", status: "active" });
await db.insert(agents).values({
id: agentId, companyId, name: "Claude", role: "engineer", status: "active",
adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
});
await db.insert(issues).values({
id: issueId, companyId, goalId, title: "Fix duplicate deliveries", status: "in_progress",
priority: "medium", assigneeAgentId: agentId, workMode,
});
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { issueId } });
const binding = { runId, issueId, companyId, agentId };
return { companyId, issueId, agentId, runId, binding };
}
function proposalEvent(markdown: string, status: "streaming" | "completed" = "completed") {
return {
sourceEventId: `event-${randomUUID()}`,
turnId: "turn-1",
eventType: "plan.proposed",
payload: {
schema: "paperclip.plan.proposed.v1",
proposalId: "toolu_plan",
status,
markdown,
source: "claude_exit_plan_mode",
},
};
}
it("saves an agent-initiated proposal on a Standard task as the Plan document and asks for approval", async () => {
const { issueId, binding } = await seed("standard");
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "standard" } } as never;
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
expect(result).toMatchObject({ status: "synchronized", planId: "toolu_plan", documentRevision: 1 });
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
expect(interaction).toMatchObject({ kind: "request_confirmation", status: "pending", sourceRunId: binding.runId });
expect(interaction?.payload).toMatchObject({
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
target: { type: "issue_document", key: "plan", revisionNumber: 1 },
});
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
// The agent opted into a plan gate; the task's work mode stays Standard.
// The run finalizer turns the pending confirmation into the review wait.
expect(issue).toMatchObject({ status: "in_progress", workMode: "standard" });
});
it("revises an existing Plan document from its current revision", async () => {
const { issueId, binding } = await seed("standard");
await documentService(db).upsertIssueDocument({
issueId, key: "plan", title: "Plan", format: "markdown", body: "# Old plan", createdByAgentId: binding.agentId,
});
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "standard" } } as never;
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
expect(result).toMatchObject({ status: "synchronized", documentRevision: 2 });
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
});
it("lets a planning run's full proposal supersede its own earlier checklist sync", async () => {
const { issueId, binding } = await seed("planning");
const execution = {
binding,
provider: { kind: "codex" },
task: { workMode: "planning" },
executionMode: "plan",
planningContext: { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "none", reviewContext: {} },
} as never;
const checklist = await synchronizeCompletedProviderPlan({
db,
execution,
event: {
sourceEventId: "checklist-1",
eventType: "plan.updated",
payload: { schema: "paperclip.plan.updated.v1", planId: "turn-1", revision: 1, complete: true, steps: [{ stepId: "s1", body: "Inspect", status: "completed" }] },
},
});
expect(checklist).toMatchObject({ status: "synchronized", documentRevision: 1 });
const proposal = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
expect(proposal).toMatchObject({ status: "synchronized", documentRevision: 2 });
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
const pending = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.status, "pending"));
expect(pending.map((row) => (row.payload as { target?: { revisionNumber?: number } }).target?.revisionNumber)).toContain(2);
});
it("still refuses a proposal when someone else edited the plan after the pinned base", async () => {
const { issueId, binding } = await seed("planning");
await documentService(db).upsertIssueDocument({
issueId, key: "plan", title: "Plan", format: "markdown", body: "# Human edit", createdByUserId: "local-board",
});
const execution = {
binding,
provider: { kind: "codex" },
task: { workMode: "planning" },
executionMode: "plan",
planningContext: { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "none", reviewContext: {} },
} as never;
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
expect(result).toMatchObject({ status: "conflict" });
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe("# Human edit");
});
it("never writes the Plan document from an Ask-mode run", async () => {
const { issueId, binding } = await seed("ask");
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "ask" } } as never;
expect(await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) })).toBeNull();
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan")).toBeNull();
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).toEqual([]);
});
it("ignores streaming proposals and Standard-mode checklists", async () => {
const { issueId, binding } = await seed("standard");
const execution = { binding, provider: { kind: "codex" }, task: { workMode: "standard" } } as never;
expect(await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent("# Partial", "streaming") })).toBeNull();
expect(await synchronizeCompletedProviderPlan({
db,
execution,
event: {
sourceEventId: "checklist-1",
eventType: "plan.updated",
payload: { schema: "paperclip.plan.updated.v1", planId: "turn-1", revision: 1, complete: true, steps: [{ stepId: "s1", body: "Done", status: "completed" }] },
},
})).toBeNull();
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan")).toBeNull();
});
});