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

+2
View File
@@ -2,6 +2,7 @@ import type { SetIssueTitle } from "@paperclipai/shared";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
import type { IssueSummary } from "@paperclipai/shared";
import type { ExecutionReconciliation } from "@paperclipai/shared";
import type { IssueContextUsage } from "@paperclipai/shared";
import type {
AcceptedPlanDecompositionSummary,
AskUserQuestionsAnswer,
@@ -370,6 +371,7 @@ export const issuesApi = {
options
? api.get<Issue>(`/issues/${id}`, options)
: api.get<Issue>(`/issues/${id}`),
getContextUsage: (id: string) => api.get<IssueContextUsage | null>(`/issues/${id}/context-usage`),
getRunnerGoal: (id: string, agentId?: string | null) => {
const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : "";
return api.get<RunnerGoalProjection>(`/issues/${id}/runner-goal${query}`);
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { contextUsageLevel, formatContextTokens } from "./ContextUsageMeter";
describe("contextUsageLevel", () => {
it("is neutral well below compaction", () => {
expect(contextUsageLevel({ usedTokens: 100_000, contextWindow: 272_000, autoCompactTokens: 244_800 })).toBe("normal");
});
it("turns amber within ten points of auto-compaction", () => {
expect(contextUsageLevel({ usedTokens: 229_500, contextWindow: 272_000, autoCompactTokens: 244_800 })).toBe("near");
expect(contextUsageLevel({ usedTokens: 170_000, contextWindow: 200_000 })).toBe("near");
});
it("turns red at 95%", () => {
expect(contextUsageLevel({ usedTokens: 191_300, contextWindow: 200_000 })).toBe("full");
});
});
describe("formatContextTokens", () => {
it("abbreviates token counts", () => {
expect(formatContextTokens(950)).toBe("950");
expect(formatContextTokens(1_240)).toBe("1.2k");
expect(formatContextTokens(258_400)).toBe("258k");
expect(formatContextTokens(1_000_000)).toBe("1M");
});
});
+187
View File
@@ -0,0 +1,187 @@
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { formatContextTokens } from "@/lib/context-tokens";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export { formatContextTokens };
/**
* Latest context-window snapshot for the agent's live session. Built from
* provider usage events (Codex `thread/tokenUsage/updated`, ACP `usage_update`).
*/
export interface ContextUsage {
/** Tokens currently occupying the context window. */
usedTokens: number;
/** Model context window size in tokens. */
contextWindow: number;
/** Token count at which the harness compacts on its own, when known. */
autoCompactTokens?: number | null;
/** Session totals, when the provider reports them. */
totals?: {
inputTokens?: number | null;
cachedInputTokens?: number | null;
outputTokens?: number | null;
reasoningOutputTokens?: number | null;
} | null;
/** Most recent model request, when the provider reports it. */
lastTurn?: { inputTokens?: number | null; outputTokens?: number | null } | null;
costUsd?: number | null;
/** Number of compactions so far in this session. */
compactions?: number;
/** When the provider reported this reading (ISO timestamp). */
observedAt?: string | null;
}
export type ContextUsageLevel = "normal" | "near" | "full";
/** Amber once the context is within reach of auto-compaction, red when nearly full. */
export function contextUsageLevel(usage: ContextUsage): ContextUsageLevel {
const fraction = usage.usedTokens / usage.contextWindow;
if (fraction >= 0.95) return "full";
const threshold = usage.autoCompactTokens && usage.autoCompactTokens > 0
? usage.autoCompactTokens / usage.contextWindow
: 0.9;
return fraction >= threshold - 0.1 ? "near" : "normal";
}
const LEVEL_TEXT: Record<ContextUsageLevel, string> = {
normal: "text-foreground",
near: "text-(--status-agent-paused)",
full: "text-(--status-agent-error)",
};
const LEVEL_FILL: Record<ContextUsageLevel, string> = {
normal: "bg-muted-foreground",
near: "bg-(--status-agent-paused)",
full: "bg-(--status-agent-error)",
};
function Bar({ percent, level, className }: { percent: number; level: ContextUsageLevel; className?: string }) {
return (
<div className={cn("h-1 overflow-hidden rounded-full bg-border", className)}>
<div
className={cn("h-full rounded-full transition-all duration-500", LEVEL_FILL[level])}
style={{ width: `${percent}%` }}
/>
</div>
);
}
function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{label}</dt>
<dd className={cn("font-mono tabular-nums", muted ? "text-muted-foreground" : "text-foreground")}>{value}</dd>
</div>
);
}
function ContextUsageCard({ usage }: { usage: ContextUsage }) {
const percent = Math.min(100, Math.round((usage.usedTokens / usage.contextWindow) * 100));
const level = contextUsageLevel(usage);
const totals = usage.totals;
return (
<div className="flex flex-col">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium text-foreground">Context window</span>
<span className={cn("font-medium tabular-nums", LEVEL_TEXT[level])}>{percent}%</span>
</div>
<p className="mt-0.5 text-muted-foreground tabular-nums">
{formatContextTokens(usage.usedTokens)} of {formatContextTokens(usage.contextWindow)} tokens
{usage.observedAt ? <> · updated {timeAgo(usage.observedAt)}</> : null}
</p>
<Bar percent={percent} level={level} className="mt-2 w-full" />
{level !== "normal" ? (
<p className={cn("mt-2", LEVEL_TEXT[level])}>
{usage.autoCompactTokens
? `Compacts automatically near ${formatContextTokens(usage.autoCompactTokens)}.`
: "Close to full. The agent will compact its context soon."}
</p>
) : null}
{totals || usage.lastTurn || usage.costUsd != null || usage.compactions ? (
<dl className="mt-3 flex flex-col gap-1 border-t border-border pt-2">
{totals?.inputTokens != null ? (
<Row
label="Input"
value={totals.cachedInputTokens
? `${formatContextTokens(totals.inputTokens)} · ${formatContextTokens(totals.cachedInputTokens)} cached`
: formatContextTokens(totals.inputTokens)}
/>
) : null}
{totals?.outputTokens != null ? <Row label="Output" value={formatContextTokens(totals.outputTokens)} /> : null}
{totals?.reasoningOutputTokens ? (
<Row label="Reasoning" value={formatContextTokens(totals.reasoningOutputTokens)} muted />
) : null}
{usage.lastTurn ? (
<Row
label="Last request"
value={`${formatContextTokens(usage.lastTurn.inputTokens ?? 0)} in · ${formatContextTokens(usage.lastTurn.outputTokens ?? 0)} out`}
muted
/>
) : null}
{usage.costUsd != null ? <Row label="Cost" value={`$${usage.costUsd.toFixed(2)}`} /> : null}
{usage.compactions ? <Row label="Compactions" value={String(usage.compactions)} muted /> : null}
</dl>
) : null}
</div>
);
}
/**
* Context row value for the task Properties panel: a thin fill bar and
* "64% of 272k". Hover or tap opens the token breakdown.
*/
export function ContextUsageMeter({
usage,
defaultOpen = false,
}: {
usage: ContextUsage;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const percent = Math.min(100, Math.round((usage.usedTokens / usage.contextWindow) * 100));
const level = contextUsageLevel(usage);
const windowLabel = formatContextTokens(usage.contextWindow);
const show = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
};
// A short grace period lets the pointer travel from the row into the card.
const hide = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => setOpen(false), 150);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Context window: ${percent}% of ${windowLabel} tokens used`}
data-testid="context-usage-meter"
data-level={level}
onMouseEnter={show}
onMouseLeave={hide}
className="-mx-1 flex min-w-0 items-center gap-2 rounded-md px-1 py-0.5 text-sm transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Bar percent={percent} level={level} className="w-12 shrink-0" />
<span className={cn("truncate tabular-nums", level === "normal" ? "text-foreground" : LEVEL_TEXT[level])}>
{percent}%
<span className="text-muted-foreground"> of {windowLabel}</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="left"
align="start"
className="w-64 p-3 text-xs"
onOpenAutoFocus={(event) => event.preventDefault()}
onMouseEnter={show}
onMouseLeave={hide}
>
<ContextUsageCard usage={usage} />
</PopoverContent>
</Popover>
);
}
@@ -36,6 +36,7 @@ const mockExecutionWorkspacesApi = vi.hoisted(() => ({
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
getContextUsage: vi.fn(),
getDocument: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
listAttachments: vi.fn(),
@@ -642,6 +643,7 @@ describe("IssueProperties", () => {
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.getContextUsage.mockResolvedValue(null);
mockIssuesApi.getDocument.mockResolvedValue(null);
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
mockIssuesApi.listAttachments.mockResolvedValue([]);
@@ -118,6 +118,7 @@ import {
} from "./helpers";
import { PropertyPicker } from "./property-picker";
import { PropertyChip, PropertyRow, PropertySection } from "./primitives";
import { IssueContextUsageRow } from "./context-usage-row";
import {
buildWorkspaceSelectionUpdate,
currentWorkspaceSelection,
@@ -214,6 +215,8 @@ interface IssuePropertiesProps {
documentDeepLink?: IssuePropertiesDocumentDeepLink | null;
/** Render only the Properties body when a parent owns the side-panel tabs. */
sidePanelContentOnly?: boolean;
/** Overrides the fetched Context row (Storybook fixtures); null hides it. */
contextUsage?: import("react").ReactNode;
}
export interface IssuePropertiesDocumentDeepLink {
@@ -252,6 +255,7 @@ export function IssueProperties({
checkingMonitorNow = false,
documentDeepLink,
sidePanelContentOnly = false,
contextUsage,
}: IssuePropertiesProps) {
const policyResult = useExecutionPolicy(issue.executionPolicy);
const policyAvailable = policyResult.success;
@@ -2762,6 +2766,10 @@ export function IssueProperties({
>
{watchdogContent}
</PropertyPicker>
{contextUsage !== undefined
? contextUsage ? <PropertyRow label="Context">{contextUsage}</PropertyRow> : null
: <IssueContextUsageRow issueId={issue.id} assigneeAgentId={issue.assigneeAgentId ?? null} hasActiveRun={hasActiveRun} />}
</PropertySection>
{workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({ getContextUsage: vi.fn() }));
vi.mock("@/api/issues", () => ({ issuesApi: { getContextUsage: api.getContextUsage } }));
import { ApiError } from "@/api/api-error";
import { IssueContextUsageRow } from "./context-usage-row";
async function settle() {
for (let i = 0; i < 8; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); }
}
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
vi.resetAllMocks();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
flushSync(() => root.unmount());
client.clear();
document.body.innerHTML = "";
});
function render(assigneeAgentId: string | null = "agent-1") {
flushSync(() => root.render(
<QueryClientProvider client={client}>
<IssueContextUsageRow issueId="issue-1" assigneeAgentId={assigneeAgentId} hasActiveRun={false} />
</QueryClientProvider>,
));
}
const reading = {
runId: "run-1", agentId: "agent-1", sessionId: "session-1", usedTokens: 26_672, windowTokens: 258_400,
observedAt: new Date().toISOString(), totals: null, costUsd: null, compactions: 0,
};
describe("IssueContextUsageRow", () => {
it("stays hidden until the session reports a context window", async () => {
api.getContextUsage.mockResolvedValue(null);
render(); await settle();
expect(container.textContent).toBe("");
});
it("shows the reading", async () => {
api.getContextUsage.mockResolvedValue(reading);
render(); await settle();
expect(container.textContent).toContain("10%");
expect(container.textContent).toContain("of 258k");
});
it("shows a failed request with a retry that recovers", async () => {
api.getContextUsage
.mockRejectedValueOnce(new ApiError("Internal error", 500, { error: "internal" }))
.mockResolvedValue(reading);
render(); await settle();
const retry = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Retry"));
expect(retry).toBeTruthy();
retry!.click(); await settle();
expect(container.textContent).toContain("10%");
});
it("drops the previous assignee's reading after an idle reassignment", async () => {
api.getContextUsage.mockResolvedValueOnce(reading).mockResolvedValueOnce(null);
render("agent-1"); await settle();
expect(container.textContent).toContain("10%");
render("agent-2"); await settle();
expect(api.getContextUsage).toHaveBeenCalledTimes(2);
expect(container.textContent).toBe("");
});
it("keeps the last reading when a refresh fails", async () => {
api.getContextUsage.mockResolvedValueOnce(reading).mockRejectedValue(new ApiError("Internal error", 500, { error: "internal" }));
render(); await settle();
await client.refetchQueries(); await settle();
expect(container.textContent).toContain("10%");
});
});
@@ -0,0 +1,80 @@
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { IssueContextUsage } from "@paperclipai/shared";
import { issuesApi } from "../../api/issues";
import { queryKeys } from "../../lib/queryKeys";
import { QueryErrorState, useQueryView } from "../QueryView";
import { ContextUsageMeter, type ContextUsage } from "../ContextUsageMeter";
import { PropertyRow } from "./primitives";
/** Refresh cadence while an agent run is reporting usage. */
const ACTIVE_RUN_REFRESH_MS = 8_000;
export function contextUsageFromIssue(value: IssueContextUsage): ContextUsage {
return {
usedTokens: value.usedTokens,
contextWindow: value.windowTokens,
totals: value.totals
? {
inputTokens: value.totals.inputTokens,
cachedInputTokens: value.totals.cachedInputTokens,
outputTokens: value.totals.outputTokens,
}
: null,
costUsd: value.costUsd,
observedAt: value.observedAt,
compactions: value.compactions,
};
}
/**
* Context row for the task Properties panel. Renders nothing until the
* agent's harness has reported a context window for this task. A cached
* reading stays visible through a failed refresh; a failed first load shows
* the shared error state with Retry.
*/
export function IssueContextUsageRow({
issueId,
assigneeAgentId,
hasActiveRun,
}: {
issueId: string;
/** The reading belongs to the assignee's session, so a reassignment refetches it. */
assigneeAgentId: string | null;
hasActiveRun: boolean;
}) {
const query = useQuery({
queryKey: queryKeys.issues.contextUsage(issueId, assigneeAgentId),
queryFn: () => issuesApi.getContextUsage(issueId),
refetchInterval: hasActiveRun ? ACTIVE_RUN_REFRESH_MS : false,
staleTime: hasActiveRun ? 0 : 60_000,
});
const view = useQueryView(query);
// Pick up the final measurement once the run settles.
const wasActive = useRef(hasActiveRun);
const { refetch } = query;
useEffect(() => {
if (wasActive.current && !hasActiveRun) void refetch();
wasActive.current = hasActiveRun;
}, [hasActiveRun, refetch]);
if (view.data) {
return (
<PropertyRow label="Context">
<ContextUsageMeter usage={contextUsageFromIssue(view.data)} />
</PropertyRow>
);
}
if (view.kind !== "error") return null;
return (
<PropertyRow label="Context">
<QueryErrorState
error={view.error}
size="inline"
action="load the context window"
onRetry={view.retry}
retrying={view.isFetching}
/>
</PropertyRow>
);
}
@@ -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" &&
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
import { nativeRunEventsToTranscript } from "./native-run-events";
import { paperclipRunnerFinalResponse, settledRunChildren, transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
import { buildTurnTimelineRows, paperclipRunnerFinalResponse, paperclipRunnerTimelineItems, settledRunChildren, transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
const RUN_ID = "10000000-0000-4000-8000-000000000001";
@@ -1300,6 +1300,87 @@ describe("nativeRunEventsToTranscript", () => {
});
});
describe("context compaction", () => {
function usage(seq: number, usedTokens: number) {
return itemEvent(seq, "item.completed", `turn-1:usage:${seq}`, {
kind: "usage",
usage: { total: { inputTokens: 1 }, context: { usedTokens, windowTokens: 258_400 } },
});
}
it("sizes a compaction from the usage reported before and after it", () => {
const transcript = nativeRunEventsToTranscript([
usage(1, 229_500),
event(2, "context.compacted", {
schema: "paperclip.context.compacted.v1",
compactionId: "compaction-1",
reason: "provider",
preTokens: null,
postTokens: null,
sameSession: true,
}),
usage(3, 38_200),
]);
const compaction = transcript.find((entry) => entry.kind === "provider_activity");
expect(compaction).toMatchObject({
eventType: "context.compacted",
payload: { preTokens: 229_500, postTokens: 38_200 },
});
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Codex", running: false });
const flat = JSON.stringify(items);
expect(flat).toContain('"variant":"context_compacted"');
expect(flat).toContain("230k → 38k tokens");
// The live runner turn keeps the divider as its own timeline row.
const rows = buildTurnTimelineRows(paperclipRunnerTimelineItems(items), true);
expect(rows).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "marker", variant: "context_compacted" })]),
);
});
it("keeps every compaction in a run even when the provider reuses its id", () => {
const compacted = (seq: number) => event(seq, "context.compacted", {
schema: "paperclip.context.compacted.v1",
compactionId: "thread-1",
reason: "provider",
preTokens: null,
postTokens: null,
sameSession: true,
});
const transcript = nativeRunEventsToTranscript([
usage(1, 230_000),
compacted(2),
compacted(3),
usage(4, 40_000),
usage(5, 235_000),
compacted(6),
usage(7, 36_000),
]);
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Codex", running: false });
const markers = items.filter((item) => item.kind === "marker" && item.variant === "context_compacted");
expect(markers.map((item) => item.kind === "marker" ? item.detail : null)).toEqual([
"230k → 40k tokens",
"235k → 36k tokens",
]);
});
it("keeps the divider when the provider reports no sizes", () => {
const transcript = nativeRunEventsToTranscript([
event(1, "context.compacted", {
schema: "paperclip.context.compacted.v1",
compactionId: "compaction-2",
reason: "provider",
preTokens: null,
postTokens: null,
sameSession: true,
}),
]);
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: false });
const flat = JSON.stringify(items);
expect(flat).toContain('"label":"Context compacted"');
expect(flat).not.toContain("tokens");
});
});
describe("tool execution facts", () => {
const tool = (seq: number, eventType: string, fields: Record<string, unknown>) =>
event(seq, eventType, {
@@ -582,6 +582,21 @@ function providerActivityPresentation(
};
}
function finiteCount(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
}
/** Context-window occupancy carried on a usage item, when the provider reported it. */
export function contextUsageFromUsagePayload(
value: unknown,
): { usedTokens: number; windowTokens: number } | null {
const context = record(record(value)?.context);
const usedTokens = finiteCount(context?.usedTokens);
const windowTokens = finiteCount(context?.windowTokens);
if (usedTokens === null || windowTokens === null || windowTokens < 1) return null;
return { usedTokens, windowTokens };
}
/**
* Activity families that render as dedicated task-chat cards. They keep the
* full canonical payload (sources, models, durations) instead of collapsing
@@ -955,6 +970,12 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
}
const itemIdentityById = new Map<string, ItemIdentity>();
// Context occupancy rides on usage items. A compaction divider takes its
// "before" size from the last report and its "after" size from the next.
let latestContextUsedTokens: number | null = null;
let awaitingPostCompaction: Record<string, unknown> | null = null;
let lastCompaction: Record<string, unknown> | null = null;
let usageSinceLastCompaction = false;
const proposalMarkdownById = new Map<string, string>();
const completedProposalIds = new Set<string>();
for (const event of orderedEvents) {
@@ -979,6 +1000,47 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
}
const itemKind = itemIdentity.kind;
if (event.eventType === "item.completed" && payload.kind === "usage") {
const context = contextUsageFromUsagePayload(payload.usage);
if (context) {
usageSinceLastCompaction = true;
if (awaitingPostCompaction && context.usedTokens < (latestContextUsedTokens ?? Infinity)) {
awaitingPostCompaction.postTokens = context.usedTokens;
awaitingPostCompaction = null;
}
latestContextUsedTokens = context.usedTokens;
}
}
if (event.eventType === "context.compacted" && payload.schema === "paperclip.context.compacted.v1") {
// A provider can report the same compaction twice (item start and end)
// with no usage between them. Later compactions always follow usage.
if (
lastCompaction
&& lastCompaction.compactionId === payload.compactionId
&& !usageSinceLastCompaction
) continue;
usageSinceLastCompaction = false;
const compaction: Record<string, unknown> = {
...payload,
preTokens: finiteCount(payload.preTokens) ?? latestContextUsedTokens,
postTokens: finiteCount(payload.postTokens),
};
if (compaction.postTokens === null) awaitingPostCompaction = compaction;
lastCompaction = compaction;
entries.push({
kind: "provider_activity",
ts,
family: "context",
eventType: event.eventType,
status: "completed",
title: "Context compacted",
summary: "Context compacted",
payload: compaction,
});
continue;
}
if (event.eventType === "item.delta" && isAssistantItemKind(itemKind)) {
const value = normalizedItemText(payload);
if (!value || !itemId) continue;
+10
View File
@@ -0,0 +1,10 @@
/** Compact token count for context-window labels: 950, 1.2k, 258k, 1M. */
export function formatContextTokens(value: number): string {
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}M`;
if (value >= 1_000) return value >= 10_000 ? `${Math.round(value / 1_000)}k` : `${trimDecimal(value / 1_000)}k`;
return String(Math.round(value));
}
function trimDecimal(value: number): string {
return value.toFixed(1).replace(/\.0$/, "");
}
+2
View File
@@ -413,6 +413,8 @@ export const queryKeys = {
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
contextUsage: (issueId: string, assigneeAgentId: string | null) =>
["issues", "context-usage", issueId, assigneeAgentId] as const,
runnerGoal: (issueId: string, agentId?: string | null) =>
["issues", "runner-goal", issueId, agentId ?? "__effective__"] as const,
workProducts: (issueId: string) =>
@@ -0,0 +1,318 @@
import { useState, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode";
import { ContextUsageMeter, type ContextUsage } from "@/components/ContextUsageMeter";
import { IssueProperties } from "@/components/IssueProperties";
import { ScrollArea } from "@/components/ui/scroll-area";
import { queryKeys } from "@/lib/queryKeys";
import type {
TaskChatItem,
TaskChatMarkerItem,
TaskChatMessageItem,
TaskChatToolItem,
} from "@/components/task-chat/task-chat-model";
import {
storybookAgents,
storybookAuthSession,
storybookCompanies,
storybookIssueLabels,
storybookIssues,
storybookProjects,
} from "../../fixtures/paperclipData";
/** Codex gpt-5 class session: 272k window, auto-compacts around 245k. */
const CODEX_WINDOW = 272_000;
const CODEX_COMPACT = 244_800;
const USAGE = {
fresh: {
usedTokens: 18_400,
contextWindow: CODEX_WINDOW,
autoCompactTokens: CODEX_COMPACT,
totals: { inputTokens: 21_300, cachedInputTokens: 9_800, outputTokens: 1_240 },
lastTurn: { inputTokens: 18_400, outputTokens: 1_240 },
},
working: {
usedTokens: 174_100,
contextWindow: CODEX_WINDOW,
autoCompactTokens: CODEX_COMPACT,
totals: { inputTokens: 1_480_000, cachedInputTokens: 1_310_000, outputTokens: 31_800, reasoningOutputTokens: 12_400 },
lastTurn: { inputTokens: 174_100, outputTokens: 2_150 },
},
nearCompaction: {
usedTokens: 229_500,
contextWindow: CODEX_WINDOW,
autoCompactTokens: CODEX_COMPACT,
totals: { inputTokens: 3_920_000, cachedInputTokens: 3_610_000, outputTokens: 58_200, reasoningOutputTokens: 21_900 },
lastTurn: { inputTokens: 229_500, outputTokens: 1_870 },
},
afterCompaction: {
usedTokens: 38_200,
contextWindow: CODEX_WINDOW,
autoCompactTokens: CODEX_COMPACT,
totals: { inputTokens: 4_310_000, cachedInputTokens: 3_840_000, outputTokens: 63_900, reasoningOutputTokens: 23_100 },
lastTurn: { inputTokens: 38_200, outputTokens: 940 },
compactions: 1,
},
claudeNearlyFull: {
usedTokens: 191_300,
contextWindow: 200_000,
totals: { inputTokens: 2_640_000, cachedInputTokens: 2_410_000, outputTokens: 44_700 },
costUsd: 3.18,
},
claudeWithCost: {
usedTokens: 61_900,
contextWindow: 200_000,
totals: { inputTokens: 412_000, cachedInputTokens: 366_000, outputTokens: 9_300 },
costUsd: 0.47,
},
windowOnly: { usedTokens: 96_000, contextWindow: 200_000 },
} satisfies Record<string, ContextUsage>;
const compactedMarker: TaskChatMarkerItem = {
id: "compacted-1",
kind: "marker",
variant: "context_compacted",
label: "Context compacted",
detail: "230k → 38k tokens",
};
const autoCompactedClaude: TaskChatMarkerItem = {
id: "compacted-claude",
kind: "marker",
variant: "context_compacted",
label: "Context compacted",
detail: "188k → 24k tokens",
};
const unknownSizes: TaskChatMarkerItem = {
id: "compacted-unknown",
kind: "marker",
variant: "context_compacted",
label: "Context compacted",
};
const request: TaskChatMessageItem = {
id: "request",
kind: "message",
author: "human",
authorName: "Alex",
isCurrentUser: true,
text: "Port the billing exporter to the new SDK and keep the CSV output byte-identical.",
timestamp: "2:04 PM",
};
const tool = (id: string, name: string, target: string): TaskChatToolItem => ({
id, kind: "tool", name, target, status: "completed",
});
const progress: TaskChatMessageItem = {
id: "progress",
kind: "message",
author: "agent",
authorName: "Codex",
channel: "progress",
interstitial: true,
text: "Mapped all 41 call sites of the legacy client. Porting the streaming reader next.",
timestamp: "2:19 PM",
};
const afterCompaction: TaskChatMessageItem = {
id: "after",
kind: "message",
author: "agent",
authorName: "Codex",
channel: "progress",
interstitial: true,
text: "Picking up from the summary: streaming reader is ported, batch uploader is next.",
timestamp: "2:31 PM",
};
const THREAD: TaskChatItem[] = [
request,
tool("t1", "Search", "legacyClient\\."),
tool("t2", "Read", "server/src/billing/exporter.ts"),
progress,
tool("t3", "Edit", "server/src/billing/stream-reader.ts"),
tool("t4", "Run", "pnpm vitest billing"),
compactedMarker,
afterCompaction,
tool("t5", "Read", "server/src/billing/batch-uploader.ts"),
];
const companyId = "company-storybook";
const issue = storybookIssues[0]!;
function StorybookData({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [ready] = useState(() => {
queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false });
queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession);
queryClient.setQueryData(queryKeys.agents.list(companyId), storybookAgents);
queryClient.setQueryData(queryKeys.projects.list(companyId), storybookProjects);
queryClient.setQueryData(queryKeys.projects.list(companyId, { includeArchived: true }), storybookProjects);
queryClient.setQueryData(queryKeys.issues.list(companyId), storybookIssues);
queryClient.setQueryData(queryKeys.issues.labels(companyId), storybookIssueLabels);
queryClient.setQueryData(queryKeys.issues.liveRuns(issue.id), []);
queryClient.setQueryData(queryKeys.issues.activeRun(issue.id), null);
return true;
});
return ready ? children : null;
}
function PropertiesPane({ usage, defaultOpen }: { usage: ContextUsage; defaultOpen?: boolean }) {
return (
<StorybookData>
<div className="flex h-(--sz-640px) w-80 flex-col overflow-hidden border border-border bg-card">
<div className="border-b border-border px-4 py-2 text-sm font-medium">Properties</div>
<ScrollArea className="flex-1">
<div className="p-4">
<IssueProperties
issue={issue}
childIssues={[]}
onUpdate={() => undefined}
contextUsage={<ContextUsageMeter usage={usage} defaultOpen={defaultOpen} />}
/>
</div>
</ScrollArea>
</div>
</StorybookData>
);
}
function Page({ children, narrow }: { children: ReactNode; narrow?: boolean }) {
return (
<div className="min-h-screen bg-background p-6 text-foreground">
<div className={narrow ? "mx-auto w-full max-w-sm" : "mx-auto w-full max-w-2xl"}>{children}</div>
</div>
);
}
function StateCard({ title, rule, children }: { title: string; rule: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs text-muted-foreground">{rule}</p>
</div>
{children}
</section>
);
}
/** A Properties-style row without the full panel, for comparing states. */
function MeterRow({ usage }: { usage: ContextUsage }) {
return (
<div className="flex w-80 items-center gap-3 rounded-md border border-border bg-card px-4 py-2">
<span className="w-24 shrink-0 text-xs text-muted-foreground">Context</span>
<ContextUsageMeter usage={usage} />
</div>
);
}
function TaskPage() {
return (
<div className="flex min-h-screen gap-6 bg-background p-6 text-foreground">
<div className="min-w-0 flex-1">
<h1 className="mb-4 text-xl font-semibold">Port billing exporter to the new SDK</h1>
<TaskChatPresentationProvider mode="streamlined">
<TaskChatThreadView items={THREAD} scroll={false} />
</TaskChatPresentationProvider>
</div>
<div className="pl-72">
<PropertiesPane usage={USAGE.afterCompaction} />
</div>
</div>
);
}
function AllStates() {
return (
<Page>
<div className="flex flex-col gap-10">
<header>
<h2 className="text-lg font-semibold">Context window</h2>
<p className="text-sm text-muted-foreground">
A Context row in the task&apos;s Properties shows how full the assigned agent&apos;s context window is. Hover
or tap it for the token breakdown. Codex reports usage through{" "}
<code className="font-mono text-xs">thread/tokenUsage/updated</code>, Claude through ACP{" "}
<code className="font-mono text-xs">usage_update</code>.
</p>
</header>
<StateCard title="Fresh session" rule="Neutral bar, percentage and window size.">
<MeterRow usage={USAGE.fresh} />
</StateCard>
<StateCard title="Working" rule="Bar fills as the conversation grows. Still neutral.">
<MeterRow usage={USAGE.working} />
</StateCard>
<StateCard title="Near auto-compaction" rule="Amber within 10 points of the harness's auto-compaction threshold.">
<MeterRow usage={USAGE.nearCompaction} />
</StateCard>
<StateCard title="Nearly full (Claude)" rule="Red at 95% or more.">
<MeterRow usage={USAGE.claudeNearlyFull} />
</StateCard>
<StateCard title="Hidden" rule="No Context row until the agent's harness has reported a context window for this task's session.">
<p className="text-xs text-muted-foreground">(no row)</p>
</StateCard>
<StateCard title="Compaction divider" rule="A transcript rule marks the point where the agent's context was summarized, with before and after sizes when known.">
<div className="flex flex-col gap-3">
<TaskChatMarker item={compactedMarker} />
<TaskChatMarker item={autoCompactedClaude} />
<TaskChatMarker item={unknownSizes} />
</div>
</StateCard>
</div>
</Page>
);
}
function Breakdown({ usage }: { usage: ContextUsage }) {
return (
<div className="flex min-h-screen justify-end bg-background p-6 text-foreground">
<PropertiesPane usage={usage} defaultOpen />
</div>
);
}
const meta = {
id: "product-task-context-window",
title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Context window",
component: ContextUsageMeter,
args: { usage: USAGE.working },
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Context row in the task Properties panel with a hover breakdown, plus the transcript divider recorded when the agent's context is compacted.",
},
},
paperclip: {
"capabilities": [
"tasks/discussion",
"runs-adapters/runs"
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Renders the context meter inside the production Issue Properties panel and the compaction divider in the production thread view, from fixture usage snapshots."
},
},
render: (args) => (
<div className="bg-background p-6 text-foreground"><MeterRow {...args} /></div>
),
} satisfies Meta<typeof ContextUsageMeter>;
export default meta;
type Story = StoryObj<typeof meta>;
export const InTask: Story = { render: () => <TaskPage /> };
export const AllStatesGallery: Story = { render: () => <AllStates /> };
export const BreakdownNearCompaction: Story = { render: () => <Breakdown usage={USAGE.nearCompaction} /> };
export const BreakdownWorking: Story = { render: () => <Breakdown usage={USAGE.working} /> };
export const BreakdownClaudeWithCost: Story = { render: () => <Breakdown usage={USAGE.claudeWithCost} /> };