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