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>
107 lines
6.0 KiB
TypeScript
107 lines
6.0 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import {
|
|
ContextCompactionError,
|
|
contextCompactionService,
|
|
contextCompactionSupport,
|
|
isContextCompactionRun,
|
|
readContextCompactionRequest,
|
|
} from "../services/context-compaction.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
|
|
describe("context compaction support", () => {
|
|
it("supports Codex and Claude on the Paperclip runner only", () => {
|
|
expect(contextCompactionSupport({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }).supported).toBe(true);
|
|
expect(contextCompactionSupport({ adapterType: "paperclip_runner", adapterConfig: {} }).supported).toBe(true);
|
|
expect(contextCompactionSupport({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }).supported).toBe(true);
|
|
expect(contextCompactionSupport({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi" } }).supported).toBe(false);
|
|
expect(contextCompactionSupport({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode" } }).supported).toBe(false);
|
|
expect(contextCompactionSupport({ adapterType: "codex_local", adapterConfig: {} }).supported).toBe(false);
|
|
});
|
|
|
|
it("recognizes a compaction run from its context", () => {
|
|
expect(readContextCompactionRequest({ contextCompactionRequestId: "req-1" })).toEqual({ requestId: "req-1" });
|
|
expect(isContextCompactionRun({ contextCompactionRequestId: " " })).toBe(false);
|
|
expect(isContextCompactionRun({ goalControlRequestId: "goal-1" })).toBe(false);
|
|
expect(isContextCompactionRun(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const suite = support.supported ? describe : describe.skip;
|
|
|
|
suite("context compaction availability", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let temporaryDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
beforeAll(async () => {
|
|
temporaryDb = await startEmbeddedPostgresTestDatabase("context-compaction-");
|
|
db = createDb(temporaryDb.connectionString);
|
|
}, 60_000);
|
|
afterAll(async () => { await temporaryDb?.cleanup(); });
|
|
|
|
async function fixture(adapterConfig: Record<string, unknown> = { provider: "codex" }, adapterType = "paperclip_runner") {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Compaction fixture", issuePrefix: `C${companyId.slice(0, 6)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Codie", role: "engineer", status: "idle", adapterType, adapterConfig });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Compact", status: "in_progress", assigneeAgentId: agentId });
|
|
return { companyId, agentId, issue: { id: issueId, assigneeAgentId: agentId as string | null, status: "in_progress" } };
|
|
}
|
|
|
|
it("is available for an idle supported agent and queues a compaction run", async () => {
|
|
const { companyId, agentId, issue } = await fixture();
|
|
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
|
|
const service = contextCompactionService(db, { wakeup });
|
|
expect(await service.availability(companyId, issue)).toEqual({ status: "available", reasonCode: null, reason: null });
|
|
const accepted = await service.request({ companyId, issue, userId: "board-user" });
|
|
expect(wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
|
reason: "context_compaction",
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "board-user",
|
|
allowRunCoalescing: false,
|
|
manualUserWake: true,
|
|
contextSnapshot: expect.objectContaining({
|
|
issueId: issue.id,
|
|
contextCompactionRequestId: accepted.requestId,
|
|
skipIssueComment: true,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("waits for an active run and reports a compaction already underway", async () => {
|
|
const { companyId, agentId, issue } = await fixture();
|
|
const service = contextCompactionService(db, { wakeup: vi.fn() });
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, scopeKind: "issue", issueId: issue.id, status: "running" });
|
|
expect(await service.availability(companyId, issue)).toMatchObject({ status: "unavailable", reasonCode: "agent_busy" });
|
|
await expect(service.request({ companyId, issue, userId: "board-user" })).rejects.toMatchObject({ code: "agent_busy" });
|
|
|
|
const { companyId: otherCompanyId, agentId: otherAgentId, issue: otherIssue } = await fixture();
|
|
const compactionRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: compactionRunId, companyId: otherCompanyId, agentId: otherAgentId, scopeKind: "issue", status: "queued",
|
|
contextSnapshot: { issueId: otherIssue.id, contextCompactionRequestId: "req-1" },
|
|
});
|
|
expect(await service.availability(otherCompanyId, otherIssue)).toEqual({
|
|
status: "pending", reasonCode: null, reason: null, runId: compactionRunId,
|
|
});
|
|
await expect(service.request({ companyId: otherCompanyId, issue: otherIssue, userId: "board-user" }))
|
|
.rejects.toBeInstanceOf(ContextCompactionError);
|
|
});
|
|
|
|
it("explains unsupported harnesses, closed tasks, and missing assignees", async () => {
|
|
const unsupported = await fixture({ provider: "opencode" });
|
|
const service = contextCompactionService(db);
|
|
expect(await service.availability(unsupported.companyId, unsupported.issue)).toMatchObject({
|
|
status: "unavailable", reasonCode: "unsupported",
|
|
});
|
|
const closed = await fixture();
|
|
expect(await service.availability(closed.companyId, { ...closed.issue, status: "done" })).toMatchObject({
|
|
status: "unavailable", reasonCode: "task_closed",
|
|
});
|
|
expect(await service.availability(closed.companyId, { ...closed.issue, assigneeAgentId: null })).toMatchObject({
|
|
status: "unavailable", reasonCode: "no_assignee",
|
|
});
|
|
});
|
|
});
|