feat(task): show the agent's context window in task properties (#19273)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run through a harness (Codex app-server, or Claude through
ACPX), and the runner turns harness events into Paperclip events
> - Both harnesses report how full the model's context window is. Codex
sends `modelContextWindow` with its token usage. Claude sends `used` and
`size` in ACP `usage_update`
> - Paperclip dropped both values. The task view could not show how
close an agent is to compaction, and a compaction left no visible mark
in the task chat
> - This pull request carries the context-window occupancy through the
runner, stores it with the existing usage events, and shows it as a
"Context" row in the task Properties panel
> - It also shows a "Context compacted · 230k → 38k tokens" divider in
the task chat when the agent compacts its context
> - The benefit is that operators can see when a long task is near its
context limit and when the agent lost detail to a compaction

## Linked Issues or Issue Description

Refs #19252 (same pattern: a canonical harness event turned into
task-chat UI). Refs #19267 (status notices; it turns Claude's compaction
banner into a notice, and this PR reads that notice too).

**What existing behavior does this improve?**
The task view shows no context-window information, and a compaction is
invisible in the task chat.

**Subsystem affected**
Runner usage normalization (Rust and TypeScript transport), the PRP
usage schema, a read-only issue API, the task Properties panel and the
task-chat transcript.

**Current behavior**
Codex `modelContextWindow` and ACP `used`/`size` are dropped in the
runner. `context.compacted` always has null sizes, Claude compactions
emit no `context.compacted` event at all, and the live runner turn hides
every marker except "interrupted".

**Proposed behavior**
`usage.reported` carries an optional `context {usedTokens,
windowTokens}`. The task Properties panel shows a Context row ("10% of
258k") with a hover breakdown (tokens used, input and cached input,
output, cost, compactions). The transcript shows a compaction divider,
with sizes from the usage before and after the compaction.

**Reason and benefit**
Operators can see when a long task is close to its context limit, and
they can see where the agent's earlier context was summarized.

**Breaking changes**
None. The new schema field is optional, and the new endpoint is
read-only.

## What Changed

- PRP `usage.schema.json`: add an optional `context {usedTokens,
windowTokens}` object. Regenerate the schema bundle and the protocol
manifest.
- Rust `provider_events.rs`: add `codex_context_usage` (last request
`totalTokens` and `modelContextWindow`) and `acpx_context_usage` (ACP
`used`/`size`). ACPX `usage_update` now sets `context`.
- Rust `provider_backend.rs`: Codex `thread/tokenUsage/updated` sets
`context` on live (non-historical) usage.
- Rust `provider_events.rs`: emit `context.compacted` for the completed
`context_compaction` provider notice that #19267 produces from Claude's
compaction banner. Only this path marks a Claude compaction, so the
server counts each compaction once.
- `runnerd-codex-transport.ts`: `rehydrateRunnerdUsageNotification`
keeps a valid `context` on `tokenUsage`, so it reaches the stored
`item.completed` usage item.
- Server: new `GET /api/issues/:id/context-usage` (issue read access is
required) and `issueContextUsage` service. It reads only the assignee's
current session (the session of its newest usage report), and returns
that session's latest context reading, non-zero totals, cost, and the
count of compactions across all of the session's runs. Added to OpenAPI.
Shared type `IssueContextUsage`.
- DB: migration 0432 adds two partial indexes on `heartbeat_run_events`,
built concurrently. One indexes usage items by run and time. The other
indexes `context.compacted` events by company and session. `EXPLAIN`
shows that both lookups use them.
- UI: new `ContextUsageMeter` (bar, percentage, hover card) and
`IssueContextUsageRow` in the Execution section of Properties. The row
refreshes every 8 s while a run is active, and once more when the run
ends.
- UI transcript: `native-run-events.ts` maps `context.compacted` to a
`context` activity entry with pre/post sizes from neighbouring usage
items. `transcript-adapter.ts` turns it into a new `context_compacted`
marker variant. The live runner turn keeps that marker as its own
timeline row.
- Storybook: `task-context-window.stories.tsx` (Properties panel in a
task, all states, hover breakdowns, divider).

## Verification

