mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
Settle OpenCode completion before ending its turn
Keep controller acceptance, inner result and tool feedback consistent during idle, error, abort, close and interruption. Reject concurrent finishing calls before controller admission. Preserve the stopped zero-provider campaign and qualify only a fresh bounded source. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e171282078
commit
c3e0cb303a
5 files changed
+124
-3
No files matched your search
@@ -2153,6 +2153,86 @@ describe("OpenCodeServerDriver", () => {
|
||||
await session.close({ reason: "test" });
|
||||
});
|
||||
|
||||
it.each(["session.idle", "session.status", "session.error", "aborted"])("settles accepted completion before a racing %s event", async terminalType => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-race-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-race-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
let stream: ReadableStreamDefaultController<Uint8Array>;
|
||||
let releaseFeedback!: () => void;
|
||||
let feedbackStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => { feedbackStarted = resolve; });
|
||||
const release = new Promise<void>(resolve => { releaseFeedback = resolve; });
|
||||
const feedback = "Accepted. Keep the exact saved document link in the final response.";
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
fetch: async (input, init) => String(input).endsWith("/event")
|
||||
? new Response(new ReadableStream<Uint8Array>({ start(controller) { stream = controller; } }), { headers: { "Content-Type": "text/event-stream" } })
|
||||
: fetch(input, init),
|
||||
completionFeedback: async () => { feedbackStarted(); await release; return feedback; },
|
||||
});
|
||||
const session = await driver.openSession({ runId: "completion-race", normalizedSessionId: "completion-race", workingDirectory: workspace });
|
||||
try {
|
||||
const events = collectTurnEvents(session.events());
|
||||
await session.startTurn({ message: { role: "user", text: "completion-feedback" } });
|
||||
await started;
|
||||
stream!.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: terminalType === "aborted" ? "session.error" : terminalType, id: "terminal-during-feedback", properties: { sessionID: session.ids().providerSessionId, status: { type: "idle" }, error: terminalType === "aborted" ? { name: "MessageAbortedError", data: { message: "Aborted" } } : { name: "FixtureError", message: "Provider ended during feedback." } } })}\n\n`));
|
||||
// Drain the queued SSE frame before releasing the controller response.
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
releaseFeedback();
|
||||
const observed = await events;
|
||||
const proposed = observed.findIndex(event => event.eventType === "run.result.proposed");
|
||||
const completed = observed.findIndex(event => event.eventType === "item.completed" && event.payload.kind === "dynamicToolCall" && (event.payload.item as { result?: unknown })?.result === feedback);
|
||||
const terminal = observed.findIndex(event => TURN_TERMINAL_EVENT_TYPES.has(event.eventType));
|
||||
expect(proposed).toBeGreaterThanOrEqual(0);
|
||||
expect(completed).toBeGreaterThan(proposed);
|
||||
expect(terminal).toBeGreaterThan(completed);
|
||||
expect(observed.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
|
||||
expect((await session.snapshot()).semanticResult).not.toBeNull();
|
||||
const sessionRoots = (await readdir(root, { withFileTypes: true })).filter(entry => entry.isDirectory());
|
||||
await expect.poll(async () => JSON.parse(await readFile(join(root, sessionRoots[0]!.name, "data/fake-completion-feedback.json"), "utf8"))).toMatchObject([
|
||||
{ result: { content: [{ text: expect.stringContaining(feedback) }] } },
|
||||
]);
|
||||
} finally {
|
||||
releaseFeedback();
|
||||
await session.close({ reason: "test complete" });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["close", "interrupt"] as const)("settles bound completion before explicit %s", async operation => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-stop-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-stop-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
let releaseFeedback!: () => void, feedbackStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => { feedbackStarted = resolve; });
|
||||
const release = new Promise<void>(resolve => { releaseFeedback = resolve; });
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
completionFeedback: async () => { feedbackStarted(); await release; return "Accepted completion."; },
|
||||
});
|
||||
const session = await driver.openSession({ runId: "completion-stop", normalizedSessionId: "completion-stop", workingDirectory: workspace });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "finish" } });
|
||||
await started;
|
||||
let stopped = false;
|
||||
const stop = session[operation]({ reason: "fixture stop" }).then(() => { stopped = true; });
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
releaseFeedback();
|
||||
await stop;
|
||||
expect((await session.snapshot()).semanticResult).not.toBeNull();
|
||||
const transcript = await session.transcript();
|
||||
expect(transcript.events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
|
||||
expect(transcript.events.some(event => event.eventType === "item.completed" && (event.payload.item as { is_error?: boolean })?.is_error)).toBe(false);
|
||||
} finally {
|
||||
releaseFeedback();
|
||||
await session.close({ reason: "test complete" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed and oversized SSE frames", async () => {
|
||||
const stream = (value: string) =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
|
||||
@@ -444,6 +444,7 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
readonly #conversationMode: "task" | "prepared";
|
||||
#sendFullContext: boolean;
|
||||
#closed = false;
|
||||
#completionSettlement: Promise<void> | null = null;
|
||||
#abort = new AbortController();
|
||||
|
||||
constructor(input: {
|
||||
@@ -625,6 +626,7 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
}
|
||||
|
||||
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
|
||||
await this.#completionSettlement;
|
||||
if (
|
||||
input.turnId &&
|
||||
this.#activeTurnId &&
|
||||
@@ -874,6 +876,10 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
// A controller response is authoritative. Settle its tool result before
|
||||
// closing the provider or the event stream, rather than reject after acceptance.
|
||||
await this.#completionSettlement;
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#abort.abort();
|
||||
@@ -942,7 +948,10 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
{ turnId, itemId: call.callId },
|
||||
);
|
||||
if (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) {
|
||||
let settle: (() => void) | undefined;
|
||||
try {
|
||||
if (this.#closed || this.#completionSettlement)
|
||||
throw new Error("A completion is already settling or the session is closed");
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
if (!validation.ok) throw new Error("Invalid semantic result");
|
||||
if (
|
||||
@@ -972,6 +981,7 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
const fingerprint = canonicalJson(validation.result);
|
||||
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint)
|
||||
throw new Error("A different semantic result was already committed");
|
||||
this.#completionSettlement = new Promise<void>(resolve => { settle = resolve; });
|
||||
// Wait for the bound controller before committing or resolving the
|
||||
// provider call. A rejection remains repairable in this same turn.
|
||||
const feedback = this.#completionFeedback
|
||||
@@ -981,8 +991,6 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
: "Semantic completion accepted.";
|
||||
if (typeof feedback !== "string" || !feedback.trim())
|
||||
throw new Error("Completion feedback omitted its response text");
|
||||
if (this.#closed || this.#activeTurnId !== turnId || this.#terminalTurns.has(turnId))
|
||||
throw new Error("The turn ended while checking completion. The result was not accepted.");
|
||||
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint)
|
||||
throw new Error("A different semantic result was already committed");
|
||||
if (!this.#resultFingerprint) {
|
||||
@@ -1019,6 +1027,11 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
is_error: true, error: error instanceof Error ? error.message : String(error) },
|
||||
}, { turnId, itemId: call.callId });
|
||||
throw error;
|
||||
} finally {
|
||||
if (settle) {
|
||||
this.#completionSettlement = null;
|
||||
settle();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.#dynamicToolHandler)
|
||||
@@ -1303,6 +1316,16 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const type = text(record(event).type);
|
||||
const properties = record(record(event).properties);
|
||||
if (type === "session.idle" || type === "session.error"
|
||||
|| (type === "session.status" && text(record(record(event).properties).status && record(record(record(event).properties).status).type) === "idle")) {
|
||||
// Do not seal the turn while its bound controller is deciding a
|
||||
// finishing call. Acceptance/rejection and the tool result must
|
||||
// precede the provider's terminal event.
|
||||
await this.#completionSettlement;
|
||||
if (this.#closed) return;
|
||||
}
|
||||
this.#mapProviderEvent(event, frameId);
|
||||
}
|
||||
throw new Error(
|
||||
@@ -1312,6 +1335,8 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
if (this.#closed || this.#abort.signal.aborted) return;
|
||||
attempts += 1;
|
||||
if (attempts > 3) {
|
||||
await this.#completionSettlement;
|
||||
if (this.#closed) return;
|
||||
this.#emit("harness.diagnostic", {
|
||||
code: "opencode_sse_failed",
|
||||
message: redact(String(error), this.#runtime.sensitiveValues),
|
||||
|
||||
Reference in new issue
Block a user