mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner controls provider processes, permissions and task completion. > - Copilot needs its pinned distribution, native message identities and accepted semantic completion receipts. > - The release must preserve saved-token access and task lifecycle guarantees. > - This pull request will keep the current completion and recovery fixes while adding the Copilot runtime. > - The benefit is a reviewable part of the Copilot production release. ## Linked Issues or Issue Description Refs #14434. Supersedes the runtime work in #15560. This is the first PR in the stack; #15703, #15705 and #15706 follow. Related alternative adapter work: #15881. This change uses the shared Runner path. ## What Changed - Pin Copilot CLI 1.0.88 and bind the integrated implementation as profile45. - Keep native instruction, permission, receipt, process ownership and cancellation boundaries. - Preserve current master completion continuation and remote verification behavior. Other provider release identities stay unchanged. ## Verification Final reviewed source [`4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5`](https://github.com/paperclipai/paperclip/commit/4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5) includes master `cde944ed75648adfb569e86133adc8b118b55dd6`. The combined four-part PR tree matches it except for the CI-owned lockfile. Every prior public PR head remains an ancestor. Released master migrations remain unchanged; Copilot adds migration361. No unrelated provider is enabled. Copilot CLI1.0.88 and profile47 retain digest `sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea`. The pending qualification marker is removed. The [qualification record](https://github.com/paperclipai/paperclip/blob/4f3f1af807d2ed21adabb04c2f27f4df9c4bc8d5/doc/plans/2026-10-20-copilot-qualification-v47.json) reports **20/20 Product cells,154 assertions;7/7 protocol cases,46 assertions** against frozen installed application `c2616370800f5f6df45246b0890ba35a7cd2faab`. Both environments use the exact authenticated model `gpt-5.6-luna`. Every passing Product case confirms cleanup. Automatic behavior retries are disabled. Original failed attempts remain recorded. The ordinary installed remote journey passed saved-token selection, authenticated model discovery, normal UI save/reopen, metadata-only Test, task completion, accepted receipt and an exact19-byte browser download. An open model catalog caused no new discovery requests during117seconds. Actual installed runtime smokes passed on macOS ARM64, macOS x64 under Rosetta and native Linux x64 on Daytona. Raw token browser entry was not exercised; the encrypted connection was created through the normal API. The original Daytona file attempt timed out during delayed semantic dispatch. Its precise stalled layer remains unresolved. Independent ordinary delivery and the other19 cells passed before one instrumented recheck passed8/8 with unchanged source,prompt,assertions and deadlines. No causal latency repair is claimed. After current-master reconciliation,129 connection tests,59 runner tests,72 shared eval tests and88 recovery/workspace tests pass. Migration generation/safety, unchanged profile identity and UI token gates pass. Prior required CI was green. Fresh exact-head required CI and E2E are green for all four updated heads. Each has a fresh Greptile5/5 review and zero unresolved review threads. The [final normal-package cloud build](https://github.com/paperclipai/paperclip/actions/runs/39414239704) is running. Recursive typecheck,full CI test shards,build and all8 browser shards pass. These checks cover the final source; no additional local full-suite pass is claimed. The [frozen image build](https://github.com/paperclipai/paperclip/actions/runs/39334401638) and [original normal package build](https://github.com/paperclipai/paperclip/actions/runs/39334407471) passed. The exact image is published and independently retrieved anonymously. No separate full-image scan is claimed. All18 original normal archives were scanned with zero selected credentials. Docker builds run in the cloud. Provider USD remains unknown and GitHub-attributed. **Qualification is complete on the recorded frozen build. Normal protected merge, automatic master canary publication and final shipped-package local/Daytona canaries remain. No production deployment is claimed.** ## Risks Shared Runner behavior can affect active tasks. Exact profile admission rejects stale runtime identities. The final campaign and installed canaries remain release gates. ## Model Used OpenAI Codex, based on GPT-6. The exact serving model ID and context window are not exposed in this task. Used for code changes, review and tool-assisted verification. ## 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 - [ ] 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
835 lines
29 KiB
TypeScript
835 lines
29 KiB
TypeScript
import {
|
|
PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA,
|
|
PRP_BLOCK_TOOL_DESCRIPTION,
|
|
PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA,
|
|
PRP_COMPLETION_TOOL_DESCRIPTION,
|
|
} from "../../contracts/completion-result.js";
|
|
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";
|
|
|
|
class BlockingBootstrapTransport extends FakeCodexTransport {
|
|
readonly blocked: Promise<void>;
|
|
readonly closeStarted: Promise<void>;
|
|
closeCalls = 0;
|
|
#resolveBlocked!: () => void;
|
|
#resolveCloseStarted!: () => void;
|
|
#resolveClose!: () => void;
|
|
#rejectBlocked: ((reason: Error) => void) | null = null;
|
|
readonly #closeReleased: Promise<void>;
|
|
|
|
constructor(readonly blockedMethod: "thread/start" | "thread/read") {
|
|
super();
|
|
this.blocked = new Promise<void>((resolve) => {
|
|
this.#resolveBlocked = resolve;
|
|
});
|
|
this.closeStarted = new Promise<void>((resolve) => {
|
|
this.#resolveCloseStarted = resolve;
|
|
});
|
|
this.#closeReleased = new Promise<void>((resolve) => {
|
|
this.#resolveClose = resolve;
|
|
});
|
|
}
|
|
|
|
override request(
|
|
method: string,
|
|
params: Record<string, unknown>,
|
|
): Promise<Record<string, unknown>> {
|
|
if (method !== this.blockedMethod) return super.request(method, params);
|
|
this.calls.push({ method, params: structuredClone(params) });
|
|
this.#resolveBlocked();
|
|
return new Promise<Record<string, unknown>>((_resolve, reject) => {
|
|
this.#rejectBlocked = reject;
|
|
});
|
|
}
|
|
|
|
releaseClose(): void {
|
|
this.#resolveClose();
|
|
}
|
|
|
|
override async close(): Promise<void> {
|
|
this.closeCalls += 1;
|
|
this.#rejectBlocked?.(new Error("blocked bootstrap transport closed"));
|
|
this.#rejectBlocked = null;
|
|
this.#resolveCloseStarted();
|
|
await this.#closeReleased;
|
|
await super.close();
|
|
}
|
|
}
|
|
|
|
class WarmAttachTransport extends FakeCodexTransport {
|
|
readonly attachments: Array<{
|
|
runId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
}> = [];
|
|
|
|
async attachRun(input: {
|
|
runId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
}): Promise<void> {
|
|
this.attachments.push(structuredClone(input));
|
|
}
|
|
}
|
|
|
|
describe("Codex app-server Codex driver", () => {
|
|
it("accepts runner-proven warm attachment when the host active-turn reducer is stale", async () => {
|
|
const transport = new WarmAttachTransport();
|
|
const driver = makeDriver([transport]);
|
|
const session = await driver.openSession({
|
|
runId: "run-warm-first",
|
|
normalizedSessionId: "normalized-warm",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
await session.startTurn({
|
|
message: { role: "user", text: "first turn" },
|
|
});
|
|
await expect(
|
|
session.attachRun?.({ runId: "run-warm-second", currentRunGrant: { runtimeContext: null, instructions: "Current registered instructions." } }),
|
|
).resolves.toBeUndefined();
|
|
expect(transport.attachments).toHaveLength(1);
|
|
expect(transport.attachments[0]).toMatchObject({ currentRunGrant: { runtimeContext: null, instructions: "Current registered instructions." } });
|
|
expect((await session.snapshot()).activeTurnId).toBeNull();
|
|
|
|
await expect(
|
|
session.startTurn({
|
|
message: { role: "user", text: "second turn" },
|
|
}),
|
|
).resolves.toMatchObject({ turnId: "turn-1" });
|
|
await session.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("does not create a transport for a pre-aborted session open", async () => {
|
|
const transportFactory = vi.fn(() => new FakeCodexTransport());
|
|
const driver = makeDriver([], { transportFactory });
|
|
const controller = new AbortController();
|
|
const cancelled = new Error("session open cancelled before admission");
|
|
controller.abort(cancelled);
|
|
|
|
await expect(
|
|
driver.openSession({
|
|
runId: "run-pre-aborted",
|
|
normalizedSessionId: "normalized-pre-aborted",
|
|
workingDirectory: WORKSPACE,
|
|
signal: controller.signal,
|
|
}),
|
|
).rejects.toBe(cancelled);
|
|
expect(transportFactory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not create a transport for pre-aborted session recovery", async () => {
|
|
const initialDriver = makeDriver([new FakeCodexTransport()]);
|
|
const original = await initialDriver.openSession({
|
|
runId: "run-pre-aborted-recovery",
|
|
normalizedSessionId: "normalized-pre-aborted-recovery",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const snapshot = await original.snapshot();
|
|
await original.close({ reason: "prepare recovery checkpoint" });
|
|
const transportFactory = vi.fn(() => new FakeCodexTransport());
|
|
const recoveryDriver = makeDriver([], { transportFactory });
|
|
const controller = new AbortController();
|
|
const cancelled = new Error("session recovery cancelled before admission");
|
|
controller.abort(cancelled);
|
|
|
|
await expect(
|
|
recoveryDriver.recoverSession(snapshot, {
|
|
signal: controller.signal,
|
|
}),
|
|
).rejects.toBe(cancelled);
|
|
expect(transportFactory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes and awaits a blocked session-open transport on abort", async () => {
|
|
const transport = new BlockingBootstrapTransport("thread/start");
|
|
const driver = makeDriver([transport]);
|
|
const controller = new AbortController();
|
|
const cancelled = new Error("session open cancelled while blocked");
|
|
let settled = false;
|
|
|
|
const opening = driver
|
|
.openSession({
|
|
runId: "run-blocked-open",
|
|
normalizedSessionId: "normalized-blocked-open",
|
|
workingDirectory: WORKSPACE,
|
|
signal: controller.signal,
|
|
})
|
|
.then(
|
|
() => ({ error: null }),
|
|
(error: unknown) => ({ error }),
|
|
)
|
|
.finally(() => {
|
|
settled = true;
|
|
});
|
|
await transport.blocked;
|
|
controller.abort(cancelled);
|
|
await transport.closeStarted;
|
|
await Promise.resolve();
|
|
|
|
expect(settled).toBe(false);
|
|
expect(transport.closeCalls).toBe(1);
|
|
transport.releaseClose();
|
|
await expect(opening).resolves.toEqual({ error: cancelled });
|
|
expect(transport.calls.map((call) => call.method)).toEqual([
|
|
"initialize",
|
|
"thread/start",
|
|
]);
|
|
});
|
|
|
|
it("closes and awaits a blocked recovery transport on abort", async () => {
|
|
const initial = new FakeCodexTransport();
|
|
const recoveryTransport = new BlockingBootstrapTransport("thread/read");
|
|
const driver = makeDriver([initial, recoveryTransport]);
|
|
const original = await driver.openSession({
|
|
runId: "run-blocked-recovery",
|
|
normalizedSessionId: "normalized-blocked-recovery",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const snapshot = await original.snapshot();
|
|
await original.close({ reason: "simulate transport loss" });
|
|
const controller = new AbortController();
|
|
const cancelled = new Error("session recovery cancelled while blocked");
|
|
let settled = false;
|
|
|
|
const recovering = driver
|
|
.recoverSession(snapshot, {
|
|
signal: controller.signal,
|
|
})
|
|
.then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
)
|
|
.finally(() => {
|
|
settled = true;
|
|
});
|
|
await recoveryTransport.blocked;
|
|
controller.abort(cancelled);
|
|
await recoveryTransport.closeStarted;
|
|
await Promise.resolve();
|
|
|
|
expect(settled).toBe(false);
|
|
expect(recoveryTransport.closeCalls).toBe(1);
|
|
recoveryTransport.releaseClose();
|
|
await expect(recovering).resolves.toEqual({
|
|
value: null,
|
|
error: cancelled,
|
|
});
|
|
expect(recoveryTransport.calls.map((call) => call.method)).toEqual([
|
|
"initialize",
|
|
"thread/read",
|
|
]);
|
|
});
|
|
|
|
it("persists process ownership before sending provider requests", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
Object.assign(transport, {
|
|
processInfo: () => ({
|
|
pid: 71_001,
|
|
processGroupId: 71_001,
|
|
startedAt: "2026-08-18T18:00:00.000Z",
|
|
exited: false,
|
|
exitCode: null,
|
|
signal: null,
|
|
}),
|
|
});
|
|
let releaseOwnership!: () => void;
|
|
const ownershipPersisted = new Promise<void>((resolve) => {
|
|
releaseOwnership = resolve;
|
|
});
|
|
const onSpawn = vi.fn(() => ownershipPersisted);
|
|
const driver = makeDriver([transport], { onSpawn });
|
|
|
|
const opening = driver.openSession({
|
|
runId: "run-owned",
|
|
normalizedSessionId: "normalized-owned",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
await vi.waitFor(() => expect(onSpawn).toHaveBeenCalledOnce());
|
|
expect(transport.calls).toEqual([]);
|
|
|
|
releaseOwnership();
|
|
await opening;
|
|
expect(onSpawn).toHaveBeenCalledWith({
|
|
pid: 71_001,
|
|
processGroupId: 71_001,
|
|
startedAt: "2026-08-18T18:00:00.000Z",
|
|
});
|
|
expect(transport.calls[0]?.method).toBe("initialize");
|
|
});
|
|
|
|
it("persists process ownership after a lazy transport launches during session open", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
Object.assign(transport, {
|
|
processInfo: () => ({
|
|
pid: transport.calls.some((call) => call.method === "thread/start")
|
|
? 71_002
|
|
: null,
|
|
processGroupId: 71_002,
|
|
startedAt: "2026-08-18T18:01:00.000Z",
|
|
exited: false,
|
|
exitCode: null,
|
|
signal: null,
|
|
}),
|
|
});
|
|
const onSpawn = vi.fn(async () => undefined);
|
|
const driver = makeDriver([transport], { onSpawn });
|
|
|
|
await driver.openSession({
|
|
runId: "run-lazy-owned",
|
|
normalizedSessionId: "normalized-lazy-owned",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
expect(onSpawn).toHaveBeenCalledOnce();
|
|
expect(onSpawn).toHaveBeenCalledWith({
|
|
pid: 71_002,
|
|
processGroupId: 71_002,
|
|
startedAt: "2026-08-18T18:01:00.000Z",
|
|
});
|
|
expect(transport.calls.map((call) => call.method)).toContain(
|
|
"thread/start",
|
|
);
|
|
});
|
|
|
|
it("persists process ownership after a lazy transport launches during recovery", async () => {
|
|
const originalTransport = new FakeCodexTransport();
|
|
const originalDriver = makeDriver([originalTransport]);
|
|
const original = await originalDriver.openSession({
|
|
runId: "run-lazy-recovery-owned",
|
|
normalizedSessionId: "normalized-lazy-recovery-owned",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const snapshot = await original.snapshot();
|
|
snapshot.activeTurnId = "turn-recovery-race";
|
|
await original.close({ reason: "prepare lazy ownership recovery" });
|
|
|
|
const recoveryTransport = new FakeCodexTransport();
|
|
recoveryTransport.readResponse = {
|
|
thread: {
|
|
id: snapshot.driverSessionId,
|
|
sessionId: snapshot.providerSessionId,
|
|
cwd: WORKSPACE,
|
|
turns: [{ id: "turn-recovery-race", status: "inProgress", items: [] }],
|
|
},
|
|
};
|
|
Object.assign(recoveryTransport, {
|
|
processInfo: () => ({
|
|
pid: recoveryTransport.calls.some(
|
|
(call) => call.method === "thread/read",
|
|
)
|
|
? 71_003
|
|
: null,
|
|
processGroupId: 71_003,
|
|
startedAt: "2026-08-18T18:02:00.000Z",
|
|
exited: false,
|
|
exitCode: null,
|
|
signal: null,
|
|
}),
|
|
});
|
|
const onSpawn = vi.fn(async () => undefined);
|
|
const transportFactory = vi.fn(() => recoveryTransport);
|
|
const recoveryDriver = makeDriver([], { onSpawn, transportFactory });
|
|
|
|
const recovered = await recoveryDriver.recoverSession(snapshot);
|
|
|
|
expect(recovered.recovered).toBe(true);
|
|
expect(transportFactory).toHaveBeenCalledWith({
|
|
workingDirectory: snapshot.workingDirectory,
|
|
providerRecoveryPolicy: snapshot.providerRecoveryPolicy,
|
|
persistedSession: {
|
|
driverSessionId: snapshot.driverSessionId,
|
|
providerSessionId: snapshot.providerSessionId,
|
|
providerIdentity: snapshot.providerIdentity,
|
|
activeTurnId: snapshot.activeTurnId,
|
|
},
|
|
});
|
|
expect(onSpawn).toHaveBeenCalledOnce();
|
|
expect(onSpawn).toHaveBeenCalledWith({
|
|
pid: 71_003,
|
|
processGroupId: 71_003,
|
|
startedAt: "2026-08-18T18:02:00.000Z",
|
|
});
|
|
expect(recoveryTransport.calls.map((call) => call.method)).toContain(
|
|
"thread/read",
|
|
);
|
|
});
|
|
|
|
it("detaches restart authority without closing the provider transport", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const detachControllerForRestart = vi.fn(async () => undefined);
|
|
Object.assign(transport, { detachControllerForRestart });
|
|
const driver = makeDriver([transport]);
|
|
const session = await driver.openSession({
|
|
runId: "run-hot-detach",
|
|
normalizedSessionId: "normalized-hot-detach",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const close = vi.spyOn(transport, "close");
|
|
|
|
await session.detachControllerForRestart?.();
|
|
|
|
expect(detachControllerForRestart).toHaveBeenCalledOnce();
|
|
expect(close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends direct chat as plain text and permits a follow-up turn", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const driver = makeDriver([transport], { conversationMode: "direct" });
|
|
const session = await driver.openSession({
|
|
runId: "run-chat",
|
|
normalizedSessionId: "normalized-chat",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
const threadStart = transport.calls.find(
|
|
(call) => call.method === "thread/start",
|
|
);
|
|
expect(threadStart?.params).not.toHaveProperty("baseInstructions");
|
|
expect(threadStart?.params.dynamicTools).toEqual([]);
|
|
|
|
const first = await session.startTurn({
|
|
message: { role: "user", text: "Hello Codex" },
|
|
});
|
|
const firstStart = transport.calls.find(
|
|
(call) => call.method === "turn/start",
|
|
);
|
|
expect(firstStart?.params).not.toHaveProperty("outputSchema");
|
|
expect(firstStart?.params.input).toEqual([
|
|
{ type: "text", text: "Hello Codex", text_elements: [] },
|
|
]);
|
|
transport.push("turn/started", {
|
|
threadId: "thread-1",
|
|
turn: { id: first.turnId, status: "inProgress" },
|
|
});
|
|
transport.push("turn/completed", {
|
|
threadId: "thread-1",
|
|
turn: { id: first.turnId, status: "completed", items: [] },
|
|
});
|
|
await collectUntilTerminal(session.events());
|
|
|
|
transport.turnStartResponse = Promise.resolve({
|
|
turn: { id: "turn-2", status: "inProgress", items: [] },
|
|
});
|
|
await expect(
|
|
session.startTurn({ message: { role: "user", text: "And a follow-up" } }),
|
|
).resolves.toEqual({
|
|
turnId: "turn-2",
|
|
effectiveCollaborationMode: "default",
|
|
});
|
|
});
|
|
|
|
it("forwards the persisted native model to the runner transport", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const driver = makeDriver([transport], {
|
|
model: "qualified-provider-model",
|
|
});
|
|
|
|
await driver.openSession({
|
|
runId: "run-qualified-model",
|
|
normalizedSessionId: "normalized-qualified-model",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
expect(
|
|
transport.calls.find((call) => call.method === "thread/start")?.params,
|
|
).toMatchObject({
|
|
model: "qualified-provider-model",
|
|
completionContract: {
|
|
revision: "codex-demo-v1",
|
|
criterionIds: ["file"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("adds Paperclip developer instructions without replacing the Codex base and enables selected skills", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const baseInstructions = [
|
|
"You are running as a Paperclip agent.",
|
|
"Follow the attached AGENTS.md instructions.",
|
|
"Read-only instruction sibling root: /paperclip/context/instructions",
|
|
].join("\n\n");
|
|
const driver = makeDriver([transport], {
|
|
baseInstructions,
|
|
includeSkillInstructions: true,
|
|
});
|
|
|
|
await driver.openSession({
|
|
runId: "run-runtime-context",
|
|
normalizedSessionId: "normalized-runtime-context",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
|
|
const threadStart = transport.calls.find(
|
|
(call) => call.method === "thread/start",
|
|
);
|
|
expect(threadStart?.params).toMatchObject({
|
|
developerInstructions: baseInstructions,
|
|
config: {
|
|
"skills.include_instructions": true,
|
|
include_apps_instructions: false,
|
|
},
|
|
});
|
|
expect(threadStart?.params).not.toHaveProperty("baseInstructions");
|
|
expect(JSON.stringify(threadStart?.params.input ?? null)).not.toContain(
|
|
baseInstructions,
|
|
);
|
|
});
|
|
|
|
it.each(["task", "prepared", "direct"] as const)("preserves stock Codex instructions on %s recovery", async (conversationMode) => {
|
|
const originalTransport = new FakeCodexTransport();
|
|
const recoveryTransport = new FakeCodexTransport();
|
|
const baseInstructions = "Paperclip coordination and assigned instruction paths.";
|
|
const driver = makeDriver([originalTransport, recoveryTransport], {
|
|
conversationMode,
|
|
baseInstructions,
|
|
includeSkillInstructions: true,
|
|
});
|
|
const original = await driver.openSession({
|
|
runId: "run-additive-recovery",
|
|
normalizedSessionId: "normalized-additive-recovery",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const snapshot = await original.snapshot();
|
|
recoveryTransport.readResponse = {
|
|
thread: { id: snapshot.driverSessionId, sessionId: snapshot.providerSessionId, cwd: WORKSPACE, turns: [] },
|
|
};
|
|
await original.close({ reason: "verify additive recovery" });
|
|
|
|
const recovered = await driver.recoverSession(snapshot);
|
|
expect(recovered.recovered).toBe(true);
|
|
const started = originalTransport.calls.find((call) => call.method === "thread/start")!.params;
|
|
const resumed = recoveryTransport.calls.find((call) => call.method === "thread/resume")!.params;
|
|
expect(started).not.toHaveProperty("baseInstructions");
|
|
expect(resumed).not.toHaveProperty("baseInstructions");
|
|
expect(started.developerInstructions).toBe(conversationMode === "direct" ? undefined : baseInstructions);
|
|
expect(resumed.developerInstructions).toBe(conversationMode === "direct" ? "" : baseInstructions);
|
|
expect(resumed.dynamicTools).toEqual(started.dynamicTools);
|
|
expect(started.dynamicTools).toEqual(conversationMode === "direct" ? [] : [
|
|
{ name: "paperclip_finish", description: PRP_COMPLETION_TOOL_DESCRIPTION, inputSchema: PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA },
|
|
{ name: "paperclip_block", description: PRP_BLOCK_TOOL_DESCRIPTION, inputSchema: PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA },
|
|
]);
|
|
expect(resumed.config).toEqual(started.config);
|
|
await recovered.session?.close({ reason: "verified additive recovery" });
|
|
});
|
|
|
|
it("retains the instruction field for non-Codex provider facades", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const driver = makeDriver([transport], {
|
|
baseInstructions: "Provider-specific runtime context.",
|
|
driverIdentity: { kind: "opencode_server", displayName: "OpenCode", version: "1" },
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-facade-instructions",
|
|
normalizedSessionId: "normalized-facade-instructions",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const started = transport.calls.find((call) => call.method === "thread/start")!.params;
|
|
expect(started.baseInstructions).toBe("Provider-specific runtime context.");
|
|
expect(started).not.toHaveProperty("developerInstructions");
|
|
await session.close({ reason: "verified facade instructions" });
|
|
});
|
|
|
|
it("passes the common typed-event contract and reports one provider turn terminal", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const driver = makeDriver([transport]);
|
|
const descriptor = await driver.descriptor();
|
|
const session = await driver.openSession({
|
|
runId: "run-1",
|
|
normalizedSessionId: "normalized-1",
|
|
workingDirectory: WORKSPACE,
|
|
});
|
|
const turn = await session.startTurn({
|
|
message: { role: "user", text: "Do the safe task." },
|
|
});
|
|
|
|
transport.push("turn/started", {
|
|
threadId: "thread-1",
|
|
turn: { id: turn.turnId, status: "inProgress" },
|
|
});
|
|
transport.push("turn/plan/updated", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
revision: 1,
|
|
plan: [
|
|
{ step: "Inspect", status: "completed" },
|
|
{ step: "Implement", status: "inProgress" },
|
|
],
|
|
});
|
|
transport.push("turn/diff/updated", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
revision: 1,
|
|
diff: [
|
|
"diff --git a/hello.txt b/hello.txt",
|
|
"--- a/hello.txt",
|
|
"+++ b/hello.txt",
|
|
"@@ -1 +1,2 @@",
|
|
"-hello",
|
|
"+hello world",
|
|
"+again",
|
|
].join("\n"),
|
|
});
|
|
transport.push("turn/plan/updated", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
revision: 2,
|
|
plan: [
|
|
{ step: "Inspect", status: "completed" },
|
|
{ step: "Implement", status: "completed" },
|
|
],
|
|
});
|
|
transport.push("item/started", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
item: { id: "cmd-1", type: "commandExecution", command: "printf hello" },
|
|
});
|
|
transport.push("item/commandExecution/outputDelta", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
itemId: "cmd-1",
|
|
delta: "hello",
|
|
});
|
|
transport.push("item/completed", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
item: {
|
|
id: "file-1",
|
|
type: "fileChange",
|
|
changes: [{ path: "hello.txt" }],
|
|
},
|
|
});
|
|
transport.push("item/completed", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
item: {
|
|
id: "reference-1",
|
|
type: "agentMessage",
|
|
text: "Open [hello.txt](hello.txt).",
|
|
},
|
|
});
|
|
const requestResolution = transport.invoke({
|
|
id: "request-1",
|
|
method: "item/tool/requestUserInput",
|
|
params: {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
itemId: "question-1",
|
|
},
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
|
|
await session.resolveRuntimeRequest?.({
|
|
requestId: "request-1",
|
|
turnId: turn.turnId,
|
|
resolution: { action: "cancel" },
|
|
});
|
|
expect(await requestResolution).toEqual({ answers: {} });
|
|
transport.push("thread/tokenUsage/updated", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
tokenUsage: {
|
|
total: { inputTokens: 10, outputTokens: 4 },
|
|
modelContextWindow: 128000,
|
|
},
|
|
});
|
|
transport.push("item/completed", {
|
|
threadId: "thread-1",
|
|
turnId: turn.turnId,
|
|
item: {
|
|
id: "answer-1",
|
|
type: "agentMessage",
|
|
text: JSON.stringify(result),
|
|
},
|
|
});
|
|
transport.push("turn/completed", {
|
|
threadId: "thread-1",
|
|
turn: { id: turn.turnId, status: "completed", items: [] },
|
|
});
|
|
|
|
const events = await collectUntilTerminal(session.events());
|
|
expect(events.every((event) => validatePrpEvent(event).ok)).toBe(true);
|
|
expect(
|
|
events.filter((event) => event.eventType === "run.result.proposed"),
|
|
).toHaveLength(1);
|
|
expect(
|
|
events.filter((event) => event.eventType === "turn.completed"),
|
|
).toHaveLength(1);
|
|
expect(
|
|
events.filter((event) => event.eventType === "run.terminal"),
|
|
).toHaveLength(0);
|
|
const workspaceEvents = events.filter(
|
|
(event) => event.eventType === "workspace.change.updated",
|
|
);
|
|
expect(workspaceEvents[0]?.payload).toMatchObject({
|
|
schema: "paperclip.workspace.diff.v1",
|
|
changeSetId: `${turn.turnId}:workspace`,
|
|
complete: false,
|
|
totals: { files: 1, additions: 2, deletions: 1 },
|
|
});
|
|
expect(workspaceEvents.at(-1)?.payload).toMatchObject({
|
|
schema: "paperclip.workspace.diff.v1",
|
|
complete: true,
|
|
totals: { files: 1 },
|
|
});
|
|
const planEvents = events.filter(
|
|
(event) => event.eventType === "plan.updated",
|
|
);
|
|
expect(planEvents).toHaveLength(2);
|
|
expect(new Set(planEvents.map((event) => event.itemId))).toEqual(
|
|
new Set([turn.turnId]),
|
|
);
|
|
expect(planEvents.at(-1)?.payload).toMatchObject({
|
|
planId: turn.turnId,
|
|
revision: 2,
|
|
complete: true,
|
|
syncStatus: "not_applicable",
|
|
});
|
|
expect(
|
|
events.find((event) => event.eventType === "workspace.diff.recorded")
|
|
?.payload,
|
|
).toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true });
|
|
expect(
|
|
events.find((event) => event.eventType === "workspace.file.referenced")
|
|
?.payload,
|
|
).toMatchObject({
|
|
schema: "paperclip.workspace.file_reference.v1",
|
|
path: "hello.txt",
|
|
});
|
|
expect(events.map((event) => event.eventType)).toEqual(
|
|
expect.arrayContaining([
|
|
"session.started",
|
|
"turn.started",
|
|
"item.started",
|
|
"item.delta",
|
|
"item.completed",
|
|
"run.result.proposed",
|
|
"turn.completed",
|
|
"runtime_request.created",
|
|
"runtime_request.resolved",
|
|
"workspace.change.updated",
|
|
"workspace.diff.recorded",
|
|
]),
|
|
);
|
|
|
|
const capabilities: PrpCapabilities = {
|
|
schema: "paperclip.prp.capabilities.v1",
|
|
sessionReusePolicy: "reuse_per_issue",
|
|
driver: { kind: descriptor.kind, version: descriptor.version },
|
|
steer: true,
|
|
interrupt: true,
|
|
resume: true,
|
|
runtimeRequests: true,
|
|
structuredResult: true,
|
|
typedEvents: true,
|
|
};
|
|
const metadata = {
|
|
fixtureName: "codex-conformance",
|
|
identity: {
|
|
schema: "paperclip.prp.identity.v1" as const,
|
|
companyId: "company-1",
|
|
issueId: "issue-1",
|
|
runId: "run-1",
|
|
environmentLeaseId: "lease-1",
|
|
runnerInstanceId: "runner-codex",
|
|
normalizedSessionId: "normalized-1",
|
|
},
|
|
capabilities,
|
|
};
|
|
const live = events.reduce(
|
|
applyPrpEvent,
|
|
createSessionSnapshotFromMetadata(metadata),
|
|
);
|
|
const replay = events.reduce(
|
|
applyPrpEvent,
|
|
createSessionSnapshotFromMetadata(metadata),
|
|
);
|
|
expect(live).toEqual(replay);
|
|
expect(live.integrity).toBe("complete");
|
|
expect(await session.usage?.()).toMatchObject({
|
|
modelContextWindow: 128000,
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
it("sends explicit skill input on every requested turn", async () => {
|
|
const transport = new FakeCodexTransport();
|
|
const skillInputs = [{ type: "skill" as const, name: "first-task", path: "/runtime/assigned/first-task/SKILL.md" }];
|
|
const driver = makeDriver([transport], { conversationMode: "direct", skillInputs });
|
|
const session = await driver.openSession({ runId: "run-skills", normalizedSessionId: "session-skills", workingDirectory: WORKSPACE });
|
|
for (const text of ["Start onboarding", "Yes, I approve"]) {
|
|
const turn = await session.startTurn({ message: { role: "user", text } });
|
|
const call = transport.calls.filter((call) => call.method === "turn/start").at(-1);
|
|
expect(call?.params.input).toEqual([{ type: "text", text: `$first-task\n\n${text}`, text_elements: [] }, ...skillInputs]);
|
|
transport.push("turn/completed", { threadId: "thread-1", turn: { id: turn.turnId, status: "completed", items: [] } });
|
|
await collectUntilTerminal(session.events());
|
|
transport.turnStartResponse = Promise.resolve({ turn: { id: "turn-2", status: "inProgress", items: [] } });
|
|
}
|
|
await session.close();
|
|
});
|
|
|
|
|
|
it("reapplies explicit skills after recovery without persisting them into ordinary tasks", async () => {
|
|
const skillInputs = [{ type: "skill" as const, name: "first-task", path: "/runtime/onboarding/SKILL.md" }];
|
|
const first = new FakeCodexTransport();
|
|
const original = await makeDriver([first], { skillInputs }).openSession({
|
|
runId: "initial", normalizedSessionId: "skill-recovery", workingDirectory: WORKSPACE,
|
|
});
|
|
const snapshot = await original.snapshot();
|
|
await original.close();
|
|
for (const selected of [skillInputs, []]) {
|
|
const transport = new FakeCodexTransport();
|
|
const recovery = await makeDriver([transport], { skillInputs: selected }).recoverSession(snapshot);
|
|
expect(recovery.recovered).toBe(true);
|
|
const session = recovery.session!;
|
|
try {
|
|
await session.startTurn({ message: { role: "user", text: "Continue" } });
|
|
const items = transport.calls.find((call) => call.method === "turn/start")?.params.input as Array<{type:string}>;
|
|
expect(items.filter((item) => item.type === "skill")).toEqual(selected);
|
|
} finally { await session.close(); }
|
|
}
|
|
});
|