mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { IssueDocument } from "@paperclipai/shared";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import {
|
|
planPreviewContent,
|
|
TaskChatPlanPreviewCard,
|
|
} from "./TaskChatPlanPreviewCard";
|
|
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
|
import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
|
|
|
vi.mock("@/lib/router", () => ({ Link: ({ to, children, disableIssueQuicklook: _quicklook, ...props }: any) => <a href={to} {...props}>{children}</a> }));
|
|
|
|
function planDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
|
return {
|
|
id: "plan-document",
|
|
companyId: "company-1",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "# Streaming task UX\n\n## Implementation\n- Extract the shared card.\n2. Stream provider steps.\n3. Reconcile the saved revision.\n4. Verify the mobile layout.",
|
|
latestRevisionId: "plan-revision-4",
|
|
latestRevisionNumber: 4,
|
|
createdByAgentId: "agent-1",
|
|
createdByUserId: null,
|
|
updatedByAgentId: "agent-1",
|
|
updatedByUserId: null,
|
|
lockedAt: null,
|
|
lockedByAgentId: null,
|
|
lockedByUserId: null,
|
|
createdAt: new Date("2026-08-23T10:00:00.000Z"),
|
|
updatedAt: new Date("2026-08-23T10:01:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function livePlan(steps: TaskChatProviderActivityItem["steps"]): TaskChatProviderActivityItem {
|
|
return {
|
|
id: "live-plan",
|
|
kind: "protocol",
|
|
surface: "provider_activity",
|
|
family: "plan",
|
|
eventType: "plan.updated",
|
|
status: "running",
|
|
title: "Plan",
|
|
summary: "Implement the streaming UX.",
|
|
details: [{ label: "Revision", value: "5" }],
|
|
steps,
|
|
links: [],
|
|
children: [],
|
|
};
|
|
}
|
|
|
|
describe("TaskChatPlanPreviewCard", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it("extracts the Markdown H1 and the first three meaningful plan lines, skipping section headings", () => {
|
|
expect(planPreviewContent("# Ship it\n\n---\n## Build\n- **First** step\n2. Use [the docs](https://example.com)\n```\nFourth"))
|
|
.toEqual({ title: "Ship it", preview: ["First step", "Use the docs", "Fourth"] });
|
|
});
|
|
|
|
it("previews a streaming proposed-plan document from its markdown", () => {
|
|
const proposal: TaskChatProviderActivityItem = {
|
|
...livePlan([]),
|
|
family: "plan_proposal",
|
|
eventType: "plan.proposed",
|
|
output: "# Stop duplicates\n\n## Steps\n1. Add a unique index",
|
|
};
|
|
act(() => root.render(<ThemeProvider><TaskChatPlanPreviewCard source={{ kind: "live", activity: proposal }} /></ThemeProvider>));
|
|
expect(container.textContent).toContain("Writing plan");
|
|
expect(container.textContent).toContain("Stop duplicates");
|
|
expect(container.textContent).toContain("Add a unique index");
|
|
expect(container.textContent).not.toContain("Steps");
|
|
});
|
|
|
|
it("renders a saved revision as a Plan-pane link", () => {
|
|
act(() => root.render(
|
|
<ThemeProvider>
|
|
<TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument() }} />
|
|
</ThemeProvider>,
|
|
));
|
|
|
|
const preview = container.querySelector('[data-testid="task-chat-plan-preview"]');
|
|
expect(preview?.getAttribute("href")).toBe("#document-plan");
|
|
expect(preview?.getAttribute("aria-label")).toBe("Open Plan revision 4");
|
|
expect(preview?.textContent).toContain("Plan· rev 4");
|
|
expect(preview?.textContent).toContain("Streaming task UX");
|
|
expect(preview?.textContent).toContain("Extract the shared card.");
|
|
expect(preview?.textContent).not.toContain("Verify the mobile layout.");
|
|
});
|
|
|
|
it("replaces saved preview content when the canonical revision updates", () => {
|
|
const render = (document: IssueDocument) => act(() => root.render(
|
|
<ThemeProvider>
|
|
<TaskChatPlanPreviewCard source={{ kind: "saved", document }} />
|
|
</ThemeProvider>,
|
|
));
|
|
render(planDocument());
|
|
render(planDocument({
|
|
body: "# Revised streaming UX\n- Preserve final-answer streaming.\n- Preserve provider redaction.",
|
|
latestRevisionId: "plan-revision-5",
|
|
latestRevisionNumber: 5,
|
|
}));
|
|
|
|
expect(container.querySelectorAll('[data-testid="task-chat-plan-preview"]')).toHaveLength(1);
|
|
expect(container.textContent).toContain("Plan· rev 5");
|
|
expect(container.textContent).toContain("Revised streaming UX");
|
|
expect(container.textContent).not.toContain("Streaming task UX");
|
|
});
|
|
|
|
it("keeps Coordinator plans compact and links the saved revision in its owning task", () => {
|
|
act(() => root.render(<ThemeProvider><CoordinatorPresentation.Provider value>
|
|
<TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument() }} />
|
|
</CoordinatorPresentation.Provider></ThemeProvider>));
|
|
const card = container.querySelector("a")!;
|
|
expect(card.getAttribute("href")).toBe("/issues/issue-1#document-plan&revision=plan-revision-4");
|
|
expect(card.textContent).toContain("Open Streaming task UX");
|
|
expect(container.querySelector("ul")).toBeNull();
|
|
expect(container.textContent).not.toContain("Extract the shared card.");
|
|
});
|
|
|
|
it("does not invent a task link for an unsaved live plan", () => {
|
|
act(() => root.render(<ThemeProvider><CoordinatorPresentation.Provider value>
|
|
<TaskChatPlanPreviewCard source={{ kind: "live", activity: livePlan([{ id: "a", label: "Internal step", status: "in_progress" }]) }} />
|
|
</CoordinatorPresentation.Provider></ThemeProvider>));
|
|
expect(container.textContent).toContain("Writing Plan");
|
|
expect(container.textContent).not.toContain("Internal step");
|
|
expect(container.querySelector("a")).toBeNull();
|
|
});
|
|
|
|
it("updates a live provider preview in place as steps stream", () => {
|
|
const render = (steps: TaskChatProviderActivityItem["steps"]) => act(() => root.render(
|
|
<ThemeProvider>
|
|
<TaskChatPlanPreviewCard source={{ kind: "live", activity: livePlan(steps) }} />
|
|
</ThemeProvider>,
|
|
));
|
|
|
|
render([{ id: "one", label: "Inspect the existing card", status: "in_progress" }]);
|
|
const preview = container.querySelector('[data-testid="task-chat-plan-preview"]');
|
|
expect(preview?.tagName).toBe("SECTION");
|
|
expect(preview?.textContent).toContain("Writing plan· rev 5");
|
|
expect(preview?.textContent).toContain("Inspect the existing card");
|
|
|
|
render([
|
|
{ id: "one", label: "Inspect the existing card", status: "completed" },
|
|
{ id: "two", label: "Render streamed plan steps", status: "in_progress" },
|
|
]);
|
|
expect(container.querySelectorAll('[data-testid="task-chat-plan-preview"]')).toHaveLength(1);
|
|
expect(container.textContent).toContain("Render streamed plan steps");
|
|
});
|
|
});
|