mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through a harness (Codex app-server, or Claude through ACPX), and the runner turns harness events into Paperclip events > - Both harnesses report how full the model's context window is. Codex sends `modelContextWindow` with its token usage. Claude sends `used` and `size` in ACP `usage_update` > - Paperclip dropped both values. The task view could not show how close an agent is to compaction, and a compaction left no visible mark in the task chat > - This pull request carries the context-window occupancy through the runner, stores it with the existing usage events, and shows it as a "Context" row in the task Properties panel > - It also shows a "Context compacted · 230k → 38k tokens" divider in the task chat when the agent compacts its context > - The benefit is that operators can see when a long task is near its context limit and when the agent lost detail to a compaction ## Linked Issues or Issue Description Refs #19252 (same pattern: a canonical harness event turned into task-chat UI). Refs #19267 (status notices; it turns Claude's compaction banner into a notice, and this PR reads that notice too). **What existing behavior does this improve?** The task view shows no context-window information, and a compaction is invisible in the task chat. **Subsystem affected** Runner usage normalization (Rust and TypeScript transport), the PRP usage schema, a read-only issue API, the task Properties panel and the task-chat transcript. **Current behavior** Codex `modelContextWindow` and ACP `used`/`size` are dropped in the runner. `context.compacted` always has null sizes, Claude compactions emit no `context.compacted` event at all, and the live runner turn hides every marker except "interrupted". **Proposed behavior** `usage.reported` carries an optional `context {usedTokens, windowTokens}`. The task Properties panel shows a Context row ("10% of 258k") with a hover breakdown (tokens used, input and cached input, output, cost, compactions). The transcript shows a compaction divider, with sizes from the usage before and after the compaction. **Reason and benefit** Operators can see when a long task is close to its context limit, and they can see where the agent's earlier context was summarized. **Breaking changes** None. The new schema field is optional, and the new endpoint is read-only. ## What Changed - PRP `usage.schema.json`: add an optional `context {usedTokens, windowTokens}` object. Regenerate the schema bundle and the protocol manifest. - Rust `provider_events.rs`: add `codex_context_usage` (last request `totalTokens` and `modelContextWindow`) and `acpx_context_usage` (ACP `used`/`size`). ACPX `usage_update` now sets `context`. - Rust `provider_backend.rs`: Codex `thread/tokenUsage/updated` sets `context` on live (non-historical) usage. - Rust `provider_events.rs`: emit `context.compacted` for the completed `context_compaction` provider notice that #19267 produces from Claude's compaction banner. Only this path marks a Claude compaction, so the server counts each compaction once. - `runnerd-codex-transport.ts`: `rehydrateRunnerdUsageNotification` keeps a valid `context` on `tokenUsage`, so it reaches the stored `item.completed` usage item. - Server: new `GET /api/issues/:id/context-usage` (issue read access is required) and `issueContextUsage` service. It reads only the assignee's current session (the session of its newest usage report), and returns that session's latest context reading, non-zero totals, cost, and the count of compactions across all of the session's runs. Added to OpenAPI. Shared type `IssueContextUsage`. - DB: migration 0432 adds two partial indexes on `heartbeat_run_events`, built concurrently. One indexes usage items by run and time. The other indexes `context.compacted` events by company and session. `EXPLAIN` shows that both lookups use them. - UI: new `ContextUsageMeter` (bar, percentage, hover card) and `IssueContextUsageRow` in the Execution section of Properties. The row refreshes every 8 s while a run is active, and once more when the run ends. - UI transcript: `native-run-events.ts` maps `context.compacted` to a `context` activity entry with pre/post sizes from neighbouring usage items. `transcript-adapter.ts` turns it into a new `context_compacted` marker variant. The live runner turn keeps that marker as its own timeline row. - Storybook: `task-context-window.stories.tsx` (Properties panel in a task, all states, hover breakdowns, divider). ## Verification - `cargo test -p paperclip-runner-core --lib provider_events` (new tests: ACPX context, Codex context, banner compaction, notice compaction) - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport.test.ts` - `cd ui && npx vitest run src/components/ContextUsageMeter.test.tsx src/components/transcript src/components/task-chat src/components/issue-properties` - `cd server && npx vitest run src/__tests__/issue-context-usage.test.ts` (embedded Postgres) - `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`, `pnpm --filter @paperclipai/paperclip-runner run check:protocol-types` - Live check on a local worktree instance through the runnerd path: - A Codex `gpt-5.5` task showed "10% of 258k" (27k of 258k, input 77k with 49k cached, output 1.1k). - A Claude Sonnet 4.6 task showed "17% of 200k" (34k of 200k). - Storybook: Tasks and conversations / … / Task chat / Context window. - Not verified live: a real compaction. The divider is covered by unit tests through the same projector path the task chat uses. ## Risks - The schema field is optional, and older runners omit it. The UI renders nothing until a harness reports a window. - Migration 0432 builds two partial indexes concurrently on `heartbeat_run_events`, which is a large table. The indexes cover only usage items and compaction events, so they stay small. They add a little write overhead to those events. - Claude in-progress usage rows still record zero tokens with `requests: 1`. The final turn usage is correct. This PR does not change accounting. - A "Compact now" action is out of scope. It needs a compaction-only run that does not produce a run result. It will come in a follow-up. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, in-app browser for Storybook and the live instance). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
import { useId, useState } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
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";
|
||
import { Link } from "@/lib/router";
|
||
import { timeAgo } from "@/lib/timeAgo";
|
||
|
||
const VARIANT_ICON = {
|
||
session_start: CircleDot,
|
||
interrupted: OctagonX,
|
||
turn_boundary: Flag,
|
||
context_compacted: FoldVertical,
|
||
} as const;
|
||
|
||
/**
|
||
* Lifecycle divider — renders a state boundary (session start, interruption,
|
||
* turn boundary) as a recessed rule. Interruptions use a compact row without
|
||
* divider rules, including older items that did not set `collapsible`.
|
||
*/
|
||
export function TaskChatMarker({
|
||
item,
|
||
onTryAgain,
|
||
tryAgainPending = false,
|
||
}: {
|
||
item: TaskChatMarkerItem;
|
||
onTryAgain?: () => Promise<void> | void;
|
||
tryAgainPending?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const detailsId = useId();
|
||
const streamlined = useStreamlinedTaskChatPresentation();
|
||
const Icon = item.tone === "neutral" ? Square : VARIANT_ICON[item.variant];
|
||
const interrupted = item.variant === "interrupted" && item.tone !== "neutral";
|
||
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
|
||
const handleTryAgain = () => {
|
||
void Promise.resolve()
|
||
.then(() => onTryAgain?.())
|
||
// The parent mutation owns visible error feedback.
|
||
.catch(() => undefined);
|
||
};
|
||
|
||
if (item.credentialAccess) {
|
||
const { agentName, credentialName, deniedUser, settingsHref } = item.credentialAccess;
|
||
return (
|
||
<div role="alert" data-testid="task-chat-credential-access-notice"
|
||
className="tc-enter-marker flex w-full items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm">
|
||
<KeyRound className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />
|
||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||
<p className="font-semibold text-destructive">{agentName} couldn’t start</p>
|
||
<p className="break-words font-medium text-foreground">
|
||
{agentName} is configured to use {credentialName || "an AI credential"}, but {deniedUser === "you" ? "you don’t" : `${deniedUser} doesn’t`} have access to that credential.
|
||
</p>
|
||
<p className="text-muted-foreground">
|
||
Choose an AI connection {deniedUser === "you" ? "you can" : `${deniedUser} can`} use, or ask an administrator to update {agentName}’s connection. Then try again.
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||
<Button asChild variant="outline" size="sm"><Link to={settingsHref}>Change AI connection</Link></Button>
|
||
{item.runHref && <Button asChild variant="ghost" size="sm"><Link to={item.runHref}>View run</Link></Button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (item.collapsible) {
|
||
return (
|
||
<div
|
||
className="tc-enter-marker flex flex-col items-start py-1 text-xs text-muted-foreground"
|
||
data-testid="task-chat-collapsible-marker"
|
||
>
|
||
<div className="flex max-w-(--pct-85) items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((value) => !value)}
|
||
aria-expanded={open}
|
||
aria-controls={detailsId}
|
||
className={cn(
|
||
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5",
|
||
interrupted ? "text-destructive" : "text-muted-foreground",
|
||
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||
)}
|
||
>
|
||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||
<span className="truncate font-medium">{item.label}</span>
|
||
{relative ? (
|
||
<span className="shrink-0 text-muted-foreground">
|
||
· {relative}
|
||
</span>
|
||
) : null}
|
||
<ChevronDown
|
||
className={cn(
|
||
"tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform",
|
||
open && "rotate-180",
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
</button>
|
||
{onTryAgain && !open ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="xs"
|
||
onClick={handleTryAgain}
|
||
disabled={tryAgainPending}
|
||
data-testid="task-chat-run-failed-try-again"
|
||
>
|
||
{tryAgainPending ? "Trying again..." : "Try again"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{open ? (
|
||
<div
|
||
id={detailsId}
|
||
data-testid="task-chat-collapsible-marker-details"
|
||
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
|
||
>
|
||
{item.detail ? (
|
||
<div className="px-3 py-2.5 text-foreground/90">
|
||
{item.detail}
|
||
</div>
|
||
) : null}
|
||
{item.runHref || item.planHref || onTryAgain ? (
|
||
<div className="flex items-center justify-end gap-2 border-t border-border/70 bg-background/50 px-3 py-2 dark:bg-background/30">
|
||
{item.planHref ? (
|
||
<Button asChild variant="ghost" size="xs">
|
||
<Link to={item.planHref}>View saved plan</Link>
|
||
</Button>
|
||
) : null}
|
||
{item.runHref ? (
|
||
<Button asChild variant="ghost" size="xs">
|
||
<Link to={item.runHref}>View run</Link>
|
||
</Button>
|
||
) : null}
|
||
{onTryAgain ? (
|
||
<Button
|
||
type="button"
|
||
size="xs"
|
||
onClick={handleTryAgain}
|
||
disabled={tryAgainPending}
|
||
data-testid="task-chat-run-failed-try-again"
|
||
>
|
||
{tryAgainPending ? "Trying again..." : "Try again"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="tc-enter-marker flex items-center gap-2 py-1 text-xs text-muted-foreground"
|
||
role={streamlined && item.variant !== "interrupted" ? "separator" : undefined}
|
||
aria-label={streamlined && item.variant !== "interrupted" ? item.label : undefined}
|
||
>
|
||
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
|
||
<span
|
||
className={cn(
|
||
"flex min-w-0 flex-wrap items-center gap-1.5",
|
||
interrupted && "text-destructive",
|
||
)}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
<span className="font-medium">{item.label}</span>
|
||
{item.detail ? (
|
||
<span className="text-muted-foreground">· {item.detail}</span>
|
||
) : null}
|
||
{onTryAgain ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="xs"
|
||
className="text-foreground"
|
||
onClick={handleTryAgain}
|
||
disabled={tryAgainPending}
|
||
data-testid="task-chat-run-failed-try-again"
|
||
>
|
||
{tryAgainPending ? "Trying again..." : "Try again"}
|
||
</Button>
|
||
) : null}
|
||
</span>
|
||
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
|
||
</div>
|
||
);
|
||
}
|