- `cargo test -p paperclip-runner-core --lib provider_events` (new
tests: ACPX context, Codex context, banner compaction, notice
compaction)
- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/live/runnerd-codex-transport.test.ts`
- `cd ui && npx vitest run src/components/ContextUsageMeter.test.tsx
src/components/transcript src/components/task-chat
src/components/issue-properties`
- `cd server && npx vitest run
src/__tests__/issue-context-usage.test.ts` (embedded Postgres)
- `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`,
`pnpm --filter @paperclipai/paperclip-runner run check:protocol-types`
- Live check on a local worktree instance through the runnerd path:
- A Codex `gpt-5.5` task showed "10% of 258k" (27k of 258k, input 77k
with 49k cached, output 1.1k).
  - A Claude Sonnet 4.6 task showed "17% of 200k" (34k of 200k).
- Storybook: Tasks and conversations / … / Task chat / Context window.
- Not verified live: a real compaction. The divider is covered by unit
tests through the same projector path the task chat uses.

## Risks

- The schema field is optional, and older runners omit it. The UI
renders nothing until a harness reports a window.
- Migration 0432 builds two partial indexes concurrently on
`heartbeat_run_events`, which is a large table. The indexes cover only
usage items and compaction events, so they stay small. They add a little
write overhead to those events.
- Claude in-progress usage rows still record zero tokens with `requests:
1`. The final turn usage is correct. This PR does not change accounting.
- A "Compact now" action is out of scope. It needs a compaction-only run
that does not produce a run result. It will come in a follow-up.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, in-app browser for Storybook and
the live instance).

## 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 16:54:27 -06:00
1 parent b61a815d3d
commit bf10bc695a
35 files changed
+1760 -11

No files matched your search

@@ -1,6 +1,6 @@
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, CircleDot, OctagonX, Square, Flag, KeyRound } from "lucide-react";
import { ChevronDown, CircleDot, FoldVertical, OctagonX, Square, Flag, KeyRound } from "lucide-react";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type { TaskChatMarkerItem } from "./task-chat-model";
import { Button } from "@/components/ui/button";
@@ -11,6 +11,7 @@ const VARIANT_ICON = {
session_start: CircleDot,
interrupted: OctagonX,
turn_boundary: Flag,
context_compacted: FoldVertical,
} as const;
/**
@@ -16,6 +16,7 @@ import { TaskChatSubagentTree } from "./TaskChatSubagentCard";
import { TaskChatRunHealthBanner, partitionRunHealthItems } from "./TaskChatRunHealthBanner";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatShimmerScope, useTaskChatShimmer } from "./TaskChatShimmer";
import {
buildTurnTimelineRows,
@@ -291,6 +292,8 @@ export function TaskChatRunnerTurn({
: "task-chat-plan-preview"
}
/>
) : row.kind === "marker" ? (
<TaskChatMarker item={row} />
) : row.kind === "subagent_group" ? (
<TaskChatSubagentTree subagents={row.subagents} />
) : row.kind === "protocol" ? (
@@ -294,7 +294,7 @@ export interface TaskChatStatusItem {
export interface TaskChatMarkerItem {
id: string;
kind: "marker";
variant: "session_start" | "interrupted" | "turn_boundary";
variant: "session_start" | "interrupted" | "turn_boundary" | "context_compacted";
label: string;
detail?: string;
/** False when the recorded run cannot be retried, even after the chat continues. */
@@ -8,6 +8,7 @@
import { foldApprovedAutoReviews } from "./fold-auto-reviews";
import { isFatalProviderError, isStatusNotice, isStatusNoticePayload, statusNoticeCategory } from "./status-notice-presentation";
import type { TranscriptEntry } from "@/adapters";
import { formatContextTokens } from "@/lib/context-tokens";
import { turnChangesFromEditRows } from "./turn-file-changes";
import type {
TaskChatDiff,
@@ -24,6 +25,7 @@ import type {
TaskChatMessageItem,
TaskChatPlanDocumentItem,
TaskChatRuntimeRequestItem,
TaskChatMarkerItem,
TaskChatSubagent,
TaskChatSubagentGroupItem,
} from "./task-chat-model";
@@ -133,6 +135,30 @@ export function flattenSelfTalk(text: string): string {
.trim();
}
/** "Context compacted · 230k → 38k tokens" divider for a provider compaction. */
export function contextCompactedMarker(
entry: Extract<TranscriptEntry, { kind: "provider_activity" }>,
id: string,
): TaskChatMarkerItem {
const count = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
const pre = count(entry.payload.preTokens);
const post = count(entry.payload.postTokens);
const detail = pre !== null && post !== null
? `${formatContextTokens(pre)} → ${formatContextTokens(post)} tokens`
: pre !== null
? `from ${formatContextTokens(pre)} tokens`
: undefined;
return {
id,
kind: "marker",
variant: "context_compacted",
label: "Context compacted",
...(detail ? { detail } : {}),
createdAtIso: entry.ts,
};
}
function diffKind(changeType: string): "add" | "remove" | "context" | "hunk" {
if (changeType === "add") return "add";
if (changeType === "remove") return "remove";
@@ -979,6 +1005,12 @@ export function transcriptToTaskChatItems(
break;
}
case "provider_activity": {
if (entry.eventType === "context.compacted") {
// One entry per compaction; producers may reuse a thread-scoped id.
items.push(contextCompactedMarker(entry, `${runId}:compaction:${String(entry.payload.compactionId ?? "")}:${i}`));
resetInline();
break;
}
// Use producer classification, including the legacy generic Codex
// categories, never message-text matching. The run transcript still
// retains these diagnostics.
@@ -1369,7 +1401,7 @@ export function paperclipRunnerActivityItems(
return normalizedName !== "paperclip_finish";
}
case "marker":
return item.variant === "interrupted";
return item.variant === "interrupted" || item.variant === "context_compacted";
case "protocol":
// Completion is already represented by task state and the final answer.
// Keep its event in the inspector, but omit it from feed rows and counts.
@@ -2061,6 +2093,10 @@ export function buildTurnTimelineRows(
} else if (item.kind === "plan_document") {
current = null;
rows.push(item);
} else if (item.kind === "marker" && item.variant === "context_compacted") {
// A compaction divides the turn, so it closes the current phase.
current = null;
rows.push(item);
} else if (
item.kind === "protocol" &&
item.surface === "provider_activity" &&