mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
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:
1 parent
b61a815d3d
commit
bf10bc695a
35 files changed
+1760
-11
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user