mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - A task's Properties panel shows the assignee agent's context window
(paperclipai/paperclip#19273)
> - The board can see that the context is nearly full, but it cannot act
on it
> - A full context makes the agent compact at an unplanned time, often
in the middle of work
> - This pull request adds "Compact now" to the Context hover card
> - The benefit is that the board can compact the agent's context
between runs, at a time it chooses
## Linked Issues or Issue Description
Refs paperclipai/paperclip#19273
**Problem**
The Context row shows how full the agent's context window is. The board
cannot ask the agent to compact it.
**Proposed solution**
A "Compact now" button in the Context hover card. It runs the harness's
own compaction: Codex `thread/compact/start`, and Claude's `/compact`
command through ACP.
**Alternatives considered**
A separate PRP `session.compact` command. Both harnesses run compaction
as a turn, so a compaction turn keeps the existing durable turn binding,
interruption, and recovery. A separate command needs a second turn
lifecycle.
## What Changed
- UI: the Context hover card shows Compact now with three states:
available, Compacting…, and unavailable with a reason (agent mid-run,
harness unsupported, task closed). The Storybook story shows each state.
- Server: `POST /api/issues/:id/context/compact` (board only,
`decideIssueAccess` `issue:mutate`, activity log
`issue.context_compaction_requested`). It queues a short run marked by
`contextSnapshot.contextCompactionRequestId`. `GET
/issues/:id/context-usage` returns the `compaction` state.
- Server: a compaction run does no task work. The native status arbiter
preserves the task status. The lifecycle skips handoff, liveness
continuation, review-path disposition and the issue comment, as it does
for goal-control runs.
- Runner TS: `startTurn({ compact: true })`. The Codex harness session
sends `thread/compact/start`. The runnerd transport maps it to PRP
`turn.start` with `compact: true` (Codex) or `text: "/compact"`
(Claude/ACPX). The native runtime option `sessionCompaction` requires a
resumed session and settles with a synthesized yielded result.
- Runner Rust: `turn.start` accepts `compact: true` for Codex.
`CodexProvider::start_compaction_turn` sends `thread/compact/start` and
binds the turn from the `turn/started` that follows the empty response.
- Fix (from #19273): Codex emitted `context.compacted` on both the start
and the end of one compaction item. It now emits it once, and the count
is distinct by `compactionId`.
- Fix (from #19273): the conversation event view dropped
`context.compacted`, so a finished run never showed the divider. The
view now keeps compactions, plus context readings for runs that
compacted. A finished run's compactions show as thread dividers.
- Fix: the Codex harness stamps before/after sizes on the compaction
event. Codex reports the compacted size before the compaction item ends,
so the old neighbour-based guess showed the wrong size.
## Verification
- `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, `node
scripts/storybook-catalog.mjs`: pass.
- New tests: `server/src/__tests__/context-compaction.test.ts`,
`status-arbiter.test.ts`, `issue-context-usage.test.ts` (duplicate
compaction counts once), `ui/.../context-usage-row.test.tsx`,
`native-session-runtime.test.ts` (context compaction), Codex driver and
runnerd transport tests, Rust
`codex_compaction_turn_binds_the_turn_reported_after_its_empty_response`.
Rust `codex_provider` suite: 115 passed.
- `pnpm test:run` locally: one failure in
`chat-channels.integration.test.ts`. It reads
`.claude/skills/slack/SKILL.md` from the main checkout, which is not
present in this worktree. It is not related to this change.
- Live, local worktree instance, Codex agent on the Paperclip runner:
clicked Compact now. The run succeeded. Context went from 35k to 9.4k
tokens, and later 9.4k to 9.2k. The task stayed In Review. The thread
shows "Context compacted · 9.4k → 9.2k tokens" and the meter dropped.
- Live Claude proof: pending. The local Claude agent needs an Anthropic
API key. claude-agent-acp handles `/compact` as an echo-less turn, and
the runner already maps its "Compacting completed." notice to
`context.compacted`.
## Risks
- A compaction run resumes the agent's session. If the session cannot
resume, the run fails with `native_context_compaction_unavailable` and
the task is unchanged.
- Compact now is not available while the agent runs on the task, because
compaction interrupts the active turn.
- The conversation view now returns context readings for runs that
compacted. Runs that never compacted return the same events as before.
- An old runner binary ignores `compact` and rejects the `turn.start`
because it has no text. It does not start an ordinary turn.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use and code execution.
## 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>
231 lines
9.3 KiB
TypeScript
231 lines
9.3 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { FoldVertical, Loader2 } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
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>
|
|
);
|
|
}
|
|
|
|
export type ContextCompactState =
|
|
/** The agent's session can be compacted now. */
|
|
| { status: "available"; onCompact: () => void }
|
|
/** A compaction was requested and has not finished yet. */
|
|
| { status: "pending" }
|
|
/** Shown disabled with the reason, e.g. the agent is mid-run. */
|
|
| { status: "unavailable"; reason: string };
|
|
|
|
function CompactAction({ compact, level }: { compact: ContextCompactState; level: ContextUsageLevel }) {
|
|
return (
|
|
<div className="mt-3 flex flex-col gap-1.5 border-t border-border pt-3">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={level === "normal" ? "outline" : "default"}
|
|
className="w-full"
|
|
disabled={compact.status !== "available"}
|
|
onClick={compact.status === "available" ? compact.onCompact : undefined}
|
|
data-testid="context-usage-compact"
|
|
>
|
|
{compact.status === "pending"
|
|
? <Loader2 className="size-3.5 animate-spin" aria-hidden />
|
|
: <FoldVertical className="size-3.5" aria-hidden />}
|
|
{compact.status === "pending" ? "Compacting…" : "Compact now"}
|
|
</Button>
|
|
<p className="text-muted-foreground" role={compact.status === "unavailable" ? "note" : undefined}>
|
|
{compact.status === "unavailable"
|
|
? compact.reason
|
|
: compact.status === "pending"
|
|
? "The agent is summarizing its conversation. This usually takes under a minute."
|
|
: "Summarizes the conversation so far so the agent has room to keep working."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The hover card body; exported for Storybook galleries. */
|
|
export function ContextUsageCard({ usage, compact }: { usage: ContextUsage; compact?: ContextCompactState | null }) {
|
|
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}
|
|
{compact ? <CompactAction compact={compact} level={level} /> : 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 and, when the
|
|
* agent's harness supports it, Compact now.
|
|
*/
|
|
export function ContextUsageMeter({
|
|
usage,
|
|
compact,
|
|
defaultOpen = false,
|
|
}: {
|
|
usage: ContextUsage;
|
|
compact?: ContextCompactState | null;
|
|
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} compact={compact} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|