mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user