From bf10bc695abaef6b20e85b287f456b5ce2edb266 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:54:27 -0600 Subject: [PATCH] feat(task): show the agent's context window in task properties (#19273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/DATABASE.md | 7 + packages/db/src/client.ts | 1 + .../src/migrations/0432_strong_mole_man.sql | 2 + ...{0427_snapshot.json => 0432_snapshot.json} | 345 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/heartbeat_run_events.ts | 8 + .../paperclip-runner/protocol/manifest.json | 2 +- .../protocol/schemas/usage.schema.json | 12 +- .../runner-core/src/provider_backend.rs | 10 +- .../crates/runner-core/src/provider_events.rs | 170 ++++++++- .../src/live/runnerd-codex-transport.test.ts | 14 + .../src/live/runnerd-codex-transport.ts | 11 + .../src/protocol/generated/schema-bundle.ts | 19 + packages/shared/src/index.ts | 1 + packages/shared/src/issue-context-usage.ts | 23 ++ .../src/__tests__/issue-context-usage.test.ts | 98 +++++ server/src/routes/activity.ts | 8 + server/src/routes/openapi.ts | 9 + server/src/services/issue-context-usage.ts | 116 ++++++ ui/src/api/issues.ts | 2 + ui/src/components/ContextUsageMeter.test.tsx | 24 ++ ui/src/components/ContextUsageMeter.tsx | 187 ++++++++++ ui/src/components/IssueProperties.test.tsx | 2 + .../issue-properties/IssueProperties.tsx | 8 + .../context-usage-row.test.tsx | 84 +++++ .../issue-properties/context-usage-row.tsx | 80 ++++ .../components/task-chat/TaskChatMarker.tsx | 3 +- .../task-chat/TaskChatRunnerTurn.tsx | 3 + .../components/task-chat/task-chat-model.ts | 2 +- .../task-chat/transcript-adapter.ts | 38 +- .../transcript/native-run-events.test.ts | 83 ++++- .../transcript/native-run-events.ts | 62 ++++ ui/src/lib/context-tokens.ts | 10 + ui/src/lib/queryKeys.ts | 2 + .../tasks/task-context-window.stories.tsx | 318 ++++++++++++++++ 35 files changed, 1760 insertions(+), 11 deletions(-) create mode 100644 packages/db/src/migrations/0432_strong_mole_man.sql rename packages/db/src/migrations/meta/{0427_snapshot.json => 0432_snapshot.json} (99%) create mode 100644 packages/shared/src/issue-context-usage.ts create mode 100644 server/src/__tests__/issue-context-usage.test.ts create mode 100644 server/src/services/issue-context-usage.ts create mode 100644 ui/src/components/ContextUsageMeter.test.tsx create mode 100644 ui/src/components/ContextUsageMeter.tsx create mode 100644 ui/src/components/issue-properties/context-usage-row.test.tsx create mode 100644 ui/src/components/issue-properties/context-usage-row.tsx create mode 100644 ui/src/lib/context-tokens.ts create mode 100644 ui/storybook/stories/tasks/task-context-window.stories.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index b647275bb43..6c7ef6ea6d8 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -225,6 +225,13 @@ indexes concurrently and can resume an interrupted build without blocking run writes for the full build. The indexes add storage and run-write overhead; they do not change task visibility or secret redaction. +The task Context row reads the agent's latest context-window reading through +two small partial indexes on `heartbeat_run_events`. One index covers only usage +items, by run and time. The other index covers only `context.compacted` events, +by company and normalized session. Migration 0432 builds both indexes +concurrently. A poll therefore reads a few index entries, not a run's full event +history. + The chat wake-notice sweep compares UUID columns directly against guarded, canonical lowercase UUID payload values. Malformed and noncanonical payloads still match nothing. The query can use existing primary keys without adding a diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 6f1e07d3c5e..aca401494ff 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -591,6 +591,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([ "0413_tired_kronos.sql", "0414_closed_edwin_jarvis.sql", "0419_slippery_impossible_man.sql", + "0432_strong_mole_man.sql", "0384_funny_adam_warlock.sql", diff --git a/packages/db/src/migrations/0432_strong_mole_man.sql b/packages/db/src/migrations/0432_strong_mole_man.sql new file mode 100644 index 00000000000..2afc2f1a05a --- /dev/null +++ b/packages/db/src/migrations/0432_strong_mole_man.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "heartbeat_run_events_usage_item_run_idx" ON "heartbeat_run_events" USING btree ("run_id","created_at") WHERE "heartbeat_run_events"."event_type" = 'item.completed' and ("heartbeat_run_events"."payload" -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage';--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "heartbeat_run_events_compaction_session_idx" ON "heartbeat_run_events" USING btree ("company_id",("payload" -> 'prpEvent' ->> 'normalizedSessionId')) WHERE "heartbeat_run_events"."event_type" = 'context.compacted'; diff --git a/packages/db/src/migrations/meta/0427_snapshot.json b/packages/db/src/migrations/meta/0432_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0427_snapshot.json rename to packages/db/src/migrations/meta/0432_snapshot.json index 5ea62c26145..d44daa9c781 100644 --- a/packages/db/src/migrations/meta/0427_snapshot.json +++ b/packages/db/src/migrations/meta/0432_snapshot.json @@ -1,6 +1,6 @@ { - "id": "dd860bff-55de-4849-8787-58b9de729258", - "prevId": "a1fcb4c6-40e7-47a6-b3f2-c43cd9bf332b", + "id": "a87278a8-15b3-4187-94a9-f4bb79d8640d", + "prevId": "9fcf29d1-a9a6-4ead-8d43-7e087a2b2b24", "version": "7", "dialect": "postgresql", "tables": { @@ -713,6 +713,12 @@ "notNull": true, "default": "'{}'::jsonb" }, + "previous_usage_json": { + "name": "previous_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "reason": { "name": "reason", "type": "text", @@ -7171,6 +7177,147 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.budget_evaluation_work": { + "name": "budget_evaluation_work", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_evaluation_work_company_pending_idx": { + "name": "budget_evaluation_work_company_pending_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_evaluation_work_agent_pending_idx": { + "name": "budget_evaluation_work_agent_pending_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_evaluation_work_project_pending_idx": { + "name": "budget_evaluation_work_project_pending_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_evaluation_work_company_id_companies_id_fk": { + "name": "budget_evaluation_work_company_id_companies_id_fk", + "tableFrom": "budget_evaluation_work", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.budget_incidents": { "name": "budget_incidents", "schema": "", @@ -7518,6 +7665,13 @@ "notNull": true, "default": 0 }, + "evaluation_version": { + "name": "evaluation_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -21885,6 +22039,12 @@ "primaryKey": false, "notNull": false }, + "reported_usage_json": { + "name": "reported_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "pricing_provenance": { "name": "pricing_provenance", "type": "jsonb", @@ -28957,6 +29117,12 @@ "primaryKey": false, "notNull": true }, + "admitted_project_ids": { + "name": "admitted_project_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": false + }, "settlement_id": { "name": "settlement_id", "type": "uuid", @@ -29076,6 +29242,10 @@ "execution_admissions_settlement_check": { "name": "execution_admissions_settlement_check", "value": "(\"execution_admissions\".\"settlement_id\" is null) = (\"execution_admissions\".\"settled_at\" is null)" + }, + "execution_admissions_project_set_check": { + "name": "execution_admissions_project_set_check", + "value": "case when \"execution_admissions\".\"admitted_project_ids\" is null\n then \"execution_admissions\".\"settled_at\" is not null else array_position(\"execution_admissions\".\"admitted_project_ids\", null) is null and cardinality(\"execution_admissions\".\"admitted_project_ids\") <= 100 end" } }, "isRLSEnabled": false @@ -34016,6 +34186,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_run_events_usage_item_run_idx": { + "name": "heartbeat_run_events_usage_item_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_run_events\".\"event_type\" = 'item.completed' and (\"heartbeat_run_events\".\"payload\" -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_compaction_session_idx": { + "name": "heartbeat_run_events_compaction_session_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\" -> 'prpEvent' ->> 'normalizedSessionId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_run_events\".\"event_type\" = 'context.compacted'", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -44016,6 +44230,133 @@ }, "isRLSEnabled": false }, + "public.native_harness_backups": { + "name": "native_harness_backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_digest": { + "name": "scope_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reference": { + "name": "reference", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "native_harness_backups_current_idx": { + "name": "native_harness_backups_current_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"native_harness_backups\".\"status\" = 'current'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_harness_backups_scope_idx": { + "name": "native_harness_backups_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manifest_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.native_run_finalizations": { "name": "native_run_finalizations", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ae39b842ae7..47ff2896652 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3011,6 +3011,13 @@ "when": 1793798006496, "tag": "0431_magenta_vanisher", "breakpoints": true + }, + { + "idx": 432, + "version": "7", + "when": 1793916406980, + "tag": "0432_strong_mole_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/heartbeat_run_events.ts b/packages/db/src/schema/heartbeat_run_events.ts index 71da8bd3d9b..9b17e9bf47d 100644 --- a/packages/db/src/schema/heartbeat_run_events.ts +++ b/packages/db/src/schema/heartbeat_run_events.ts @@ -46,5 +46,13 @@ export const heartbeatRunEvents = pgTable( .where(sql`${table.sourceInstanceId} is not null and ${table.sourceSeq} is not null`), companyRunIdx: index("heartbeat_run_events_company_run_idx").on(table.companyId, table.runId), companyCreatedIdx: index("heartbeat_run_events_company_created_idx").on(table.companyId, table.createdAt), + // Context-window readings: the few usage items per run, newest first. + usageItemRunIdx: index("heartbeat_run_events_usage_item_run_idx") + .on(table.runId, table.createdAt) + .where(sql`${table.eventType} = 'item.completed' and (${table.payload} -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'`), + // Compactions counted per agent session across runs. + compactionSessionIdx: index("heartbeat_run_events_compaction_session_idx") + .on(table.companyId, sql`(${table.payload} -> 'prpEvent' ->> 'normalizedSessionId')`) + .where(sql`${table.eventType} = 'context.compacted'`), }), ); diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 3fab8f413c3..e3e902fc614 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -145,7 +145,7 @@ { "path": "schemas/usage.schema.json", "id": "https://paperclip.dev/schemas/prp/v1/usage.schema.json", - "sha256": "be9609768b224459546b800236045b375448f2cb2cb47ccb337e149e0b016361" + "sha256": "ed06a0b50c311e7d8ac6c6fe53f0178cc37a38b65831531efb53cb35c992319f" }, { "path": "schemas/workspace-diff.schema.json", diff --git a/packages/paperclip-runner/protocol/schemas/usage.schema.json b/packages/paperclip-runner/protocol/schemas/usage.schema.json index 1f6bf2e1094..e9b851afaea 100644 --- a/packages/paperclip-runner/protocol/schemas/usage.schema.json +++ b/packages/paperclip-runner/protocol/schemas/usage.schema.json @@ -78,7 +78,17 @@ }, "additionalProperties": false }, - "runDelta": { "$ref": "#/$defs/measurement" } + "runDelta": { "$ref": "#/$defs/measurement" }, + "context": { + "description": "Live context-window occupancy for the provider session after this usage, when the provider reports it.", + "type": "object", + "required": ["usedTokens", "windowTokens"], + "properties": { + "usedTokens": { "type": "integer", "minimum": 0 }, + "windowTokens": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } }, "additionalProperties": false, "$defs": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 5dab3b081c6..cd74a65a470 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -31,7 +31,7 @@ use crate::provider_bridge::{ TOOL_SET_SCHEMA, }; use crate::provider_events::{ - normalize_codex_notification, normalize_codex_notification_in, + codex_context_usage, normalize_codex_notification, normalize_codex_notification_in, normalized_codex_terminal_event_type, with_terminal_outcome, NormalizedProviderEvent, }; use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS}; @@ -723,6 +723,11 @@ fn normalize_provider_notification( .codex_usage .get_or_insert_with(|| CodexRunUsage::new(true)) .observe(total, historical); + let context = if historical { + None + } else { + codex_context_usage(params) + }; let mut normalized_params = params.clone(); let emit_usage = !historical || (had_baseline && increases_known_usage); if historical && emit_usage { @@ -743,6 +748,9 @@ fn normalize_provider_notification( if event.event_type == "usage.reported" { event.payload["runDeltaAvailable"] = json!(complete); event.payload["runDeltaBasis"] = json!("run"); + if let Some(context) = &context { + event.payload["context"] = context.clone(); + } } } return Ok(events); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 3e3d5611fcc..8414293cc76 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -832,6 +832,44 @@ fn measurement(value: &Value) -> Value { }) } +/// Context-window occupancy from a Codex `thread/tokenUsage/updated` payload. +/// Codex sizes the live context by the most recent request's total tokens. +pub(crate) fn codex_context_usage(params: &Value) -> Option { + let usage = params.get("tokenUsage").unwrap_or(params); + let window = usage + .get("modelContextWindow") + .and_then(Value::as_u64) + .filter(|value| *value > 0)?; + let last = usage.get("last")?; + let used = last + .get("totalTokens") + .and_then(Value::as_u64) + .or_else(|| { + let input = last.get("inputTokens").and_then(Value::as_u64)?; + Some( + input.saturating_add( + last.get("outputTokens") + .and_then(Value::as_u64) + .unwrap_or(0), + ), + ) + })?; + Some(json!({"usedTokens": used, "windowTokens": window})) +} + +/// Context-window occupancy from an ACP `usage_update` (`used` of `size`). +fn acpx_context_usage(payload: &Value) -> Option { + let window = payload + .get("size") + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 1.0)?; + let used = payload + .get("used") + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 0.0)?; + Some(json!({"usedTokens": used.round() as u64, "windowTokens": window.round() as u64})) +} + /// Converts Codex app-server notifications into provider-neutral PRP events. /// Provider-native envelopes are consumed here and never cross the PRP boundary. fn provider_performance_spans(params: &Value) -> Vec { @@ -1508,7 +1546,10 @@ pub fn normalize_acpx_runtime_event( AcpxRuntimeEventKind::Plan => stable_id(turn_id, fallback_item_id), _ => stable_id(string(payload.get("messageId")), fallback_item_id), }; - match kind { + let compaction = matches!(kind, AcpxRuntimeEventKind::ProviderNotice) + .then(|| acpx_compaction_notice_completed(payload, turn_id)) + .flatten(); + let mut events = match kind { AcpxRuntimeEventKind::TextDelta => vec![NormalizedProviderEvent { event_type: "item.delta".to_owned(), priority: EventPriority::P2, @@ -1583,6 +1624,48 @@ pub fn normalize_acpx_runtime_event( true, )], AcpxRuntimeEventKind::SemanticResult | AcpxRuntimeEventKind::Done => Vec::new(), + }; + events.extend(compaction); + events +} + +/// A completed `context_compaction` notice (the sidecar's form of Claude's +/// compaction banner) also marks a context compaction. +fn acpx_compaction_notice_completed( + payload: &Value, + turn_id: &str, +) -> Option { + if string(payload.get("category")) != "context_compaction" { + return None; + } + let completed = payload + .get("details") + .and_then(Value::as_array)? + .iter() + .any(|detail| { + string(detail.get("name")) == "Phase" && string(detail.get("value")) == "completed" + }); + if !completed { + return None; + } + Some(acpx_context_compacted(&stable_id( + &format!("{turn_id}:{}", string(payload.get("noticeKey"))), + "acpx-compaction", + ))) +} + +fn acpx_context_compacted(compaction_id: &str) -> NormalizedProviderEvent { + NormalizedProviderEvent { + event_type: "context.compacted".to_owned(), + priority: EventPriority::P1, + payload: json!({ + "schema": "paperclip.context.compacted.v1", + "compactionId": compaction_id, + "reason": "provider", + "preTokens": Value::Null, + "postTokens": Value::Null, + "sameSession": true, + }), } } @@ -1733,7 +1816,7 @@ fn normalize_acpx_status( "requests": 1, "providerCostUsd": if turn_cost { provider_cost } else { 0.0 }, }); - let usage = NormalizedProviderEvent { + let mut usage = NormalizedProviderEvent { event_type: "usage.reported".to_owned(), priority: EventPriority::P0, payload: json!({ @@ -1749,6 +1832,9 @@ fn normalize_acpx_status( "runDelta": run_delta, }), }; + if let Some(context) = acpx_context_usage(payload) { + usage.payload["context"] = context; + } // Claude forwards its SDK rate_limit_event on usage_update; the // sidecar passes only the sanitized `rateLimit` fields. let rate_limits = payload.get("rateLimit").and_then(|info| { @@ -2298,6 +2384,86 @@ mod tests { assert_eq!(cumulative[0].payload["runDelta"]["providerCostUsd"], 0.0); } + #[test] + fn acpx_usage_carries_context_window_occupancy() { + let usage = normalize_acpx_status( + &json!({"tag": "usage_update", "used": 34076, "size": 200000}), + "item", + "turn-1", + 1, + ); + assert_eq!( + usage[0].payload["context"], + json!({"usedTokens": 34076, "windowTokens": 200000}) + ); + for missing in [ + json!({"tag": "usage_update", "used": 10}), + json!({"tag": "usage_update", "used": 10, "size": 0}), + ] { + let usage = normalize_acpx_status(&missing, "item", "turn-1", 1); + assert!(usage[0].payload.get("context").is_none()); + } + } + + #[test] + fn codex_context_usage_reads_last_request_and_model_window() { + assert_eq!( + codex_context_usage(&json!({"tokenUsage": { + "total": {"inputTokens": 76682, "outputTokens": 1089}, + "last": {"inputTokens": 26000, "outputTokens": 672, "totalTokens": 26672}, + "modelContextWindow": 258400, + }})), + Some(json!({"usedTokens": 26672, "windowTokens": 258400})) + ); + assert_eq!( + codex_context_usage(&json!({"tokenUsage": { + "last": {"inputTokens": 100, "outputTokens": 20}, "modelContextWindow": 1000, + }})), + Some(json!({"usedTokens": 120, "windowTokens": 1000})) + ); + assert_eq!( + codex_context_usage(&json!({"tokenUsage": {"last": {"totalTokens": 5}}})), + None + ); + } + + #[test] + fn compaction_banner_text_alone_is_not_a_compaction() { + // The sidecar turns Claude's banner into a `context_compaction` + // notice; only that notice marks the compaction, so it is counted once. + let events = normalize_acpx_runtime_event( + AcpxRuntimeEventKind::TextDelta, + &json!({"text": "\n\nCompacting completed."}), + None, + "fallback", + "turn-1", + 0, + ); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "item.delta"); + } + + #[test] + fn completed_compaction_notice_emits_context_compacted() { + let notice = |phase: &str| { + normalize_acpx_runtime_event( + AcpxRuntimeEventKind::ProviderNotice, + &json!({"severity": "info", "category": "context_compaction", + "summary": "Compacting context", "noticeKey": "compaction-1", + "details": [{"name": "Phase", "value": phase}]}), + None, + "fallback", + "turn-1", + 0, + ) + }; + assert_eq!(notice("running").len(), 1); + let completed = notice("completed"); + assert_eq!(completed.len(), 2); + assert_eq!(completed[1].event_type, "context.compacted"); + assert_eq!(completed[1].payload["compactionId"], "turn-1:compaction-1"); + } + #[test] fn performance_diagnostics_keep_only_bounded_content_free_measurements() { let mut spans = vec![json!({"name":"private prompt","startedAtMs":1,"endedAtMs":2})]; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 08d75ae2859..650e4f3475a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -3108,6 +3108,20 @@ it("rehydrates normalized usage with the opened driver binding", () => { }); }); +it("keeps reported context-window occupancy on rehydrated usage", () => { + const base = { cumulative: { inputTokens: 10 }, runDelta: { inputTokens: 3 }, runDeltaAvailable: true }; + const params = rehydrateRunnerdUsageNotification( + { ...base, context: { usedTokens: 26672, windowTokens: 258400 } }, + "opened-thread-1", + "active-turn-1", + ); + expect(params.tokenUsage).toMatchObject({ context: { usedTokens: 26672, windowTokens: 258400 } }); + for (const context of [undefined, { usedTokens: -1, windowTokens: 10 }, { usedTokens: 1, windowTokens: 0 }]) { + const rehydrated = rehydrateRunnerdUsageNotification({ ...base, context }, "opened-thread-1", "active-turn-1"); + expect(rehydrated.tokenUsage).not.toHaveProperty("context"); + } +}); + it("rehydrates durable cumulative usage for a cold thread read", () => { expect( rehydrateRunnerdThreadTokenUsage({ inputTokens: 12, outputTokens: 3 }), diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 32b35cebcce..d9d3cb1fb27 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1879,10 +1879,21 @@ export function rehydrateRunnerdUsageNotification( // authority beside the counters so ACPX/managed driver projections and // durable replay cannot certify an incomplete delta as a free receipt. runDeltaComplete: rawParams.runDeltaAvailable === true, + ...runnerdContextUsage(rawParams.context), }, }; } +/** Context-window occupancy carried on PRP `usage.reported`, when the provider reported it. */ +function runnerdContextUsage(value: unknown): { context?: { usedTokens: number; windowTokens: number } } { + const context = record(value); + const usedTokens = context.usedTokens; + const windowTokens = context.windowTokens; + if (typeof usedTokens !== "number" || !Number.isSafeInteger(usedTokens) || usedTokens < 0) return {}; + if (typeof windowTokens !== "number" || !Number.isSafeInteger(windowTokens) || windowTokens < 1) return {}; + return { context: { usedTokens, windowTokens } }; +} + export function rehydrateRunnerdThreadTokenUsage( cumulative: unknown, ): { total: Record } | null { diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts index 1a58a486501..ac136d0e69a 100644 --- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -3673,6 +3673,25 @@ export const usageSchema = { }, "runDelta": { "$ref": "#/$defs/measurement" + }, + "context": { + "description": "Live context-window occupancy for the provider session after this usage, when the provider reports it.", + "type": "object", + "required": [ + "usedTokens", + "windowTokens" + ], + "properties": { + "usedTokens": { + "type": "integer", + "minimum": 0 + }, + "windowTokens": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false } }, "additionalProperties": false, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 03fa88d7132..5c77c6f6eb2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,6 +22,7 @@ export { type RunnerGoalActionRequest, type RunnerGoalActionAccepted, } from "./runner-goal.js"; +export type { IssueContextUsage } from "./issue-context-usage.js"; export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js"; export { CONNECTION_INTENT_AGENT_GUIDANCE, diff --git a/packages/shared/src/issue-context-usage.ts b/packages/shared/src/issue-context-usage.ts new file mode 100644 index 00000000000..9e4641deb2b --- /dev/null +++ b/packages/shared/src/issue-context-usage.ts @@ -0,0 +1,23 @@ +/** + * Latest context-window occupancy for the agent session working on a task, + * read from the provider usage the runner recorded for the task's runs. + */ +export interface IssueContextUsage { + runId: string; + agentId: string; + /** Normalized runner session the measurement belongs to. */ + sessionId: string | null; + usedTokens: number; + windowTokens: number; + observedAt: string; + /** Session totals when the provider reported non-zero counters. */ + totals: { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + } | null; + /** Provider-reported session cost in USD, when non-zero. */ + costUsd: number | null; + /** Compactions recorded for this session. */ + compactions: number; +} diff --git a/server/src/__tests__/issue-context-usage.test.ts b/server/src/__tests__/issue-context-usage.test.ts new file mode 100644 index 00000000000..91eef26a181 --- /dev/null +++ b/server/src/__tests__/issue-context-usage.test.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db"; +import { issueContextUsage } from "../services/issue-context-usage.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const suite = support.supported ? describe : describe.skip; +suite("issue context usage", () => { + let db: ReturnType; + let temporaryDb: Awaited>; + beforeAll(async () => { + temporaryDb = await startEmbeddedPostgresTestDatabase("issue-context-usage-"); + db = createDb(temporaryDb.connectionString); + }, 60_000); + afterAll(async () => { await temporaryDb?.cleanup(); }); + + function prp(runId: string, sessionId: string, eventType: string, payload: Record) { + return { prpEvent: { schema: "paperclip.prp.event.v1", runId, normalizedSessionId: sessionId, eventType, payload } }; + } + + it("returns the latest reported context window and counts the session's compactions", async () => { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), otherIssueId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Context fixture", issuePrefix: `X${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Codie", role: "engineer", status: "idle", adapterType: "codex_local" }); + await db.insert(issues).values([ + { id: issueId, companyId, title: "Context", status: "in_progress", assigneeAgentId: agentId }, + { id: otherIssueId, companyId, title: "Other", status: "in_progress", assigneeAgentId: agentId }, + ]); + expect(await issueContextUsage(db, companyId, { id: issueId, assigneeAgentId: agentId })).toBeNull(); + + const runId = randomUUID(), otherRunId = randomUUID(); + await db.insert(heartbeatRuns).values([ + { id: runId, companyId, agentId, scopeKind: "issue", issueId, status: "running" }, + { id: otherRunId, companyId, agentId, scopeKind: "issue", issueId: otherIssueId, status: "running" }, + ]); + const usage = (usedTokens: number, total = { inputTokens: 76_682, cacheReadTokens: 48_896, outputTokens: 1_089 }) => ({ + kind: "usage", + usage: { total, runDelta: total, context: { usedTokens, windowTokens: 258_400 } }, + }); + const base = { companyId, agentId, stream: "system" as const, level: "info" as const }; + await db.insert(heartbeatRunEvents).values([ + { ...base, runId, seq: 1, eventType: "item.completed", payload: prp(runId, "session-a", "item.completed", usage(229_500)), + createdAt: new Date("2026-11-05T10:00:00Z") }, + { ...base, runId, seq: 2, eventType: "context.compacted", payload: prp(runId, "session-a", "context.compacted", { + schema: "paperclip.context.compacted.v1", compactionId: "c-1" }), createdAt: new Date("2026-11-05T10:00:01Z") }, + { ...base, runId, seq: 3, eventType: "item.completed", payload: prp(runId, "session-a", "item.completed", usage(38_200)), + createdAt: new Date("2026-11-05T10:00:02Z") }, + { ...base, runId, seq: 4, eventType: "item.completed", payload: prp(runId, "session-a", "item.completed", { kind: "usage", usage: { total: {} } }), + createdAt: new Date("2026-11-05T10:00:03Z") }, + { ...base, runId: otherRunId, seq: 1, eventType: "item.completed", payload: prp(otherRunId, "session-b", "item.completed", usage(5)), + createdAt: new Date("2026-11-05T10:00:04Z") }, + ]); + + const issue = { id: issueId, assigneeAgentId: agentId }; + expect(await issueContextUsage(db, companyId, issue)).toMatchObject({ + runId, + agentId, + sessionId: "session-a", + usedTokens: 38_200, + windowTokens: 258_400, + totals: { inputTokens: 76_682, cachedInputTokens: 48_896, outputTokens: 1_089 }, + costUsd: null, + compactions: 1, + }); + expect(await issueContextUsage(db, randomUUID(), issue)).toBeNull(); + // Another assignee's session never shows through. + expect(await issueContextUsage(db, companyId, { id: issueId, assigneeAgentId: randomUUID() })).toBeNull(); + expect(await issueContextUsage(db, companyId, { id: issueId, assigneeAgentId: null })).toBeNull(); + + // A newer run that starts a fresh session hides the old session's reading + // until the new session reports its own context. + const freshRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: freshRunId, companyId, agentId, scopeKind: "issue", issueId, status: "running", + createdAt: new Date("2026-11-05T11:00:00Z") }); + await db.insert(heartbeatRunEvents).values({ ...base, runId: freshRunId, seq: 1, eventType: "item.completed", + payload: prp(freshRunId, "session-c", "item.completed", { kind: "usage", usage: { total: {} } }), + createdAt: new Date("2026-11-05T11:00:01Z") }); + expect(await issueContextUsage(db, companyId, issue)).toBeNull(); + await db.insert(heartbeatRunEvents).values({ ...base, runId: freshRunId, seq: 2, eventType: "item.completed", + payload: prp(freshRunId, "session-c", "item.completed", usage(12_000)), createdAt: new Date("2026-11-05T11:00:02Z") }); + expect(await issueContextUsage(db, companyId, issue)).toMatchObject({ sessionId: "session-c", usedTokens: 12_000, compactions: 0 }); + + // A session reused across more runs than the lookup window still counts + // compactions from its oldest runs. + const later = ["2026-11-05T12:00:00Z", "2026-11-05T13:00:00Z", "2026-11-05T14:00:00Z"]; + for (const [index, at] of later.entries()) { + const id = randomUUID(); + await db.insert(heartbeatRuns).values({ id, companyId, agentId, scopeKind: "issue", issueId, status: "succeeded", createdAt: new Date(at) }); + await db.insert(heartbeatRunEvents).values({ ...base, runId: id, seq: 1, eventType: "item.completed", + payload: prp(id, "session-c", "item.completed", usage(13_000 + index)), createdAt: new Date(at) }); + } + await db.insert(heartbeatRunEvents).values({ ...base, runId: freshRunId, seq: 3, eventType: "context.compacted", + payload: prp(freshRunId, "session-c", "context.compacted", { schema: "paperclip.context.compacted.v1", compactionId: "c-2" }), + createdAt: new Date("2026-11-05T11:00:03Z") }); + expect(await issueContextUsage(db, companyId, issue)).toMatchObject({ sessionId: "session-c", usedTokens: 13_002, compactions: 1 }); + }); +}); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 7a9b187dbab..8fd095361d3 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -10,6 +10,7 @@ import { sanitizeRecord } from "../redaction.js"; import { badRequest, forbidden } from "../errors.js"; import { agentActionAuditService } from "../services/agent-action-audit.js"; import { logActivity } from "../services/activity-log.js"; +import { issueContextUsage } from "../services/issue-context-usage.js"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; import { issueReadSqlCondition } from "../services/authorization.js"; import { caseActivityReadSqlCondition } from "../services/case-activity-access.js"; @@ -387,6 +388,13 @@ export function activityRoutes(db: Db) { res.json(result); }); + router.get("/issues/:id/context-usage", async (req, res) => { + const issue = await getAccessibleResource(req, res, resolveIssueByRef(req.params.id as string), "Issue not found"); + if (!issue) return; + if (!(await assertIssueReadAllowed(req, res, issue))) return; + res.json(await issueContextUsage(db, issue.companyId, issue)); + }); + router.get("/heartbeat-runs/:runId/issues", async (req, res) => { assertAuthenticated(req); const runId = req.params.runId as string; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index b20abbaabde..1d0a32e65be 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6168,6 +6168,15 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/context-usage", + tags: ["activity"], + summary: "Latest context-window usage for an issue's agent session", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + registry.registerPath({ method: "get", path: "/api/heartbeat-runs/{runId}/issues", diff --git a/server/src/services/issue-context-usage.ts b/server/src/services/issue-context-usage.ts new file mode 100644 index 00000000000..837b76b1967 --- /dev/null +++ b/server/src/services/issue-context-usage.ts @@ -0,0 +1,116 @@ +import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { heartbeatRunEvents, heartbeatRuns } from "@paperclipai/db"; +import type { IssueContextUsage } from "@paperclipai/shared"; + +/** + * Recent runs of the assignee searched for its newest usage report. The + * usage lookups use `heartbeat_run_events_usage_item_run_idx`, so they read + * only the few usage items of these runs. + */ +const RECENT_RUN_LIMIT = 3; + +function count(value: unknown): number | null { + const parsed = typeof value === "string" ? Number(value) : value; + return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +/** Matches the predicate of `heartbeat_run_events_usage_item_run_idx`. */ +const usageItem = sql`${heartbeatRunEvents.eventType} = 'item.completed' and (${heartbeatRunEvents.payload} -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'`; +/** Matches the key expression of `heartbeat_run_events_compaction_session_idx`. */ +const sessionIdSql = sql`(${heartbeatRunEvents.payload} -> 'prpEvent' ->> 'normalizedSessionId')`; + +/** + * Reads the most recent provider-reported context occupancy for the current + * agent session on a task. The current session is the one behind the + * assignee's newest usage report: after a reassignment or a fresh session, + * older readings are not returned. Returns null until that session reports a + * context window. + */ +export async function issueContextUsage( + db: Db, + companyId: string, + issue: { id: string; assigneeAgentId: string | null }, +): Promise { + if (!issue.assigneeAgentId) return null; + const runs = await db + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.agentId, issue.assigneeAgentId), + or(eq(heartbeatRuns.issueId, issue.id), eq(heartbeatRuns.nativeIssueId, issue.id)), + )) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(RECENT_RUN_LIMIT); + if (runs.length === 0) return null; + const runIds = runs.map((run) => run.id); + + const recentUsage = and( + eq(heartbeatRunEvents.companyId, companyId), + inArray(heartbeatRunEvents.runId, runIds), + usageItem, + ); + // The newest usage report names the current session. + const [current] = await db + .select({ sessionId: sessionIdSql }) + .from(heartbeatRunEvents) + .where(recentUsage) + .orderBy(desc(heartbeatRunEvents.createdAt), desc(heartbeatRunEvents.id)) + .limit(1); + const sessionId = current?.sessionId ?? null; + if (!sessionId) return null; + + const [latest] = await db + .select({ + runId: heartbeatRunEvents.runId, + agentId: heartbeatRunEvents.agentId, + createdAt: heartbeatRunEvents.createdAt, + usage: sql`${heartbeatRunEvents.payload} -> 'prpEvent' -> 'payload' -> 'usage'`, + }) + .from(heartbeatRunEvents) + .where(and( + recentUsage, + sql`${sessionIdSql} = ${sessionId}`, + sql`${heartbeatRunEvents.payload} -> 'prpEvent' -> 'payload' -> 'usage' ? 'context'`, + )) + .orderBy(desc(heartbeatRunEvents.createdAt), desc(heartbeatRunEvents.id)) + .limit(1); + if (!latest) return null; + const usage = record(latest.usage); + const context = record(usage?.context); + const usedTokens = count(context?.usedTokens); + const windowTokens = count(context?.windowTokens); + if (usedTokens === null || windowTokens === null || windowTokens < 1) return null; + + // Every compaction of the session, across all of its runs. + const [compactions] = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRunEvents) + .where(and( + eq(heartbeatRunEvents.companyId, companyId), + eq(heartbeatRunEvents.eventType, "context.compacted"), + sql`${sessionIdSql} = ${sessionId}`, + )); + + const total = record(usage?.total); + const inputTokens = count(total?.inputTokens) ?? 0; + const cachedInputTokens = count(total?.cacheReadTokens) ?? count(total?.cachedInputTokens) ?? 0; + const outputTokens = count(total?.outputTokens) ?? 0; + const costUsd = count(total?.providerCostUsd); + return { + runId: latest.runId, + agentId: latest.agentId, + sessionId, + usedTokens, + windowTokens, + observedAt: latest.createdAt.toISOString(), + totals: inputTokens + outputTokens > 0 ? { inputTokens, cachedInputTokens, outputTokens } : null, + costUsd: costUsd && costUsd > 0 ? costUsd : null, + compactions: compactions?.count ?? 0, + }; +} diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 6fac8563623..64f70cef752 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -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(`/issues/${id}`, options) : api.get(`/issues/${id}`), + getContextUsage: (id: string) => api.get(`/issues/${id}/context-usage`), getRunnerGoal: (id: string, agentId?: string | null) => { const query = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""; return api.get(`/issues/${id}/runner-goal${query}`); diff --git a/ui/src/components/ContextUsageMeter.test.tsx b/ui/src/components/ContextUsageMeter.test.tsx new file mode 100644 index 00000000000..fb267565e9a --- /dev/null +++ b/ui/src/components/ContextUsageMeter.test.tsx @@ -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"); + }); +}); diff --git a/ui/src/components/ContextUsageMeter.tsx b/ui/src/components/ContextUsageMeter.tsx new file mode 100644 index 00000000000..f2b1298d768 --- /dev/null +++ b/ui/src/components/ContextUsageMeter.tsx @@ -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 = { + normal: "text-foreground", + near: "text-(--status-agent-paused)", + full: "text-(--status-agent-error)", +}; + +const LEVEL_FILL: Record = { + 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 ( +
+
+
+ ); +} + +function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+
+ Context window + {percent}% +
+

+ {formatContextTokens(usage.usedTokens)} of {formatContextTokens(usage.contextWindow)} tokens + {usage.observedAt ? <> · updated {timeAgo(usage.observedAt)} : null} +

+ + {level !== "normal" ? ( +

+ {usage.autoCompactTokens + ? `Compacts automatically near ${formatContextTokens(usage.autoCompactTokens)}.` + : "Close to full. The agent will compact its context soon."} +

+ ) : null} + {totals || usage.lastTurn || usage.costUsd != null || usage.compactions ? ( +
+ {totals?.inputTokens != null ? ( + + ) : null} + {totals?.outputTokens != null ? : null} + {totals?.reasoningOutputTokens ? ( + + ) : null} + {usage.lastTurn ? ( + + ) : null} + {usage.costUsd != null ? : null} + {usage.compactions ? : null} +
+ ) : null} +
+ ); +} + +/** + * 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 | 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 ( + + + + + event.preventDefault()} + onMouseEnter={show} + onMouseLeave={hide} + > + + + + ); +} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 003cd7030dd..9588866537d 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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([]); diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index c3b94f2d818..848d447c317 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -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} + + {contextUsage !== undefined + ? contextUsage ? {contextUsage} : null + : } {workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( diff --git a/ui/src/components/issue-properties/context-usage-row.test.tsx b/ui/src/components/issue-properties/context-usage-row.test.tsx new file mode 100644 index 00000000000..cc1d100e1de --- /dev/null +++ b/ui/src/components/issue-properties/context-usage-row.test.tsx @@ -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( + + + , + )); +} + +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%"); + }); +}); diff --git a/ui/src/components/issue-properties/context-usage-row.tsx b/ui/src/components/issue-properties/context-usage-row.tsx new file mode 100644 index 00000000000..4cf177c0561 --- /dev/null +++ b/ui/src/components/issue-properties/context-usage-row.tsx @@ -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 ( + + + + ); + } + if (view.kind !== "error") return null; + return ( + + + + ); +} diff --git a/ui/src/components/task-chat/TaskChatMarker.tsx b/ui/src/components/task-chat/TaskChatMarker.tsx index be3ba871dc9..4fbf83a6a37 100644 --- a/ui/src/components/task-chat/TaskChatMarker.tsx +++ b/ui/src/components/task-chat/TaskChatMarker.tsx @@ -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; /** diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 1d556e28a35..0f67e5ef92b 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -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" ? ( + ) : row.kind === "subagent_group" ? ( ) : row.kind === "protocol" ? ( diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 8029f302061..068a8fed96b 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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. */ diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index dcb39311396..bfe6d702a64 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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, + 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" && diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index df1ea682db1..9920b0daa17 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -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) => event(seq, eventType, { diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 48bf700b3e4..7f7c0c57ffc 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -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(); + // 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 | null = null; + let lastCompaction: Record | null = null; + let usageSinceLastCompaction = false; const proposalMarkdownById = new Map(); const completedProposalIds = new Set(); 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 = { + ...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; diff --git a/ui/src/lib/context-tokens.ts b/ui/src/lib/context-tokens.ts new file mode 100644 index 00000000000..b2b83b76428 --- /dev/null +++ b/ui/src/lib/context-tokens.ts @@ -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$/, ""); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 1dc236ce7ad..3c88ecfec28 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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) => diff --git a/ui/storybook/stories/tasks/task-context-window.stories.tsx b/ui/storybook/stories/tasks/task-context-window.stories.tsx new file mode 100644 index 00000000000..45aacbda9a9 --- /dev/null +++ b/ui/storybook/stories/tasks/task-context-window.stories.tsx @@ -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; + + +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 ( + +
+
Properties
+ +
+ undefined} + contextUsage={} + /> +
+
+
+
+ ); +} + +function Page({ children, narrow }: { children: ReactNode; narrow?: boolean }) { + return ( +
+
{children}
+
+ ); +} + +function StateCard({ title, rule, children }: { title: string; rule: string; children: ReactNode }) { + return ( +
+
+

{title}

+

{rule}

+
+ {children} +
+ ); +} + +/** A Properties-style row without the full panel, for comparing states. */ +function MeterRow({ usage }: { usage: ContextUsage }) { + return ( +
+ Context + +
+ ); +} + +function TaskPage() { + return ( +
+
+

Port billing exporter to the new SDK

+ + + +
+
+ +
+
+ ); +} + +function AllStates() { + return ( + +
+
+

Context window

+

+ A Context row in the task's Properties shows how full the assigned agent's context window is. Hover + or tap it for the token breakdown. Codex reports usage through{" "} + thread/tokenUsage/updated, Claude through ACP{" "} + usage_update. +

+
+ + + + + + + + + + + + + +

(no row)

+
+ +
+ + + +
+
+
+
+ ); +} + +function Breakdown({ usage }: { usage: ContextUsage }) { + return ( +
+ +
+ ); +} + +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) => ( +
+ ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const InTask: Story = { render: () => }; +export const AllStatesGallery: Story = { render: () => }; +export const BreakdownNearCompaction: Story = { render: () => }; +export const BreakdownWorking: Story = { render: () => }; +export const BreakdownClaudeWithCost: Story = { render: () => };