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:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 09:09:39 -06:00
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)) {