diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index eb80643cf22..ad32c0c2e37 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -263,6 +263,15 @@ Messages from the same conversation still coalesce into one run. Messages from different conversations queue separately and run one after the other. Wakes without a conversation keep their normal coalescing. +For a truncated external-chat wake, native completion feedback requires the +run-bound complete comment-read receipt before accepting a finish report. An +unread, partial, stale or foreign receipt returns a corrective +`read_current_wake_comments` instruction through the completion tool. The agent +can read the accepted comments and correct its report in the same turn without +repeating completed actions. An unfinished read continues with its most recent +`nextCursor`; a changed snapshot restarts without a cursor. The terminal guard +still checks the exact current binding and snapshot after provider settlement. + ### `done` The work is complete and terminal. diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index d86ce0f955e..74f483c4790 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -282,6 +282,18 @@ async function callTerminalTool(promptBody, signal) { const first = await call(); if (String(prompt.message ?? prompt.task?.prompt ?? "").includes("completion-feedback")) { const outcomes = [first]; + if (first.result?.isError && String(prompt.message ?? prompt.task?.prompt ?? "").includes("current-wake-completion-feedback")) { + let cursor; + for (let pageIndex = 0; pageIndex < 8; pageIndex++) { + const response = await mcpRequest("tools/call", { + name: "paperclip_read_current_wake_comments", arguments: { cursor }, + }); + if (response.result?.isError) throw new Error("Current-wake fixture reader was rejected"); + const page = JSON.parse(response.result.content[0].text); + if (page.complete) break; + cursor = page.nextCursor; + } + } if (first.result?.isError) outcomes.push(await call()); await writeFile(join(process.env.XDG_DATA_HOME, "fake-completion-feedback.json"), JSON.stringify(outcomes)); return outcomes.at(-1); @@ -507,7 +519,7 @@ const server = createServer(async (request, response) => { emit({ type: "session.idle", properties: { sessionID: session.id } }); return; } - await callFirstPaperclipTool(); + if (!String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").includes("current-wake-completion-feedback")) await callFirstPaperclipTool(); if (String(parsedPrompt.message ?? "").includes("managed-github-operation")) { const result = await new Promise(resolve => { execFile("gh", ["api", "user"], { timeout: 5000 }, (error, stdout, stderr) => { diff --git a/server/src/services/native-runtime/current-wake-comments.ts b/server/src/services/native-runtime/current-wake-comments.ts index 8fcb1e9d155..2f82eafd516 100644 --- a/server/src/services/native-runtime/current-wake-comments.ts +++ b/server/src/services/native-runtime/current-wake-comments.ts @@ -21,6 +21,8 @@ export const CURRENT_WAKE_COMMENTS_BINDING_SCHEMA = "paperclip.current-wake-comments-binding.v1"; export const CURRENT_WAKE_COMMENTS_RECEIPT_SCHEMA = "paperclip.current-wake-comments-receipt.v1"; +export const NATIVE_CURRENT_WAKE_COMMENTS_UNREAD = "native_current_wake_comments_unread"; +export const NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ = "native_current_wake_comments_changed_after_read"; const CURRENT_WAKE_COMMENTS_RECEIPT_KEY = "currentWakeCommentsReceipt"; const MAX_PAGE_BODY_CHARS = 12_000; @@ -816,17 +818,17 @@ export async function assertCurrentWakeCommentsRead( record(locked.resultJson)[CURRENT_WAKE_COMMENTS_RECEIPT_KEY], ); if (!receipt || receipt.complete !== true || receipt.nextCursor !== null) { - throw new Error("native_current_wake_comments_unread"); + throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_UNREAD); } if (receipt.bindingDigest !== binding.bindingDigest) { - throw new Error("native_current_wake_comments_changed_after_read"); + throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ); } const snapshot = await currentWakeCommentsSnapshot( tx as unknown as Db, binding, ); if (receipt.snapshotDigest !== snapshot.snapshotDigest) { - throw new Error("native_current_wake_comments_changed_after_read"); + throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ); } }); } diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index bd269a9df21..941edb16daf 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -13,6 +13,7 @@ import { isConversation } from "../agent-conversations.js"; import { issueTreeControlService } from "../issue-tree-control.js"; import { isPublishedGitHubReviewCompletionContinuation, readPublishedGitHubReviewCompletionContinuation, resolveExternalChatResponseWaitAuthorization } from "./chat-attachment-reuse.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; +import { assertCurrentWakeCommentsRead, NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ, NATIVE_CURRENT_WAKE_COMMENTS_UNREAD, resolveCurrentWakeCommentsBinding } from "./current-wake-comments.js"; import { and, eq, inArray, notInArray } from "drizzle-orm"; import { approvals, @@ -66,6 +67,24 @@ export async function nativeCompletionFeedback( ) .then((rows) => rows[0]); if (!issue) throw new Error("Completion task no longer exists."); + const wakeScope = { companyId: run.companyId, issueId: issue.id, runId, agentId: run.agentId }; + const currentWake = await resolveCurrentWakeCommentsBinding(db, wakeScope); + if (currentWake) { + try { + await assertCurrentWakeCommentsRead(db, wakeScope, currentWake); + } catch (error) { + if (error instanceof Error && (error.message === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD || error.message === NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ)) { + const reason = error.message === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD + ? "The accepted comments have not been fully read." + : "The accepted comments changed after the complete read."; + const cursorInstruction = error.message === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD + ? "If you have an unfinished read, continue with its most recent nextCursor; otherwise start without a cursor. If the reader reports that the snapshot changed, restart without a cursor." + : "Start without a cursor."; + throw new Error(`${reason} Call read_current_wake_comments. ${cursorInstruction} Follow each returned nextCursor until complete=true. Reassess the complete current request and resubmit the completion report in this turn. Preserve completed work and do not repeat completed actions.`); + } + throw error; + } + } if (result.reportedWorkDisposition === "yielded" && isPublishedGitHubReviewCompletionContinuation(result.continuation)) { const current = await readPublishedGitHubReviewCompletionContinuation({ db, binding: { companyId: run.companyId, issueId: issue.id, runId, agentId: run.agentId } }); diff --git a/server/src/services/native-runtime/native-completion-format-feedback.test.ts b/server/src/services/native-runtime/native-completion-format-feedback.test.ts index 4094bcf4b4b..4a43b230858 100644 --- a/server/src/services/native-runtime/native-completion-format-feedback.test.ts +++ b/server/src/services/native-runtime/native-completion-format-feedback.test.ts @@ -17,9 +17,13 @@ vi.mock("../../vendor/paperclip-runner/index.js", () => ({ normalizePrpResultSig const result = { reportedWorkDisposition: "done", summary: "EXACT_PRIVATE_MARKER", completionClaim: { objectiveSatisfied: true, criteria: [], remainingWork: [] } } as unknown as PrpStructuredRunResult; function database(options: { interaction?: boolean; approval?: boolean; review?: boolean } = {}): Db { // Pure query double: no client, PostgreSQL process, or network is created. - return { select: () => ({ from: (table: unknown) => { - const rows = table === heartbeatRuns ? [{ id: "run", companyId: "company", nativeIssueId: "issue", contextSnapshot: {} }] - : table === issues ? [{ id: "issue", companyId: "company", status: "in_progress", ...(options.review ? { executionState: { status: "pending" } } : {}) }] + const run = { id: "run", companyId: "company", agentId: "agent", nativeIssueId: "issue", runtimeMode: "native", status: "running", contextSnapshot: {} }; + const issue = { id: "issue", companyId: "company", status: "in_progress", assigneeAgentId: "agent", executionRunId: "run", ...(options.review ? { executionState: { status: "pending" } } : {}) }; + return { select: (fields?: { run?: unknown }) => ({ from: (table: unknown) => { + const rows = table === heartbeatRuns ? fields?.run + ? [{ run, issueAssigneeAgentId: issue.assigneeAgentId, issueExecutionRunId: issue.executionRunId }] + : [run] + : table === issues ? [issue] : table === issueThreadInteractions && options.interaction ? [{ id: "request", kind: "request_confirmation", title: "Decision" }] : table === issueApprovals && options.approval ? [{ id: "approval" }] : []; const query = { where: () => query, limit: () => query, orderBy: () => query, innerJoin: () => query, then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) }; diff --git a/server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts b/server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts new file mode 100644 index 00000000000..616372ac49a --- /dev/null +++ b/server/src/services/native-runtime/native-current-wake-completion-feedback.test.ts @@ -0,0 +1,234 @@ +import { randomUUID } from "node:crypto"; +import { chmod, copyFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns, issueComments, issues, nativeRunResults } from "@paperclipai/db"; +import { createNativeSessionBackend, type NativeExecutionInput, type PrpEvent, type PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { assertCurrentWakeCommentsRead, READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION, resolveCurrentWakeCommentsBinding, type CurrentWakeCommentsPage } from "./current-wake-comments.js"; +import { nativeCompletionFeedback } from "./native-completion-feedback.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const report: PrpStructuredRunResult = { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Answered the accepted message.", + completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], +}; + +describe.skipIf(!support.supported)("current-wake completion feedback", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-wake-feedback-"); + db = createDb(temporary.connectionString); + }, 120_000); + afterAll(async () => { await db?.$client.end({ timeout: 0 }); await temporary?.cleanup(); }); + + async function seed(bodies = ["Read this complete accepted message."], fallbackFetchNeeded = true) { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Wake feedback", issuePrefix: `WF${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "paperclip_runner", status: "running" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Answer the accepted message", status: "in_progress", assigneeAgentId: agentId }); + const comments = await db.insert(issueComments).values(bodies.map(body => ({ companyId, issueId, body, authorType: "user" as const, authorUserId: "local-board" }))).returning(); + const commentIds = comments.map(comment => comment.id); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, + runtimeMode: "native", status: "running", resultJson: { existingEvidence: "keep" }, contextSnapshot: { + issueId, paperclipExternalChatExecutionBound: true, paperclipWake: { + externalChatProvider: "github", checkedOutByHarness: false, externalChatExecutionBound: true, + fallbackFetchNeeded, truncated: fallbackFetchNeeded, commentIds, latestCommentId: commentIds.at(-1), + commentWindow: { requestedCount: commentIds.length, includedCount: commentIds.length, missingCount: 0 }, + }, + } }); + await db.update(issues).set({ executionRunId: runId, executionLockedAt: new Date() }).where(eq(issues.id, issueId)); + const binding = { companyId, agentId, issueId, runId }; + const wake = await resolveCurrentWakeCommentsBinding(db, binding); + const authority = new PaperclipRunnerToolAuthority(db, { ...binding, currentWakeComments: wake ?? undefined }); + let reads = 0; + const read = async (cursor?: string | null) => authority.execute({ tool: "read_current_wake_comments", callId: `read-${++reads}`, arguments: { cursor } }) as Promise; + return { ...binding, comments, wake, read, authority }; + } + + async function finish(value: Awaited>) { + return nativeCompletionFeedback(db, value.runId, report); + } + async function receipt(value: Awaited>) { + return db.select({ result: heartbeatRuns.resultJson }).from(heartbeatRuns).where(eq(heartbeatRuns.id, value.runId)).then(rows => rows[0]!.result); + } + async function assertSameRunningTurn(value: Awaited>) { + const runs = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, value.companyId), eq(heartbeatRuns.agentId, value.agentId))); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ id: value.runId, status: "running", resultJson: { existingEvidence: "keep" } }); + expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.companyId, value.companyId))).toHaveLength(0); + } + + it("corrects an unread finish before settlement, then accepts the actual reader in the same turn", async () => { + const value = await seed(); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + await assertSameRunningTurn(value); + const page = await value.read(); + expect(page).toMatchObject({ complete: true, nextCursor: null }); + expect(page.comments[0]?.bodyChunk).toBe(value.comments[0]!.body); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await expect(assertCurrentWakeCommentsRead(db, value, value.wake)).resolves.toBeUndefined(); + await assertSameRunningTurn(value); + }); + + it("requires every page before accepting finish", async () => { + const value = await seed(["A".repeat(16_001)]); + let page = await value.read(); + expect(page.complete).toBe(false); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + const chunks = page.comments.map(comment => comment.bodyChunk); + while (!page.complete) { + page = await value.read(page.nextCursor); + chunks.push(...page.comments.map(comment => comment.bodyChunk)); + } + expect(chunks.join("")).toBe(value.comments[0]!.body); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("follows partial-read feedback after two of three pages without restarting the reader", async () => { + const value = await seed(["B".repeat(32_001)]); + const first = await value.read(); + const second = await value.read(first.nextCursor); + expect(first.complete).toBe(false); + expect(second.complete).toBe(false); + const correction = await finish(value).then( + () => { throw new Error("An incomplete read received an accepted report"); }, + error => (error as Error).message, + ); + const instructedCursor = correction.includes("most recent nextCursor") ? second.nextCursor : undefined; + const last = await value.read(instructedCursor); + expect(last).toMatchObject({ complete: true, nextCursor: null }); + expect([...first.comments, ...second.comments, ...last.comments].map(comment => comment.bodyChunk).join("")).toBe(value.comments[0]!.body); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("corrects a changed snapshot and accepts only a new complete read", async () => { + const value = await seed(); + await value.read(); + await db.update(issueComments).set({ body: "Revised accepted message", updatedAt: new Date() }).where(eq(issueComments.id, value.comments[0]!.id)); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + const page = await value.read(); + expect(page.comments[0]?.bodyChunk).toBe("Revised accepted message"); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("retains the snapshot-change restart path during a partial read", async () => { + const value = await seed(["C".repeat(32_001)]); + const first = await value.read(); + const second = await value.read(first.nextCursor); + await db.update(issueComments).set({ body: "A revised request", updatedAt: new Date() }).where(eq(issueComments.id, value.comments[0]!.id)); + const correction = await finish(value).then( + () => { throw new Error("A stale incomplete read received an accepted report"); }, + error => (error as Error).message, + ); + expect(correction).toContain("If the reader reports that the snapshot changed, restart without a cursor"); + await expect(value.read(second.nextCursor)).rejects.toThrow("paperclip_current_wake_comments_snapshot_changed"); + const restarted = await value.read(); + expect(restarted).toMatchObject({ complete: true, nextCursor: null }); + expect(restarted.comments[0]?.bodyChunk).toBe("A revised request"); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("does not accept another company's complete read receipt", async () => { + const foreign = await seed(), value = await seed(); + await foreign.read(); + const foreignReceipt = (await receipt(foreign))!.currentWakeCommentsReceipt; + await db.update(heartbeatRuns).set({ resultJson: { existingEvidence: "keep", currentWakeCommentsReceipt: foreignReceipt } }).where(eq(heartbeatRuns.id, value.runId)); + await expect(finish(value)).rejects.toThrow(/read_current_wake_comments/); + await expect(finish(foreign)).resolves.toContain("Completion report accepted"); + await value.read(); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("keeps inline-complete ordinary completion unchanged", async () => { + const value = await seed(["The full inline message"], false); + expect(value.wake).toBeNull(); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await assertSameRunningTurn(value); + }); + + it("keeps the terminal snapshot fence after early feedback accepts the report", async () => { + const value = await seed(); + await value.read(); + await expect(finish(value)).resolves.toContain("Completion report accepted"); + await db.update(issueComments).set({ body: "Changed after feedback", updatedAt: new Date() }).where(eq(issueComments.id, value.comments[0]!.id)); + await expect(assertCurrentWakeCommentsRead(db, value, value.wake)).rejects.toThrow("native_current_wake_comments_changed_after_read"); + await assertSameRunningTurn(value); + }); + + it("delivers corrective MCP feedback and completes one OpenCode turn after the real paginated reader", async () => { + const value = await seed(["A".repeat(16_001)]); + const root = await mkdtemp(join(tmpdir(), "paperclip-wake-feedback-provider-")); + const command = join(root, "fake-opencode-server.mjs"); + await copyFile(new URL("../../../../packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs", import.meta.url), command); + await chmod(command, 0o755); + const order: string[] = []; + const sessionId = randomUUID(); + const execution: NativeExecutionInput = { + schema: "paperclip.native-execution-input.v1", + binding: { companyId: value.companyId, issueId: value.issueId, runId: value.runId, agentId: value.agentId, executionWorkspaceId: randomUUID() }, + task: { identifier: "WF-1", title: "Read and answer the accepted message", description: null, prompt: "current-wake-completion-feedback", workMode: "standard" }, + workspace: { cwd: root, repoUrl: null, repoRef: null, branchName: null }, + session: { normalizedSessionId: sessionId, driverKind: "opencode_server", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }, + provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, + completionContract: { id: "feedback", sha256: "feedback", schemaVersion: "1", contract: { revision: "test", objective: "Answer the accepted message", criteria: [] } }, + interactionResponses: [], credentialBindings: [], + }; + const backend = createNativeSessionBackend(execution, { + opencodeRuntimeDirectory: join(root, "runtime"), opencodeCommand: command, + opencodeEnvironment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + dynamicTools: [READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION], + dynamicToolHandler: async call => { + order.push("read"); + return value.authority.execute({ tool: call.tool, callId: call.callId, arguments: call.arguments }); + }, + completionFeedback: async result => { + try { + const response = await nativeCompletionFeedback(db, value.runId, result); + order.push("finish-accepted"); + return response; + } catch (error) { order.push("finish-refused"); throw error; } + }, + }); + const session = await backend.openSession({ identity: { companyId: value.companyId, runId: value.runId, sessionId, issueId: value.issueId, agentId: value.agentId }, workingDirectory: root }); + try { + const collected = (async () => { + const events: PrpEvent[] = []; + for await (const event of session.events()) { + events.push(event); + if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) break; + } + return events; + })(); + await session.startTurn({ message: { role: "user", text: "current-wake-completion-feedback" } }); + const events = await collected; + expect(order[0]).toBe("finish-refused"); + expect(order.slice(1, -1).length).toBeGreaterThan(1); + expect(order.slice(1, -1).every(step => step === "read")).toBe(true); + expect(order.at(-1)).toBe("finish-accepted"); + expect(order.filter(step => step.startsWith("finish"))).toHaveLength(2); + expect(events.filter(event => event.eventType === "turn.started")).toHaveLength(1); + expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1); + expect(events.at(-1)?.eventType).toBe("turn.completed"); + expect((await session.snapshot()).semanticResult).not.toBeNull(); + const runtime = join(root, "runtime"); + const directories = (await readdir(runtime, { withFileTypes: true })).filter(entry => entry.isDirectory()); + const outcomes = JSON.parse(await readFile(join(runtime, directories[0]!.name, "data/fake-completion-feedback.json"), "utf8")); + expect(outcomes).toHaveLength(2); + expect(outcomes[0].result).toMatchObject({ isError: true, content: [{ text: expect.stringContaining("read_current_wake_comments") }] }); + expect(outcomes[1].result.isError).not.toBe(true); + await expect(assertCurrentWakeCommentsRead(db, value, value.wake)).resolves.toBeUndefined(); + await assertSameRunningTurn(value); + } finally { await session.close({ reason: "feedback qualification complete" }); await rm(root, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts index 2f91c43a862..071fdf49e8d 100644 --- a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts +++ b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts @@ -30,7 +30,8 @@ vi.mock("./paperclip-runner-tool-authority.js", () => ({ close() {} }, })); -vi.mock("./current-wake-comments.js", () => ({ +vi.mock("./current-wake-comments.js", async importOriginal => ({ + ...await importOriginal(), resolveCurrentWakeCommentsBinding: async () => null, assertCurrentWakeCommentsRead: async () => undefined, })); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 316dab9ac5a..f3a7ea42bbf 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -346,7 +346,8 @@ vi.mock("./native-runner-file-handoff.js", () => ({ state.renderNativeRunnerStagedAttachmentPrompt, })); -vi.mock("./current-wake-comments.js", () => ({ +vi.mock("./current-wake-comments.js", async importOriginal => ({ + ...await importOriginal(), resolveCurrentWakeCommentsBinding: state.resolveCurrentWakeCommentsBinding, assertCurrentWakeCommentsRead: state.assertCurrentWakeCommentsRead, })); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 3bd3b3718ea..b480ea84321 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -215,6 +215,8 @@ import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./ import { NativeChatAttachmentReadScope } from "./chat-attachment-read.js"; import { assertCurrentWakeCommentsRead, + NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ, + NATIVE_CURRENT_WAKE_COMMENTS_UNREAD, resolveCurrentWakeCommentsBinding, } from "./current-wake-comments.js"; import { @@ -7022,8 +7024,8 @@ export function nativeSessionFailureDisposition( sourceFailureCode === "runner_remote_provider_verification_unavailable" || sourceFailureCode === "native_provider_terminal_failed" || sourceFailureCode === NATIVE_PROVIDER_STATE_INCOMPATIBLE || - sourceFailureCode === "native_current_wake_comments_unread" || - sourceFailureCode === "native_current_wake_comments_changed_after_read" || + sourceFailureCode === NATIVE_CURRENT_WAKE_COMMENTS_UNREAD || + sourceFailureCode === NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ || sourceFailureCode === "native_session_cleanup_quarantined" || sourceFailureCode === "native_adopted_runner_authentication_timeout" || sourceFailureCode === "native_provider_usage_limit"; @@ -7094,8 +7096,8 @@ export function nativeSessionFailureSourceCode( | "native_event_replay_conflict" | "native_provider_runtime_missing" | "native_provider_model_rejected" - | "native_current_wake_comments_unread" - | "native_current_wake_comments_changed_after_read" + | typeof NATIVE_CURRENT_WAKE_COMMENTS_UNREAD + | typeof NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ | "provider_session_open_rejected" | "native_session_interrupted" { if (error instanceof NativePermissionDeclinedError) return "native_permission_declined"; @@ -7175,11 +7177,11 @@ export function nativeSessionFailureSourceCode( if (/native_event_replay_conflict/i.test(message)) { return "native_event_replay_conflict"; } - if (/native_current_wake_comments_changed_after_read/i.test(message)) { - return "native_current_wake_comments_changed_after_read"; + if (message.toLowerCase().includes(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ.toLowerCase())) { + return NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ; } - if (/native_current_wake_comments_unread/i.test(message)) { - return "native_current_wake_comments_unread"; + if (message.toLowerCase().includes(NATIVE_CURRENT_WAKE_COMMENTS_UNREAD.toLowerCase())) { + return NATIVE_CURRENT_WAKE_COMMENTS_UNREAD; } // The runner reported a definite session.open outcome. That command runs // before any prompt, so provider work cannot have started. An indeterminate diff --git a/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts b/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts index c60b376fed5..9753733cac7 100644 --- a/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts +++ b/server/src/services/native-runtime/native-warm-transition-restart.integration.test.ts @@ -122,7 +122,8 @@ vi.mock("./paperclip-runner-tool-authority.js", () => ({ } }, })); -vi.mock("./current-wake-comments.js", () => ({ +vi.mock("./current-wake-comments.js", async importOriginal => ({ + ...await importOriginal(), resolveCurrentWakeCommentsBinding: async () => null, assertCurrentWakeCommentsRead: async () => undefined, }));