mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
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:
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")
|
||||
|
||||
Reference in new issue
Block a user