mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +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>
890 lines
35 KiB
TypeScript
890 lines
35 KiB
TypeScript
import {
|
|
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
|
|
CODEX_INVALID_REQUEST,
|
|
CODEX_METHOD_NOT_FOUND,
|
|
CODEX_RESULT_OUTPUT_SCHEMA,
|
|
CodexAppServerDriver,
|
|
CodexRpcError,
|
|
FakeCodexTransport,
|
|
HarnessCapabilityUnavailableError,
|
|
HarnessOperationAlreadyTerminalError,
|
|
HarnessReconciliationError,
|
|
HarnessStaleTurnError,
|
|
WORKSPACE,
|
|
applyPrpEvent,
|
|
collectUntilTerminal,
|
|
createCodexTaskEnvelope,
|
|
createIsolatedCodexAppServerArgs,
|
|
createSessionSnapshotFromMetadata,
|
|
describe,
|
|
envelope,
|
|
expect,
|
|
isSkilllessCodexContext,
|
|
it,
|
|
liveConsoleFixturePath,
|
|
loadLiveConsoleConformanceFixture,
|
|
makeDriver,
|
|
replayPersistedCodexEvents,
|
|
result,
|
|
reverseObjectKeys,
|
|
runCodexCodexTracer,
|
|
traceCompletedProposal,
|
|
validateCodexResultProposal,
|
|
validatePrpEvent,
|
|
vi,
|
|
type CodexAppServerTransport,
|
|
type CodexRpcNotification,
|
|
type CodexRpcServerRequest,
|
|
type CodexServerRequestHandler,
|
|
type CodexTraceInterpretation,
|
|
type HarnessRuntimeRequestResolution,
|
|
type PrpCapabilities,
|
|
type PrpEvent,
|
|
type PrpStructuredRunResult,
|
|
} from "./codex-app-server-driver.test-support.js";
|
|
import { rehydrateRunnerdItemNotification } from "../../live/runnerd-codex-transport.js";
|
|
|
|
describe("Codex app-server Codex driver", () => {
|
|
it.each(["default", "disabled"] as const)("rejects unsupported reasoning %s without sending a turn RPC", async reasoningMode => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-reasoning", normalizedSessionId: "normalized-reasoning", workingDirectory: WORKSPACE,
|
|
});
|
|
const message = { role: "user" as const, text: "Hi" };
|
|
await expect(session.startTurn({ message, reasoningMode })).rejects.toThrow("Per-turn reasoning is not supported");
|
|
expect(transport.calls.filter(call => call.method === "turn/start")).toHaveLength(0);
|
|
await session.startTurn({ message });
|
|
expect(transport.calls.filter(call => call.method === "turn/start")).toHaveLength(1);
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("starts a compaction turn through thread/compact/start without submitting a message", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-compact", normalizedSessionId: "normalized-compact", workingDirectory: WORKSPACE,
|
|
});
|
|
const started = await session.startTurn({ message: { role: "user", text: "" }, continuation: true, compact: true });
|
|
expect(started.turnId).toBe("turn-compact");
|
|
expect(transport.calls.filter(call => call.method === "turn/start")).toHaveLength(0);
|
|
expect(transport.calls.find(call => call.method === "thread/compact/start")?.params).toEqual({ threadId: "thread-1" });
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("retains one correlation-bound steering acknowledgement after the rehydrated runnerd echo", async () => {
|
|
const transport = new FakeCodexTransport("thread-root");
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-steering-echo", normalizedSessionId: "normalized-steering-echo", workingDirectory: WORKSPACE,
|
|
});
|
|
const { turnId } = await session.startTurn({ message: { role: "user", text: "Start." } });
|
|
await session.steer?.({ turnId, message: { role: "user", text: "Stay concise." }, correlationId: "queued-comment-1" });
|
|
transport.push("item/completed", rehydrateRunnerdItemNotification({
|
|
provider: "acpx", providerTurnId: turnId, itemId: `acpx-control-${"a".repeat(64)}`,
|
|
kind: "steering_acknowledgement", mode: "steer", status: "acknowledged",
|
|
text: "Steering acknowledged for the active turn.",
|
|
}, "thread-root", turnId));
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
await session.close({ reason: "fixture complete" });
|
|
const events: PrpEvent[] = [];
|
|
for await (const event of session.events()) events.push(event);
|
|
expect(events.filter(event => event.eventType === "item.completed" && event.payload.kind === "steering_acknowledgement"))
|
|
.toEqual([expect.objectContaining({ turnId, itemId: `${turnId}:steer:queued-comment-1`,
|
|
payload: expect.objectContaining({ kind: "steering_acknowledgement", status: "acknowledged" }) })]);
|
|
});
|
|
|
|
it("captures an exact skillless model/environment snapshot with credentials absent", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-context",
|
|
normalizedSessionId: "normalized-context",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const iterator = session.events()[Symbol.asyncIterator]();
|
|
const first = await iterator.next();
|
|
const context = first.value?.payload.context as Parameters<
|
|
typeof isSkilllessCodexContext
|
|
>[0];
|
|
expect(context).toMatchObject({
|
|
codexVersion: "codex-cli/0.132.0",
|
|
model: "gpt-test",
|
|
modelProvider: "openai",
|
|
workingDirectory: WORKSPACE,
|
|
approvalPolicy: "never",
|
|
instructionSources: [],
|
|
instructionPolicy: {
|
|
skillInstructions: false,
|
|
appInstructions: false,
|
|
collaborationInstructions: true,
|
|
},
|
|
environmentKeys: ["LANG", "PATH"],
|
|
envelope,
|
|
});
|
|
expect(isSkilllessCodexContext(context)).toBe(true);
|
|
expect(JSON.stringify(context)).not.toContain("must-not-pass");
|
|
expect(JSON.stringify(transport.calls)).not.toContain("RANDOM_SKILL_PATH");
|
|
expect(
|
|
transport.calls.find((call) => call.method === "thread/start")?.params,
|
|
).toMatchObject({
|
|
approvalPolicy: "never",
|
|
config: {
|
|
"skills.include_instructions": false,
|
|
include_apps_instructions: false,
|
|
include_collaboration_mode_instructions: true,
|
|
"features.image_generation": false,
|
|
},
|
|
permissions: "paperclip-runner-workspace-only",
|
|
runtimeWorkspaceRoots: [WORKSPACE],
|
|
dynamicTools: [{ name: "paperclip_finish" }, { name: "paperclip_block" }],
|
|
});
|
|
const commandPolicy = transport.calls.find(
|
|
(call) => call.method === "thread/start",
|
|
)?.params.config as Record<string, unknown>;
|
|
expect(JSON.stringify(commandPolicy)).not.toContain("/isolated/home");
|
|
expect(JSON.stringify(commandPolicy)).not.toContain("/isolated/codex");
|
|
expect(JSON.stringify(commandPolicy)).not.toContain("CODEX_HOME");
|
|
const appServerArgs = createIsolatedCodexAppServerArgs({
|
|
PATH: "/bin",
|
|
LANG: "C.UTF-8",
|
|
HOME: "/isolated/home",
|
|
CODEX_HOME: "/isolated/codex",
|
|
});
|
|
expect(appServerArgs).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(
|
|
'default_permissions="paperclip-runner-workspace-only"',
|
|
),
|
|
expect.stringContaining(
|
|
"permissions.paperclip-runner-workspace-only.filesystem=",
|
|
),
|
|
"permissions.paperclip-runner-workspace-only.network.enabled=false",
|
|
'shell_environment_policy.inherit="none"',
|
|
expect.stringContaining(
|
|
'shell_environment_policy.set={PATH="/bin",LANG="C.UTF-8"}',
|
|
),
|
|
"--disable",
|
|
"image_generation",
|
|
]),
|
|
);
|
|
expect(
|
|
appServerArgs.find((arg) => arg.startsWith("permissions.")),
|
|
).toContain('"/isolated/home"="none"');
|
|
expect(
|
|
appServerArgs.find((arg) => arg.startsWith("permissions.")),
|
|
).toContain('"/isolated/codex"="none"');
|
|
expect(JSON.stringify(appServerArgs)).not.toContain("HOME=");
|
|
expect(CODEX_RESULT_OUTPUT_SCHEMA.properties.schema).toEqual({
|
|
type: "string",
|
|
const: "paperclip.run_result.v1",
|
|
});
|
|
expect(
|
|
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA.properties.reportedWorkDisposition,
|
|
).toEqual({
|
|
type: "string",
|
|
const: "blocked",
|
|
});
|
|
});
|
|
|
|
it.each(["never", "on-request", "untrusted"] as const)(
|
|
"pins approval policy %s for both thread start and resume",
|
|
async (approvalPolicy) => {
|
|
const first = new FakeCodexTransport();
|
|
const second = new FakeCodexTransport();
|
|
const driver = makeDriver([first, second], { approvalPolicy });
|
|
const original = await driver.openSession({
|
|
runId: `run-policy-${approvalPolicy}`,
|
|
normalizedSessionId: `normalized-policy-${approvalPolicy}`,
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const started = await original.events()[Symbol.asyncIterator]().next();
|
|
const snapshot = await original.snapshot();
|
|
await original.close({ reason: "policy recovery test" });
|
|
const recovered = await driver.recoverSession?.(snapshot);
|
|
expect(recovered).toMatchObject({ recovered: true });
|
|
const resumed = await recovered!.session!.events()[Symbol.asyncIterator]().next();
|
|
expect(first.calls.find((call) => call.method === "thread/start")?.params)
|
|
.toMatchObject({ approvalPolicy });
|
|
expect(second.calls.find((call) => call.method === "thread/resume")?.params)
|
|
.toMatchObject({ approvalPolicy });
|
|
expect(started.value).toMatchObject({ eventType: "session.started", payload: { context: { approvalPolicy } } });
|
|
expect(resumed.value).toMatchObject({ eventType: "session.resumed", payload: { context: { approvalPolicy } } });
|
|
await recovered?.session?.close({ reason: "policy recovery complete" });
|
|
},
|
|
);
|
|
|
|
it("allows eval fixtures to opt out of Codex collaboration instructions", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport], {
|
|
includeCollaborationModeInstructions: false,
|
|
}).openSession({
|
|
runId: "run-no-collaboration-instructions",
|
|
normalizedSessionId: "session-no-collaboration-instructions",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const first = await session.events()[Symbol.asyncIterator]().next();
|
|
|
|
expect(first.value?.payload.context).toMatchObject({
|
|
instructionPolicy: { collaborationInstructions: false },
|
|
});
|
|
expect(
|
|
transport.calls.find((call) => call.method === "thread/start")?.params,
|
|
).toMatchObject({
|
|
config: { include_collaboration_mode_instructions: false },
|
|
});
|
|
});
|
|
|
|
it("negotiates genuine plan mode with collaboration instructions and read-only workspace access", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const driver = makeDriver([transport], {
|
|
requestedCollaborationMode: "plan",
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-plan",
|
|
normalizedSessionId: "session-plan",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
expect(
|
|
transport.calls.find((call) => call.method === "thread/start")?.params,
|
|
).toMatchObject({
|
|
permissions: "paperclip-runner-workspace-read-only",
|
|
config: { include_collaboration_mode_instructions: true },
|
|
});
|
|
expect(
|
|
transport.calls.find((call) => call.method === "thread/start")?.params,
|
|
).not.toHaveProperty("collaborationMode");
|
|
expect(
|
|
transport.calls.some((call) => call.method === "collaborationMode/list"),
|
|
).toBe(true);
|
|
await expect(
|
|
session.startTurn({
|
|
message: { role: "user", text: "Author a plan." },
|
|
requestedCollaborationMode: "plan",
|
|
}),
|
|
).resolves.toMatchObject({ effectiveCollaborationMode: "plan" });
|
|
expect(
|
|
transport.calls.find((call) => call.method === "turn/start")?.params,
|
|
).toMatchObject({
|
|
collaborationMode: {
|
|
mode: "plan",
|
|
settings: {
|
|
model: "gpt-test",
|
|
reasoning_effort: "high",
|
|
developer_instructions: null,
|
|
},
|
|
},
|
|
permissions: "paperclip-runner-workspace-read-only",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the installed app-server does not confirm plan mode", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
transport.confirmCollaborationMode = false;
|
|
await expect(
|
|
makeDriver([transport], {
|
|
requestedCollaborationMode: "plan",
|
|
}).openSession({
|
|
runId: "run-plan-unsupported",
|
|
normalizedSessionId: "session-plan-unsupported",
|
|
workingDirectory: WORKSPACE,
|
|
}),
|
|
).rejects.toThrow("planning_mode_unsupported");
|
|
});
|
|
|
|
it("refuses to turn host credential roots into model-writable workspaces", async () => {
|
|
await expect(
|
|
makeDriver([], {
|
|
environment: {
|
|
HOME: "/isolated/home",
|
|
CODEX_HOME: WORKSPACE,
|
|
},
|
|
}).openSession({
|
|
runId: "run-codex-home",
|
|
normalizedSessionId: "normalized-codex-home",
|
|
workingDirectory: WORKSPACE,
|
|
}),
|
|
).rejects.toThrow("cannot overlap host CODEX_HOME");
|
|
for (const hostHome of [WORKSPACE, `${WORKSPACE}/host-home`]) {
|
|
await expect(
|
|
makeDriver([], {
|
|
environment: {
|
|
HOME: hostHome,
|
|
CODEX_HOME: "/isolated/codex",
|
|
},
|
|
}).openSession({
|
|
runId: "run-host-home",
|
|
normalizedSessionId: "normalized-host-home",
|
|
workingDirectory: WORKSPACE,
|
|
}),
|
|
).rejects.toThrow("cannot contain the host HOME");
|
|
}
|
|
await expect(
|
|
makeDriver([]).openSession({
|
|
runId: "run-root",
|
|
normalizedSessionId: "normalized-root",
|
|
workingDirectory: "/",
|
|
}),
|
|
).rejects.toThrow("cannot be a filesystem root");
|
|
});
|
|
|
|
it("steers and interrupts an active turn without replacing the session", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-controls",
|
|
normalizedSessionId: "normalized-controls",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "Start." },
|
|
});
|
|
await session.steer?.({
|
|
turnId,
|
|
message: { role: "user", text: "Use a shorter answer." },
|
|
});
|
|
await session.interrupt?.({ turnId, reason: "operator requested" });
|
|
expect(transport.calls.map((call) => call.method)).toEqual([
|
|
"initialize",
|
|
"thread/start",
|
|
"thread/goal/get",
|
|
"turn/start",
|
|
"turn/steer",
|
|
"turn/interrupt",
|
|
]);
|
|
expect(session.ids()).toEqual({
|
|
driverSessionId: "thread-1",
|
|
providerSessionId: "provider-session-1",
|
|
displayId: "thread-1",
|
|
});
|
|
});
|
|
|
|
it("loads the deterministic Live console wire fixture", async () => {
|
|
const fixture = await loadLiveConsoleConformanceFixture(
|
|
liveConsoleFixturePath,
|
|
);
|
|
expect(
|
|
fixture.runtimeRequests.map(({ requestKind }) => requestKind),
|
|
).toEqual([
|
|
"command_approval",
|
|
"file_approval",
|
|
"permission_approval",
|
|
"user_input",
|
|
"elicitation",
|
|
]);
|
|
expect(fixture.goals.map(({ action }) => action)).toEqual([
|
|
"get",
|
|
"set",
|
|
"pause",
|
|
"resume",
|
|
"clear",
|
|
]);
|
|
});
|
|
|
|
it("holds browser-resolved upstream requests and returns the exact fixture responses", async () => {
|
|
const fixture = await loadLiveConsoleConformanceFixture(
|
|
liveConsoleFixturePath,
|
|
);
|
|
for (const scenario of fixture.runtimeRequests) {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: `run-${scenario.id}`,
|
|
normalizedSessionId: `normalized-${scenario.id}`,
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "Exercise request." },
|
|
});
|
|
const response = transport.invoke({
|
|
id: scenario.id,
|
|
method: scenario.method,
|
|
params: {
|
|
threadId: "thread-1",
|
|
turnId,
|
|
itemId: `item-${scenario.id}`,
|
|
reason: `fixture ${scenario.id}`,
|
|
},
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(session.pendingRuntimeRequests?.()).toEqual([
|
|
expect.objectContaining({
|
|
requestId: scenario.id,
|
|
requestKind: scenario.requestKind,
|
|
method: scenario.method,
|
|
turnId,
|
|
}),
|
|
]);
|
|
await expect(
|
|
session.resolveRuntimeRequest?.({
|
|
requestId: scenario.id,
|
|
turnId,
|
|
resolution: {
|
|
action: "forged",
|
|
} as unknown as HarnessRuntimeRequestResolution,
|
|
}),
|
|
).rejects.toThrow("unsupported action");
|
|
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
|
|
await session.resolveRuntimeRequest?.({
|
|
requestId: scenario.id,
|
|
turnId,
|
|
resolution: scenario.resolution as HarnessRuntimeRequestResolution,
|
|
});
|
|
expect(await response).toEqual(scenario.expectedResponse);
|
|
expect(session.pendingRuntimeRequests?.()).toEqual([]);
|
|
await expect(
|
|
session.resolveRuntimeRequest?.({
|
|
requestId: scenario.id,
|
|
turnId,
|
|
resolution: { action: "cancel" },
|
|
}),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
await session.close({ reason: "fixture complete" });
|
|
}
|
|
});
|
|
|
|
it("acknowledges same-turn steering and rejects stale or child steering", async () => {
|
|
const fixture = await loadLiveConsoleConformanceFixture(
|
|
liveConsoleFixturePath,
|
|
);
|
|
const transport = new FakeCodexTransport("thread-root");
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-live-console-controls",
|
|
normalizedSessionId: "normalized-live-console-controls",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "Start." },
|
|
});
|
|
await session.steer?.({
|
|
turnId,
|
|
message: { role: "user", text: "Stay concise." },
|
|
correlationId: "queued-comment-1",
|
|
});
|
|
transport.push("item/completed", {
|
|
kind: "steering_acknowledgement",
|
|
status: "acknowledged",
|
|
commandId: "runnerd-steer-command",
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await session.steer?.({
|
|
turnId,
|
|
message: { role: "user", text: "Stay concise." },
|
|
correlationId: "queued-comment-1",
|
|
});
|
|
expect(
|
|
transport.calls.find((call) => call.method === "turn/steer")?.params,
|
|
).toMatchObject({ correlationId: "queued-comment-1" });
|
|
expect(
|
|
transport.calls.filter((call) => call.method === "turn/steer"),
|
|
).toHaveLength(1);
|
|
transport.rejectMethods.set(
|
|
"turn/steer",
|
|
new Error("provider rejected steering"),
|
|
);
|
|
const rejectedSteering = await session
|
|
.steer?.({
|
|
turnId,
|
|
message: { role: "user", text: "Try this again later." },
|
|
correlationId: "queued-comment-2",
|
|
})
|
|
.then(
|
|
() => null,
|
|
(error: unknown) => error,
|
|
);
|
|
expect(rejectedSteering).toBeInstanceOf(Error);
|
|
expect(rejectedSteering).not.toBeInstanceOf(
|
|
HarnessCapabilityUnavailableError,
|
|
);
|
|
expect(String(rejectedSteering)).toContain("provider rejected steering");
|
|
transport.rejectMethods.delete("turn/steer");
|
|
await expect(
|
|
session.steer?.({
|
|
turnId: fixture.controls.staleTurnSteer.turnId!,
|
|
message: { role: "user", text: "Stale." },
|
|
}),
|
|
).rejects.toBeInstanceOf(HarnessStaleTurnError);
|
|
transport.push("thread/started", { thread: fixture.lineage.childThread });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(session.lineage?.()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
threadId: "thread-root",
|
|
parentThreadId: null,
|
|
depth: 0,
|
|
}),
|
|
expect.objectContaining({
|
|
threadId: "thread-child",
|
|
parentThreadId: "thread-root",
|
|
depth: 1,
|
|
nickname: "Scout",
|
|
role: "researcher",
|
|
}),
|
|
]),
|
|
);
|
|
await expect(
|
|
session.steer?.({
|
|
turnId: "thread-child",
|
|
message: { role: "user", text: "Do not emulate child steering." },
|
|
}),
|
|
).rejects.toBeInstanceOf(HarnessStaleTurnError);
|
|
const events = session.events()[Symbol.asyncIterator]();
|
|
const observed: PrpEvent[] = [];
|
|
// PRP v2 adds a capability and authoritative goal snapshot immediately
|
|
// after session start, so include those two events in this bounded read.
|
|
for (let index = 0; index < 11; index += 1) {
|
|
const next = await events.next();
|
|
if (next.done) break;
|
|
observed.push(next.value);
|
|
}
|
|
expect(observed).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
eventType: "item.completed",
|
|
itemId: `${turnId}:steer:queued-comment-1`,
|
|
payload: expect.objectContaining({
|
|
kind: "steering_acknowledgement",
|
|
status: "acknowledged",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
eventType: "harness.diagnostic",
|
|
payload: expect.objectContaining({ code: "stale_turn_rejected" }),
|
|
}),
|
|
expect.objectContaining({
|
|
eventType: "item.started",
|
|
payload: expect.objectContaining({ kind: "thread_lineage" }),
|
|
}),
|
|
]),
|
|
);
|
|
expect(observed.some((event) => event.eventType === "session.failed"))
|
|
.toBe(false);
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("queues an interrupt before turn identity and reports a terminal race precisely", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
let releaseStart!: (value: Record<string, unknown>) => void;
|
|
transport.turnStartResponse = new Promise((resolve) => {
|
|
releaseStart = resolve;
|
|
});
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-interrupt-races",
|
|
normalizedSessionId: "normalized-interrupt-races",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const starting = session.startTurn({
|
|
message: { role: "user", text: "Start slowly." },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await session.interrupt?.({ reason: "operator" });
|
|
releaseStart({ turn: { id: "turn-1", status: "inProgress", items: [] } });
|
|
await expect(starting).resolves.toEqual({
|
|
turnId: "turn-1",
|
|
effectiveCollaborationMode: "default",
|
|
});
|
|
expect(transport.calls.map(({ method }) => method)).toEqual(
|
|
expect.arrayContaining(["turn/start", "turn/interrupt"]),
|
|
);
|
|
transport.push("turn/completed", {
|
|
threadId: "thread-1",
|
|
turn: { id: "turn-1", status: "interrupted", items: [] },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await expect(
|
|
session.interrupt?.({ turnId: "turn-1" }),
|
|
).rejects.toBeInstanceOf(HarnessOperationAlreadyTerminalError);
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("maps all goal operations and advertises an exact unsupported state", async () => {
|
|
const fixture = await loadLiveConsoleConformanceFixture(
|
|
liveConsoleFixturePath,
|
|
);
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-goals",
|
|
normalizedSessionId: "normalized-goals",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
await session.goal?.({ action: "get" });
|
|
await session.goal?.({
|
|
action: "set",
|
|
objective: "Ship the Live console tracer",
|
|
tokenBudget: 4096,
|
|
});
|
|
await session.goal?.({ action: "pause" });
|
|
await session.goal?.({ action: "resume" });
|
|
await session.goal?.({ action: "clear" });
|
|
const goalCalls = transport.calls.filter(({ method }) =>
|
|
method.startsWith("thread/goal/"),
|
|
);
|
|
expect(
|
|
goalCalls.slice(1).map(({ method, params }) => ({
|
|
method,
|
|
params: Object.fromEntries(
|
|
Object.entries(params).filter(([key]) => key !== "threadId"),
|
|
),
|
|
})),
|
|
).toEqual(fixture.goals.map(({ method, params }) => ({ method, params })));
|
|
|
|
expect(
|
|
(await makeDriver([new FakeCodexTransport()]).descriptor()).capabilities,
|
|
).toMatchObject({ goals: true });
|
|
|
|
// Both denials a real app-server sends: the method is absent, and the
|
|
// build has the feature switched off.
|
|
for (const { denial, availability } of [
|
|
{ denial: new CodexRpcError(
|
|
'{"code":-32601,"message":"method not found"}', CODEX_METHOD_NOT_FOUND,
|
|
), availability: "unsupported" },
|
|
{ denial: new CodexRpcError(
|
|
'{"code":-32600,"message":"goals feature is disabled"}', CODEX_INVALID_REQUEST,
|
|
), availability: "policy_disabled" },
|
|
{ denial: new CodexRpcError(
|
|
'{"code":-32004,"message":"goals feature is disabled by policy"}', -32_004,
|
|
), availability: "policy_disabled" },
|
|
]) {
|
|
const unsupportedTransport = new FakeCodexTransport();
|
|
unsupportedTransport.rejectMethods.set("thread/goal/get", denial);
|
|
const unsupportedDriver = makeDriver([unsupportedTransport]);
|
|
const unsupported = await unsupportedDriver.openSession({
|
|
runId: "run-goals-unsupported",
|
|
normalizedSessionId: "normalized-goals-unsupported",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
expect((await unsupportedDriver.descriptor()).capabilities).toMatchObject(
|
|
{ goals: false },
|
|
);
|
|
const unsupportedEvents = unsupported.events()[Symbol.asyncIterator]();
|
|
await unsupportedEvents.next();
|
|
await expect(unsupportedEvents.next()).resolves.toMatchObject({
|
|
value: { eventType: "session.capabilities.updated", payload: { sessionGoals: { availability } } },
|
|
});
|
|
await expect(
|
|
unsupported.goal?.({ action: "get" }),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
for (const action of ["set", "pause", "resume", "clear"] as const) {
|
|
await expect(
|
|
unsupported.goal?.({ action, ...(action === "set" ? { objective: "Must not reach the provider" } : {}) }),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
}
|
|
expect(unsupportedTransport.calls.filter(({ method }) =>
|
|
method === "thread/goal/set" || method === "thread/goal/clear",
|
|
)).toEqual([]);
|
|
await unsupported.close({ reason: "fixture complete" });
|
|
}
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("keeps goal support advertised when the probe fails transiently", async () => {
|
|
// A transport or protocol failure is evidence about this call, not about
|
|
// what the provider implements, so it must not retire the capability.
|
|
for (const failure of [
|
|
new Error("codex app-server transport closed"),
|
|
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
|
|
]) {
|
|
const transport = new FakeCodexTransport();
|
|
transport.rejectMethods.set("thread/goal/get", failure);
|
|
const driver = makeDriver([transport]);
|
|
const session = await driver.openSession({
|
|
runId: "run-goals-transient",
|
|
normalizedSessionId: "normalized-goals-transient",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
expect((await driver.descriptor()).capabilities).toMatchObject({
|
|
goals: true,
|
|
});
|
|
// The capability survives, so the operation is still offered and the
|
|
// next call reaches the provider instead of failing closed locally.
|
|
transport.rejectMethods.delete("thread/goal/get");
|
|
await expect(session.goal?.({ action: "get" })).resolves.toBeNull();
|
|
expect(
|
|
transport.calls.filter(({ method }) => method === "thread/goal/get"),
|
|
).toHaveLength(2);
|
|
await session.close({ reason: "fixture complete" });
|
|
}
|
|
});
|
|
|
|
it.each([false, true])("requires trusted continuation metadata before omitting task context (%s)", async (continuation) => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport], {
|
|
skillInputs: [{ type: "skill", name: "first-task", path: "/skills/first-task/SKILL.md" }],
|
|
}).openSession({ runId: "run-delta", normalizedSessionId: "session-delta", workingDirectory: WORKSPACE });
|
|
const text = JSON.stringify({ schema: "paperclip.native-continuation.v1", events: '{"messages":[{"body":"Go ahead"}]}', completion: { revision: "2", criterionIds: ["comment"] } });
|
|
await session.startTurn({ message: { role: "user", text }, ...(continuation ? { continuation: true as const } : {}) });
|
|
const params = transport.calls.find((call) => call.method === "turn/start")!.params;
|
|
if (continuation) {
|
|
expect(params.input).toEqual([{ type: "text", text, text_elements: [] }]);
|
|
expect(JSON.stringify(params.input)).not.toContain("constraints");
|
|
expect(JSON.stringify(params.input)).not.toContain("first-task");
|
|
} else {
|
|
expect(JSON.stringify(params.input)).toContain("constraints");
|
|
expect(params.input).toContainEqual({ type: "skill", name: "first-task", path: "/skills/first-task/SKILL.md" });
|
|
}
|
|
await session.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("sends the selected reasoning effort with the Codex turn", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport], { model: "gpt-6-astra", reasoningEffort: "ultra" }).openSession({
|
|
runId: "run-effort",
|
|
normalizedSessionId: "session-effort",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
await session.startTurn({ message: { role: "user", text: "Continue" } });
|
|
expect(transport.calls.find((call) => call.method === "turn/start")?.params).toMatchObject({ effort: "ultra" });
|
|
await session.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("uses negotiated ACP controls through the Codex transport facade", async () => {
|
|
let controls = { steering: false, queuedFollowUp: false };
|
|
const transport = Object.assign(new FakeCodexTransport(), { turnControlCapabilities: () => ({ ...controls }) });
|
|
const session = await makeDriver([transport], {
|
|
driverIdentity: { kind: "acpx_runtime", displayName: "Pi ACP", version: "test" },
|
|
capabilities: { steering: false },
|
|
}).openSession({ runId: "run-pi", normalizedSessionId: "session-pi", workingDirectory: WORKSPACE });
|
|
try {
|
|
expect(session.turnControlCapabilities?.()).toEqual(controls);
|
|
const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } });
|
|
await expect(session.steer?.({ turnId, message: { role: "user", text: "Change" } })).rejects.toThrow("not negotiated");
|
|
controls = { steering: true, queuedFollowUp: true };
|
|
expect(session.turnControlCapabilities?.()).toEqual(controls);
|
|
const input = { turnId, correlationId: "queued-1", mode: "follow_up" as const, message: { role: "user" as const, text: "Then validate" } };
|
|
await session.steer?.(input);
|
|
expect(transport.calls.find(call => call.method === "turn/steer")?.params).toMatchObject({ expectedTurnId: turnId, mode: "follow_up", correlationId: "queued-1" });
|
|
await expect(session.steer?.(input)).rejects.toThrow("already acknowledged");
|
|
expect(transport.calls.filter(call => call.method === "turn/steer")).toHaveLength(1);
|
|
} finally { await session.close({ reason: "verified" }); }
|
|
});
|
|
|
|
it("keeps native follow-up disabled for the Codex app-server driver", async () => {
|
|
const transport = Object.assign(new FakeCodexTransport(), { turnControlCapabilities: () => ({ steering: true, queuedFollowUp: true }) });
|
|
const session = await makeDriver([transport]).openSession({ runId: "run-codex", normalizedSessionId: "session-codex", workingDirectory: WORKSPACE });
|
|
try {
|
|
expect(session.turnControlCapabilities?.()).toEqual({ steering: true, queuedFollowUp: false });
|
|
const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } });
|
|
await expect(session.steer?.({ turnId, mode: "follow_up", message: { role: "user", text: "Queue" } })).rejects.toThrow("not negotiated");
|
|
expect(transport.calls.filter(call => call.method === "turn/steer")).toHaveLength(0);
|
|
} finally { await session.close({ reason: "verified" }); }
|
|
});
|
|
|
|
it("preserves an inactive goal status while editing its objective", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const session = await makeDriver([transport]).openSession({
|
|
runId: "run-goal-inactive-edit",
|
|
normalizedSessionId: "normalized-goal-inactive-edit",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
await session.goal?.({
|
|
action: "set",
|
|
objective: "Updated while paused",
|
|
status: "paused",
|
|
});
|
|
|
|
expect(
|
|
transport.calls.filter(({ method }) => method === "thread/goal/set"),
|
|
).toContainEqual({
|
|
method: "thread/goal/set",
|
|
params: {
|
|
threadId: "thread-1",
|
|
objective: "Updated while paused",
|
|
status: "paused",
|
|
},
|
|
});
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("rolls back only a definitely rejected idle goal autostart", async () => {
|
|
const definiteTransport = new FakeCodexTransport();
|
|
const definiteSession = await makeDriver([definiteTransport]).openSession({
|
|
runId: "run-goal-autostart-definite-rejection",
|
|
normalizedSessionId: "normalized-goal-autostart-definite-rejection",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
definiteTransport.rejectMethods.set(
|
|
"thread/goal/set",
|
|
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
|
|
);
|
|
await expect(
|
|
definiteSession.goal?.({
|
|
action: "set",
|
|
objective: "Rejected before a turn starts",
|
|
}),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
definiteTransport.rejectMethods.delete("thread/goal/set");
|
|
await expect(
|
|
definiteSession.startTurn({
|
|
message: { role: "user", text: "A normal turn may still start." },
|
|
}),
|
|
).resolves.toMatchObject({ turnId: "turn-1" });
|
|
await definiteSession.close({ reason: "fixture complete" });
|
|
|
|
const ambiguousTransport = new FakeCodexTransport();
|
|
const ambiguousSession = await makeDriver([ambiguousTransport]).openSession({
|
|
runId: "run-goal-autostart-ambiguous",
|
|
normalizedSessionId: "normalized-goal-autostart-ambiguous",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
ambiguousTransport.rejectMethods.set(
|
|
"thread/goal/set",
|
|
new Error("codex app-server transport closed"),
|
|
);
|
|
await expect(
|
|
ambiguousSession.goal?.({
|
|
action: "set",
|
|
objective: "The provider may have started this goal",
|
|
}),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
ambiguousTransport.rejectMethods.delete("thread/goal/set");
|
|
await expect(
|
|
ambiguousSession.startTurn({
|
|
message: { role: "user", text: "Do not create competing work." },
|
|
}),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
await ambiguousSession.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
it("preserves a provider-neutral goal action subset through the Codex transport facade", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const driver = makeDriver([transport], {
|
|
goalCapability: {
|
|
actions: ["set", "clear"],
|
|
autonomousUpdates: true,
|
|
persistentAcrossResume: true,
|
|
maxObjectiveChars: 4_000,
|
|
tokenBudgetControl: false,
|
|
usageReporting: true,
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-goals-action-subset",
|
|
normalizedSessionId: "normalized-goals-action-subset",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const events = session.events()[Symbol.asyncIterator]();
|
|
await events.next();
|
|
await expect(events.next()).resolves.toMatchObject({
|
|
value: {
|
|
eventType: "session.capabilities.updated",
|
|
payload: {
|
|
sessionGoals: {
|
|
availability: "available",
|
|
actions: ["set", "clear"],
|
|
tokenBudgetControl: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
session.goal?.({ action: "pause" }),
|
|
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
|
|
expect(
|
|
transport.calls.filter(({ method }) => method === "thread/goal/set"),
|
|
).toHaveLength(0);
|
|
await expect(
|
|
session.goal?.({ action: "set", objective: "Finish the task" }),
|
|
).resolves.toMatchObject({ objective: "Finish the task" });
|
|
await expect(session.goal?.({ action: "clear" })).resolves.toBeNull();
|
|
await session.close({ reason: "fixture complete" });
|
|
});
|
|
|
|
});
|