mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
278 lines
9.6 KiB
TypeScript
278 lines
9.6 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { ControlPlanePort } from "./contracts/control-plane-port.js";
|
|
import type { NativeExecutionInputV1 } from "./contracts/native-execution.js";
|
|
import type {
|
|
NativeSession,
|
|
NativeSessionBackend,
|
|
PersistedNativeSession,
|
|
} from "./contracts/native-session-backend.js";
|
|
import type {
|
|
PrpEvent,
|
|
PrpStructuredRunResult,
|
|
PrpTerminalState,
|
|
} from "./protocol/phase1-contract.js";
|
|
import { executeNativeSession } from "./native-session-runtime.js";
|
|
|
|
const identity = {
|
|
runId: "run-recovery",
|
|
sessionId: "session-recovery",
|
|
companyId: "company-recovery",
|
|
issueId: "issue-recovery",
|
|
agentId: "agent-recovery",
|
|
};
|
|
|
|
const result: PrpStructuredRunResult = {
|
|
schema: "paperclip.run_result.v1",
|
|
reportedWorkDisposition: "done",
|
|
summary: "Recovered native work completed.",
|
|
completionClaim: {
|
|
contractRevision: "1",
|
|
objectiveSatisfied: true,
|
|
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
|
|
remainingWork: [],
|
|
},
|
|
evidence: [],
|
|
verification: [{ commandOrCheck: "recovery", status: "passed" }],
|
|
attentionRequests: [],
|
|
artifacts: [],
|
|
};
|
|
|
|
const terminal: PrpTerminalState = {
|
|
schema: "paperclip.prp.terminal.v1",
|
|
turnTerminalState: "completed",
|
|
runTerminalState: "succeeded",
|
|
reportedWorkDisposition: "done",
|
|
};
|
|
|
|
const input: NativeExecutionInputV1 = {
|
|
schema: "paperclip.native-execution-input.v1",
|
|
binding: {
|
|
companyId: identity.companyId,
|
|
runId: identity.runId,
|
|
issueId: identity.issueId,
|
|
agentId: identity.agentId,
|
|
executionWorkspaceId: "workspace-recovery",
|
|
},
|
|
task: { identifier: "PAP-RECOVERY", title: "Recover native work", description: null, workMode: "standard" },
|
|
workspace: { cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
|
|
session: { normalizedSessionId: identity.sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
|
|
completionContract: {
|
|
id: "contract-recovery",
|
|
sha256: "contract-recovery-sha",
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
contract: {
|
|
revision: "1",
|
|
objective: "Recover native work",
|
|
criteria: [{ id: "objective", requirement: "Complete after recovery" }],
|
|
},
|
|
},
|
|
interactionResponses: [],
|
|
credentialBindings: [],
|
|
};
|
|
|
|
function controlEvent(
|
|
sourceSeq: number,
|
|
eventType: PrpEvent["eventType"],
|
|
payload: Record<string, unknown>,
|
|
): PrpEvent {
|
|
return {
|
|
schema: "paperclip.prp.event.v1",
|
|
sourceEventId: `control-recovery:${identity.runId}:${sourceSeq}`,
|
|
sourceSeq,
|
|
sourceInstanceId: "control-recovery",
|
|
sourceKind: "control_plane",
|
|
runId: identity.runId,
|
|
normalizedSessionId: identity.sessionId,
|
|
turnId: "turn-recovery",
|
|
eventType,
|
|
schemaVersion: 1,
|
|
priority: 0,
|
|
emittedAt: "2026-08-09T00:00:00.000Z",
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function highestContiguous(events: PrpEvent[]): number {
|
|
const sequences = new Set(events.map((event) => event.sourceSeq));
|
|
let cursor = 0;
|
|
while (sequences.has(cursor + 1)) cursor += 1;
|
|
return cursor;
|
|
}
|
|
|
|
describe("executeNativeSession recovery", () => {
|
|
it("continues a provider-reported active turn without starting a duplicate turn", async () => {
|
|
const checkpoint: PersistedNativeSession = {
|
|
backendKind: "mock",
|
|
sessionId: "driver-recovery",
|
|
identity,
|
|
providerSessionId: "provider-recovery",
|
|
cursor: "0",
|
|
activeTurnId: null,
|
|
pendingRuntimeRequests: [],
|
|
lineage: [],
|
|
};
|
|
const providerSnapshot: PersistedNativeSession = {
|
|
...checkpoint,
|
|
cursor: "1",
|
|
activeTurnId: "turn-recovery",
|
|
};
|
|
const terminalEvent: PrpEvent = {
|
|
schema: "paperclip.prp.event.v1",
|
|
sourceEventId: "provider-recovery:1",
|
|
sourceSeq: 1,
|
|
sourceInstanceId: "provider-recovery",
|
|
sourceKind: "provider",
|
|
runId: identity.runId,
|
|
normalizedSessionId: identity.sessionId,
|
|
turnId: "turn-recovery",
|
|
eventType: "turn.completed",
|
|
schemaVersion: 1,
|
|
priority: 0,
|
|
emittedAt: "2026-08-09T00:00:00.000Z",
|
|
payload: {},
|
|
};
|
|
const bySource = new Map<string, PrpEvent[]>();
|
|
const startTurn = vi.fn(async () => ({ turnId: "duplicate-turn" }));
|
|
const openSession = vi.fn(async () => { throw new Error("must recover the provider session"); });
|
|
const session: NativeSession = {
|
|
identity: () => identity,
|
|
async capabilities() {
|
|
return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
|
|
},
|
|
async *events() { yield terminalEvent; },
|
|
startTurn,
|
|
async result() { return { result, terminal, turnId: "turn-recovery" }; },
|
|
async snapshot() { return structuredClone(providerSnapshot); },
|
|
async close() {},
|
|
};
|
|
const backend: NativeSessionBackend = {
|
|
async descriptor() {
|
|
return {
|
|
kind: "mock",
|
|
name: "recovery-backend",
|
|
version: "1",
|
|
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
|
|
};
|
|
},
|
|
openSession,
|
|
async recoverSession() { return { recovered: true, session }; },
|
|
};
|
|
const port: ControlPlanePort = {
|
|
async openRun() {},
|
|
async loadSessionCheckpoint() { return structuredClone(checkpoint); },
|
|
async checkpointSession() {},
|
|
async appendEvent(event) {
|
|
const list = bySource.get(event.sourceInstanceId) ?? [];
|
|
list.push(structuredClone(event));
|
|
bySource.set(event.sourceInstanceId, list);
|
|
return {
|
|
cursor: list.length,
|
|
highestContiguousSourceSeq: highestContiguous(list),
|
|
disposition: "committed",
|
|
};
|
|
},
|
|
async replayEvents(replay) {
|
|
const list = bySource.get(replay.sourceInstanceId) ?? [];
|
|
return {
|
|
events: structuredClone(list.filter((event) => event.sourceSeq > replay.afterSourceSeq)),
|
|
highestContiguousSourceSeq: highestContiguous(list),
|
|
};
|
|
},
|
|
async completeRun() {},
|
|
};
|
|
|
|
await expect(executeNativeSession({
|
|
input,
|
|
backend,
|
|
controlPlane: port,
|
|
runnerInstanceId: "runner-recovery",
|
|
controlPlaneInstanceId: "control-recovery",
|
|
})).resolves.toMatchObject({ turnId: "turn-recovery", providerSessionId: "provider-recovery" });
|
|
expect(openSession).not.toHaveBeenCalled();
|
|
expect(startTurn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("recovers a completed checkpoint and appends only a missing control terminal fact", async () => {
|
|
const checkpoint: PersistedNativeSession = {
|
|
backendKind: "mock",
|
|
sessionId: "driver-recovery",
|
|
identity,
|
|
providerSessionId: "provider-recovery",
|
|
cursor: "4",
|
|
semanticResult: result,
|
|
terminal,
|
|
activeTurnId: "turn-recovery",
|
|
terminalTurns: [{ turnId: "turn-recovery", fingerprint: "terminal-fingerprint" }],
|
|
pendingRuntimeRequests: [],
|
|
lineage: [],
|
|
};
|
|
const events = [controlEvent(1, "run.result.accepted", { result })];
|
|
const checkpoints: PersistedNativeSession[] = [];
|
|
const completeRun = vi.fn(async () => undefined);
|
|
const startTurn = vi.fn(async () => ({ turnId: "unexpected-turn" }));
|
|
const openSession = vi.fn(async () => {
|
|
throw new Error("a recovered run must not open a second provider session");
|
|
});
|
|
const session: NativeSession = {
|
|
identity: () => identity,
|
|
async capabilities() {
|
|
return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
|
|
},
|
|
async *events() {},
|
|
startTurn,
|
|
async result() { return { result, terminal, turnId: "turn-recovery" }; },
|
|
async snapshot() { return structuredClone(checkpoint); },
|
|
async close() {},
|
|
};
|
|
const recoverSession = vi.fn(async () => ({ recovered: true, session }));
|
|
const backend: NativeSessionBackend = {
|
|
async descriptor() {
|
|
return {
|
|
kind: "mock",
|
|
name: "recovery-backend",
|
|
version: "1",
|
|
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
|
|
};
|
|
},
|
|
openSession,
|
|
recoverSession,
|
|
};
|
|
const port: ControlPlanePort = {
|
|
async openRun() {},
|
|
async loadSessionCheckpoint() { return structuredClone(checkpoint); },
|
|
async checkpointSession(snapshot) { checkpoints.push(structuredClone(snapshot)); },
|
|
async appendEvent(event) {
|
|
events.push(structuredClone(event as PrpEvent));
|
|
return {
|
|
cursor: events.length,
|
|
highestContiguousSourceSeq: highestContiguous(events),
|
|
disposition: "committed",
|
|
};
|
|
},
|
|
async replayEvents(replay) {
|
|
const replayed = events.filter((event) => event.sourceSeq > replay.afterSourceSeq);
|
|
return { events: structuredClone(replayed), highestContiguousSourceSeq: highestContiguous(events) };
|
|
},
|
|
completeRun,
|
|
};
|
|
|
|
const completed = await executeNativeSession({
|
|
input,
|
|
backend,
|
|
controlPlane: port,
|
|
runnerInstanceId: "runner-recovery",
|
|
controlPlaneInstanceId: "control-recovery",
|
|
});
|
|
|
|
expect(openSession).not.toHaveBeenCalled();
|
|
expect(recoverSession).toHaveBeenCalledOnce();
|
|
expect(startTurn).not.toHaveBeenCalled();
|
|
expect(events.map((event) => event.eventType)).toEqual(["run.result.accepted", "run.terminal"]);
|
|
expect(events.map((event) => event.sourceSeq)).toEqual([1, 2]);
|
|
expect(completeRun).toHaveBeenCalledOnce();
|
|
expect(completed).toMatchObject({ nativeEventCount: 1, highestContiguousSourceSeq: 2 });
|
|
expect(checkpoints.at(-1)).toMatchObject({ semanticResult: result, terminal });
|
|
});
|
|
});
|