mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
feat(task-chat): show the agent's native plan as a task list above the composer (#19252)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through the native runner. The runner turns each harness's native plan into a canonical `plan.updated` event. > - The task chat did not show these plans. A small "Step 2 / 5" pill showed the checklist only on hover, and in live runs it received no plan data. > - Live tests found three breaks. The UI flattened `plan.updated` into tool rows. Headless Claude Code hides its task tools from current models. Codex ships its `update_plan` tool disabled. > - This pull request fixes the three breaks and replaces the pill with a docked plan card above the composer. > - The benefit is that users see what the agent finished, what it does now, and what comes next. The view is the same for Claude, Codex, Grok and Copilot. ## Linked Issues or Issue Description **Subsystem affected** Task chat UI (turn status island), native runner ACPX launch environment for Claude, and native runner Codex app-server launch arguments. **Problem or motivation** Harnesses report native plans, and the runner records them as `plan.updated` events. Users do not see them. The live transcript builder converts `plan.updated` into generic tool rows, so the plan steps are lost. Claude Code in headless mode does not offer TaskCreate/TaskUpdate for current models, so Claude never makes a plan. Codex 0.160 has `update_plan` disabled by default, so Codex writes its checklist into a Paperclip document instead. **Proposed solution** Keep the full plan snapshot in the live transcript. Enable the Claude task tools with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` in the ACPX launch environment. Enable the Codex plan tool with `tools.update_plan.enabled=true`. Show the plan as a docked card with clear rules for when it shows, collapses and hides. **Alternatives considered** Show the plan only in the hover popover (the old behavior). Users did not find it. Write the plan into the issue Plan document. That mixes a per-turn checklist with the reviewed planning document. **Roadmap alignment** This makes existing native runner plan events visible. It adds no new protocol or data model. **Additional context** Grok and Copilot needed no harness change. Cursor could not start in a local worktree instance (an unrelated startup admission problem), so this PR does not verify Cursor. ## What Changed - `ui/src/components/transcript/native-run-events.ts`: keep `plan.updated` as `provider_activity` with family `plan` and the full payload, not as a tool row. - `ui/src/components/task-chat/TaskChatTurnStatusIsland.tsx`: replace the hover pill with a docked plan card. It shows done, current, upcoming and blocked steps, a progress bar and file-change totals. - Visibility rules: hidden with no steps and after the run ends; collapses when all steps are done; header only for one-step plans and while a question or approval is pending; window around the current step for plans over 7 steps; collapsed by default on mobile. - `ui/src/components/TaskChatThread.tsx`: pass `defaultCollapsed` (mobile) and `compact` (pending runtime request), and reset the card per run. - `ui/src/components/task-chat/transcript-adapter.ts`: keep the `failed` step status. - `packages/paperclip-runner/src/drivers/acpx/environment.ts`: set `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` for Claude launches. A host value cannot turn it off. - `packages/paperclip-runner/src/drivers/codex/codex-security-config.ts`: add `-c tools.update_plan.enabled=true` before the policy overrides and the `app-server` subcommand. - `ui/storybook/stories/tasks/task-chat-agent-plan.stories.tsx`: new story for each state. It uses the production composer dock, composer and question takeover. - Tests for all of the above. `ui/src/index.css` replaces the unused popover size token with a list max-height token. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/transcript src/components/task-chat src/components/TaskChatThread.test.tsx src/adapters/paperclip-runner` (1345 tests pass). - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/environment.test.ts src/drivers/codex` (pass). - UI and runner typecheck are clean. `pnpm check:token-gates` is clean. - Storybook: open "Tasks and conversations / … / Task chat / Agent plan". The "All States Gallery" story shows every state. "Live Run Simulation" animates a run. - Live test on a local worktree instance with real API keys: a multi-step task for each harness. The card showed and updated during the run for Claude (Opus 5.5), Codex (gpt-5.6-sol), Grok (grok-4.7) and Copilot (gpt-5.6-luna). For Claude the card collapsed when all steps were done and hid after the run ended. - Codex probe: `codex exec` with the pinned 0.160 binary reports no `update_plan` tool by default and reports the tool with `-c tools.update_plan.enabled=true`. ## Risks - Claude and Codex now get their plan tools. Agents can spend a few tokens on plan updates. The tools are standard harness features. - The Codex flag goes before the web-search and project-instruction policy overrides, so the existing policy tail check still holds. - Runs that finish fast show the card only briefly. This is the intended hide rule. - Cursor plan display is not verified. > 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 - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edit, browser automation) and extended reasoning. ## 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 - [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>
This commit is contained in:
1 parent
cb5d7e440c
commit
8227259d86
14 files changed
+863
-227
No files matched your search
@@ -87,21 +87,11 @@ describe("canonical native event UI boundary corpus", () => {
|
||||
|
||||
expect(transcript).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
toolUseId: `plan:${fixture.expected.planId}`,
|
||||
name: "plan",
|
||||
input: {
|
||||
eventType: "plan.updated",
|
||||
summary: "Keep every boundary lossless.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(transcript).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "tool_result",
|
||||
toolUseId: `plan:${fixture.expected.planId}`,
|
||||
content: "Keep every boundary lossless.",
|
||||
isError: false,
|
||||
kind: "provider_activity",
|
||||
family: "plan",
|
||||
eventType: "plan.updated",
|
||||
summary: "Keep every boundary lossless.",
|
||||
payload: expect.objectContaining({ planId: fixture.expected.planId }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -118,6 +118,57 @@ describe("provider notice presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native plan presentation", () => {
|
||||
it("keeps plan snapshots as plan activity so the turn plan card can read their steps", () => {
|
||||
const plan = (seq: number, statuses: string[]) => event(seq, "plan.updated", {
|
||||
schema: "paperclip.plan.updated.v1",
|
||||
planId: "turn-1",
|
||||
revision: 1,
|
||||
explanation: null,
|
||||
steps: statuses.map((status, index) => ({ stepId: `step-${index + 1}`, body: `Step ${index + 1}`, status })),
|
||||
complete: statuses.every((status) => status === "completed"),
|
||||
syncStatus: "not_applicable",
|
||||
documentRevision: null,
|
||||
});
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
plan(1, ["in_progress", "pending"]),
|
||||
plan(2, ["completed", "completed"]),
|
||||
]);
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({ kind: "provider_activity", family: "plan", status: "running", title: "Plan" }),
|
||||
expect.objectContaining({ kind: "provider_activity", family: "plan", status: "completed" }),
|
||||
]);
|
||||
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: true });
|
||||
const plans = items.filter((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan");
|
||||
expect(plans).toHaveLength(1);
|
||||
expect(plans[0]).toMatchObject({ steps: [
|
||||
{ label: "Step 1", status: "completed" },
|
||||
{ label: "Step 2", status: "completed" },
|
||||
] });
|
||||
});
|
||||
|
||||
it("keeps a blocked plan blocked in settled history instead of marking it interrupted", () => {
|
||||
const transcript = nativeRunEventsToTranscript([event(1, "plan.updated", {
|
||||
schema: "paperclip.plan.updated.v1",
|
||||
planId: "turn-1",
|
||||
revision: 1,
|
||||
explanation: null,
|
||||
steps: [
|
||||
{ stepId: "step-1", body: "Inspect", status: "completed" },
|
||||
{ stepId: "step-2", body: "Run tests", status: "blocked" },
|
||||
],
|
||||
complete: false,
|
||||
syncStatus: "not_applicable",
|
||||
documentRevision: null,
|
||||
})]);
|
||||
expect(transcript[0]).toMatchObject({ kind: "provider_activity", family: "plan", status: "failed" });
|
||||
const settled = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: false });
|
||||
const plan = settled.find((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan");
|
||||
expect(plan).toMatchObject({ status: "failed" });
|
||||
expect(JSON.stringify(plan)).not.toContain("Interrupted before completion");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nativeRunEventsToTranscript", () => {
|
||||
describe("accepted response-wake authority", () => {
|
||||
function fixture() {
|
||||
|
||||
@@ -841,6 +841,29 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
// The plan carries its steps to the turn plan card, so keep the full
|
||||
// snapshot instead of flattening it into a generic tool row.
|
||||
if (event.eventType === "plan.updated" && payload.schema === "paperclip.plan.updated.v1" && text(payload.planId)) {
|
||||
entries.push({
|
||||
kind: "provider_activity",
|
||||
ts,
|
||||
family: "plan",
|
||||
eventType: event.eventType,
|
||||
status: payload.complete === true
|
||||
? "completed"
|
||||
: Array.isArray(payload.steps) && payload.steps.some((step) => {
|
||||
const status = record(step)?.status;
|
||||
return status === "blocked" || status === "failed";
|
||||
})
|
||||
? "failed"
|
||||
: "running",
|
||||
title: "Plan",
|
||||
summary: text(payload.explanation)?.trim() || "Plan",
|
||||
payload,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerActivity = providerActivityPresentation(event, payload);
|
||||
if (providerActivity) {
|
||||
if (!startedToolIds.has(providerActivity.id)) {
|
||||
|
||||
Reference in new issue
Block a user