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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 20:53:33 +00:00
1 parent 1b0f78100d
commit 22281d4b3a
51 files changed
+2015 -116

No files matched your search

@@ -169,6 +169,116 @@ describe("native plan presentation", () => {
});
});
describe("native proposed-plan presentation", () => {
it("streams Codex plan-item deltas into one live proposal and settles on the completed document", () => {
const transcript = nativeRunEventsToTranscript([
itemEvent(1, "item.started", "plan-item-1", { kind: "plan", channel: "detail", text: "" }),
itemEvent(2, "item.delta", "plan-item-1", { kind: "plan", channel: "detail", text: "<proposed_plan>\n# Stop duplicates\n" }),
itemEvent(3, "item.delta", "plan-item-1", { kind: "plan", channel: "detail", text: "\n1. Add a unique index" }),
event(4, "plan.proposed", {
schema: "paperclip.plan.proposed.v1",
proposalId: "plan-item-1",
status: "completed",
markdown: "# Stop duplicates\n\n1. Add a unique index\n2. Upsert",
source: "codex_plan_item",
}),
]);
const proposals = transcript.filter((entry) => entry.kind === "provider_activity" && entry.family === "plan_proposal");
expect(proposals).toEqual([
expect.objectContaining({ status: "running", payload: expect.objectContaining({ markdown: "# Stop duplicates" }) }),
expect.objectContaining({ status: "running", payload: expect.objectContaining({ markdown: "# Stop duplicates\n\n1. Add a unique index" }) }),
expect.objectContaining({ status: "completed", summary: "Proposed a plan" }),
]);
const live = transcriptToTaskChatItems(transcript.slice(0, -1), { runId: RUN_ID, agentName: "Codex", running: true })
.filter((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan_proposal");
expect(live).toHaveLength(1);
expect(live[0]).toMatchObject({ status: "running", output: "# Stop duplicates\n\n1. Add a unique index" });
const settled = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Codex", running: false })
.filter((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan_proposal");
expect(settled).toHaveLength(1);
expect(settled[0]).toMatchObject({ status: "completed", output: "# Stop duplicates\n\n1. Add a unique index\n2. Upsert" });
});
it("shows a Codex message's <proposed_plan> block only through the proposal card", () => {
const transcript = nativeRunEventsToTranscript([
itemEvent(1, "item.completed", "msg-1", {
kind: "agentMessage",
channel: "final",
text: "The plan is ready for your approval.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>",
}),
event(2, "plan.proposed", {
schema: "paperclip.plan.proposed.v1",
proposalId: "msg-1",
status: "completed",
markdown: "# Fix\n1. Ship",
source: "codex_plan_item",
}),
]);
expect(transcript).toEqual([
expect.objectContaining({ kind: "assistant", text: "The plan is ready for your approval." }),
expect.objectContaining({ kind: "provider_activity", family: "plan_proposal", status: "completed" }),
]);
});
it("keeps a message's plan text when no proposal was recorded for it", () => {
const text = "Draft plan below.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>";
const transcript = nativeRunEventsToTranscript([
itemEvent(1, "item.completed", "msg-old", { kind: "agentMessage", channel: "final", text }),
]);
expect(transcript).toEqual([expect.objectContaining({ kind: "assistant", text })]);
});
it("hides the cut-off remainder of a proposal in a shortened message preview", () => {
const transcript = nativeRunEventsToTranscript([
itemEvent(1, "item.completed", "msg-1", {
kind: "agentMessage",
channel: "final",
text: "Plan ready.\n<proposed_plan>\n# Fix\n1. Ship…[truncated]",
}),
event(2, "plan.proposed", {
schema: "paperclip.plan.proposed.v1", proposalId: "msg-1", status: "completed",
markdown: "# Fix\n1. Ship\n2. Verify", source: "codex_plan_item",
}),
]);
expect(transcript[0]).toMatchObject({ kind: "assistant", text: "Plan ready." });
const fenced = "Format:\n```\n<proposed_plan>\n# Plan…[truncated]";
const fencedTranscript = nativeRunEventsToTranscript([
itemEvent(1, "item.completed", "msg-2", { kind: "agentMessage", channel: "final", text: fenced }),
event(2, "plan.proposed", {
schema: "paperclip.plan.proposed.v1", proposalId: "msg-2", status: "completed",
markdown: "# Other", source: "codex_plan_item",
}),
]);
expect(fencedTranscript[0]).toMatchObject({ kind: "assistant", text: fenced });
});
it("shows a completed proposal's example tags unchanged on its card", () => {
const markdown = "# Plan\n\n1. Format: `<proposed_plan>x</proposed_plan>`";
const transcript = nativeRunEventsToTranscript([event(1, "plan.proposed", {
schema: "paperclip.plan.proposed.v1", proposalId: "p-1", status: "completed", markdown, source: "claude_exit_plan_mode",
})]);
expect(transcript[0]).toMatchObject({ family: "plan_proposal", payload: expect.objectContaining({ markdown }) });
});
it("keeps a Claude ExitPlanMode proposal out of the turn checklist", () => {
const transcript = nativeRunEventsToTranscript([event(1, "plan.proposed", {
schema: "paperclip.plan.proposed.v1",
proposalId: "toolu_plan",
status: "completed",
markdown: "# Plan\n\n1. Ship it",
source: "claude_exit_plan_mode",
})]);
expect(transcript).toEqual([
expect.objectContaining({ kind: "provider_activity", family: "plan_proposal", status: "completed" }),
]);
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: false });
expect(items.some((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan")).toBe(false);
});
});
describe("nativeRunEventsToTranscript", () => {
describe("accepted response-wake authority", () => {
function fixture() {
@@ -93,6 +93,80 @@ function isItemIdentityEvent(eventType: string): boolean {
|| eventType === "item.completed";
}
/**
* Codex plan mode wraps a streaming plan item in `<proposed_plan>` tags. Only
* that outer wrapper is removed; example tags inside the plan stay text.
*/
function proposedPlanMarkdown(value: string): string {
const trimmed = value.trim();
const open = /^<proposed_plan>[ \t]*(?:\n|$)/i.exec(trimmed);
if (!open) return trimmed;
return trimmed.slice(open[0].length).replace(/(?:^|\n)[ \t]*<\/proposed_plan>[ \t]*$/i, "").trim();
}
/**
* Splits a message around one `<proposed_plan>` block. The tags must sit on
* their own lines outside fenced code, so quoting or documenting the tag never
* creates a proposal.
*/
function splitProposedPlanBlock(value: string): { plan: string | null; rest: string; unclosedStart: number | null } {
const lines = value.split("\n");
let fence: string | null = null;
let start = -1;
for (let index = 0; index < lines.length; index += 1) {
const trimmed = lines[index]!.trim();
// Fences are tracked inside the block too, so a fenced example tag in
// the plan never opens or closes it.
const marker = /^(`{3,}|~{3,})/.exec(trimmed)?.[1];
if (marker) {
fence = fence === null ? marker[0]! : (trimmed.startsWith(fence) ? null : fence);
continue;
}
if (fence !== null) continue;
if (start < 0 && /^<proposed_plan>$/i.test(trimmed)) start = index;
else if (start >= 0 && /^<\/proposed_plan>$/i.test(trimmed)) {
return {
plan: lines.slice(start + 1, index).join("\n").trim(),
rest: [...lines.slice(0, start), ...lines.slice(index + 1)].join("\n").trim(),
unclosedStart: null,
};
}
}
// An opening tag outside fences that never closes: a cut-off preview.
return {
plan: null,
rest: value,
unclosedStart: start < 0 ? null : lines.slice(0, start).join("\n").length,
};
}
function withoutProposedPlanBlock(value: string): string {
const { plan, rest, unclosedStart } = splitProposedPlanBlock(value);
if (plan !== null) return rest;
// A stored preview can end inside the block; hide its cut-off remainder.
return unclosedStart === null ? value : value.slice(0, unclosedStart).trim();
}
function planProposalEntry(
ts: string,
eventType: string,
proposalId: string,
markdown: string,
completed: boolean,
payload: Record<string, unknown>,
): TranscriptEntry {
return {
kind: "provider_activity",
ts,
family: "plan_proposal",
eventType,
status: completed ? "completed" : "running",
title: "Plan",
summary: completed ? "Proposed a plan" : "Writing a plan",
payload: { ...payload, proposalId, markdown, status: completed ? "completed" : "streaming" },
};
}
const TOOL_EXECUTION_SCHEMA = "paperclip.tool.execution.v1";
const RUN_RESULT_SCHEMA = "paperclip.run_result.v1";
const RUN_TERMINAL_SCHEMA = "paperclip.prp.terminal.v1";
@@ -815,6 +889,15 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
// An edit call reports an optimistic patch when it starts and the exact one
// when it lands. The row shows the latest one, wherever it first appears.
const fileChangesByExecutionId = new Map<string, unknown[]>();
// Messages whose `<proposed_plan>` block became a completed proposal show
// it through the proposal card; any other message keeps its text.
const proposedMessageIds = new Set<string>();
for (const event of orderedEvents) {
if (event.eventType !== "plan.proposed") continue;
const payload = record(record(event.payload?.prpEvent)?.payload);
const proposalId = text(payload?.proposalId);
if (proposalId && payload?.status === "completed") proposedMessageIds.add(proposalId);
}
for (const event of orderedEvents) {
if (!event.eventType.startsWith("tool.execution.")) continue;
const payload = record(record(event.payload?.prpEvent)?.payload);
@@ -872,6 +955,8 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
}
const itemIdentityById = new Map<string, ItemIdentity>();
const proposalMarkdownById = new Map<string, string>();
const completedProposalIds = new Set<string>();
for (const event of orderedEvents) {
const envelope = record(event.payload?.prpEvent);
if (
@@ -906,7 +991,9 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
}
if (event.eventType === "item.completed" && isAssistantItemKind(itemKind)) {
const value = normalizedItemText(payload);
// A `<proposed_plan>` block is shown by its plan proposal card instead.
const raw = normalizedItemText(payload) ?? "";
const value = itemId && proposedMessageIds.has(itemId) ? withoutProposedPlanBlock(raw) : raw;
if (!value) continue;
const channel = itemIdentity.assistantChannel;
entries.push({ kind: "assistant", ts, text: value, channel, ...(itemId ? { itemId } : {}) });
@@ -1032,6 +1119,29 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
continue;
}
// A proposed plan document (Codex plan item, Claude ExitPlanMode) is the
// candidate for the task's Plan document, not the turn checklist.
if (event.eventType === "plan.proposed" && payload.schema === "paperclip.plan.proposed.v1" && text(payload.proposalId)) {
const proposalId = text(payload.proposalId)!;
const completed = payload.status === "completed";
// A completed proposal is already the unwrapped document; show it as is.
const markdown = text(payload.markdown) ?? proposedPlanMarkdown(proposalMarkdownById.get(proposalId) ?? "");
if (completed) completedProposalIds.add(proposalId);
proposalMarkdownById.set(proposalId, markdown);
entries.push(planProposalEntry(ts, event.eventType, proposalId, markdown, completed, payload));
continue;
}
// Codex streams the proposed plan as plan-item deltas before the
// completed plan.proposed event carries the whole document.
if (event.eventType === "item.delta" && itemKind === "plan" && itemId) {
if (completedProposalIds.has(itemId)) continue;
const accumulated = (proposalMarkdownById.get(itemId) ?? "") + (normalizedItemText(payload) ?? "");
proposalMarkdownById.set(itemId, accumulated);
entries.push(planProposalEntry(ts, "plan.proposed", itemId, proposedPlanMarkdown(accumulated), false, {}));
continue;
}
// Governance records (hooks, automatic approval reviews) keep their full
// payload so the task chat can show the decision, reason and target.
if ((event.eventType === "hook.started" || event.eventType === "hook.completed")