From 3c66c65f9c79b6ef4a862bc2dd5179b9501f287a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 18 Sep 2026 09:54:07 -0500 Subject: [PATCH] fix(runner): retain provider sessions and send only new continuation events Co-Authored-By: Paperclip --- .../src/contracts/native-execution.test.ts | 12 +++++ .../src/contracts/native-execution.ts | 24 +++++++++- .../codex/codex-app-server-driver.test.ts | 14 ++++++ .../drivers/codex/codex-harness-session.ts | 16 +++++-- .../src/native-session-runtime.test.ts | 14 +++++- .../src/native-session-runtime.ts | 5 +- server/src/services/heartbeat.ts | 23 ++++++++-- .../native-continuation.test.ts | 46 +++++++++++++++++++ .../native-runtime/native-continuation.ts | 43 +++++++++++++++++ .../native-runtime/native-execution-input.ts | 12 ++++- .../native-session-resume.test.ts | 7 +-- tests/runner-e2e/README.md | 15 ++++++ tests/runner-e2e/first-task-scoring.ts | 21 +++++++++ tests/runner-e2e/first-task.test.ts | 14 ++++++ 14 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 server/src/services/native-runtime/native-continuation.test.ts create mode 100644 server/src/services/native-runtime/native-continuation.ts diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 5e0c90a266a..8e7657319cd 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -96,6 +96,18 @@ describe("NativeExecutionInputV1", () => { schema: "paperclip.native-execution-input.v4", provider: { kind: "codex", model: null, approvalPolicy: "on-request" }, }); + const withDelta = parseNativeExecutionInput({ ...current, continuationPrompt: '{"messages":[{"authorType":"user","body":"Just this new comment"}]}' }); + // No checkpoint / failed provider recovery must retain full bootstrap input. + expect(buildNativeModelEnvelope(withDelta)).toEqual(buildNativeModelEnvelope(current)); + const delta = buildNativeModelEnvelope(withDelta, { resumedSession: true }); + expect(delta).toEqual({ + schema: "paperclip.native-continuation.v1", + events: '{"messages":[{"authorType":"user","body":"Just this new comment"}]}', + completion: { revision: "1", criterionIds: ["objective"] }, + }); + expect(JSON.stringify(delta)).not.toContain(input.task.title); + expect(JSON.stringify(delta)).not.toContain(input.completionContract.contract.objective); + expect(JSON.stringify(delta)).not.toContain("opaque-binding"); expect(current).toMatchObject({ schema: "paperclip.native-execution-input.v4", provider: { kind: "codex", approvalPolicy: "on-request" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 2028e3e961d..b18d1f41542 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -194,6 +194,8 @@ export interface NativeExecutionInputV3 extends Omit { schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA; provider: NativeProviderConfigV4; + /** Used only after the runtime proves provider-session recovery succeeded. */ + continuationPrompt?: string | null; } export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4; @@ -290,6 +292,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput "credentialBindings", ...(isV2 ? ["executionMode", "planningContext"] : []), ...(isV3 ? ["runtimeContext"] : []), + ...(isV4 ? ["continuationPrompt"] : []), ], "input"); if (!isV2 && input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA_V1) { throw new NativeExecutionInputError( @@ -713,11 +716,30 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput return { ...withRuntimeContext, schema: NATIVE_EXECUTION_INPUT_SCHEMA, + ...(input.continuationPrompt !== undefined ? { continuationPrompt: nullableText(input.continuationPrompt, "input.continuationPrompt") } : {}), provider: parsedProvider as NativeProviderConfigV4, }; } -export function buildNativeModelEnvelope(input: NativeExecutionInput): NativeModelEnvelopeV1 | NativeModelEnvelopeV2 { +export interface NativeContinuationEnvelope { + schema: "paperclip.native-continuation.v1"; + events: string; + completion: { revision: string; criterionIds: string[] }; +} + +export function buildNativeModelEnvelope(input: NativeExecutionInput, options: { resumedSession: true }): NativeModelEnvelopeV1 | NativeModelEnvelopeV2 | NativeContinuationEnvelope; +export function buildNativeModelEnvelope(input: NativeExecutionInput): NativeModelEnvelopeV1 | NativeModelEnvelopeV2; +export function buildNativeModelEnvelope(input: NativeExecutionInput, options?: { resumedSession: boolean }): NativeModelEnvelopeV1 | NativeModelEnvelopeV2 | NativeContinuationEnvelope { + if (options?.resumedSession && "continuationPrompt" in input && input.continuationPrompt) { + return { + schema: "paperclip.native-continuation.v1", + events: input.continuationPrompt, + completion: { + revision: input.completionContract.contract.revision, + criterionIds: input.completionContract.contract.criteria.map((criterion) => criterion.id), + }, + }; + } if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V1) { return { schema: NATIVE_MODEL_ENVELOPE_SCHEMA_V1, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts index 40188359afd..ba792f14b50 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts @@ -1353,6 +1353,20 @@ describe("Codex app-server Codex driver", () => { }, ); + it("passes native continuation events without repeating task wrappers or skill invocation", async () => { + 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: TEST_WORKING_DIRECTORY }); + 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 } }); + const params = transport.calls.find((call) => call.method === "turn/start")!.params; + 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"); + await session.close({ reason: "test complete" }); + }); + it("allows eval fixtures to opt out of Codex collaboration instructions", async () => { const transport = new FakeCodexTransport(); const session = await makeDriver([transport], { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 62d6c92533a..791a73d394f 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -160,10 +160,16 @@ export class CodexHarnessSession ); } const dispositionOnlyRecovery = this.dispositionOnlyRecoveryAvailable; + // A native continuation already carries just new events and the current + // completion IDs. Do not wrap it in the prior task objective/constraints + // or re-invoke a skill whose instructions are already in this session. + let continuationTurn = false; + try { continuationTurn = record(JSON.parse(input.message.text)).schema === "paperclip.native-continuation.v1"; } catch { /* Ordinary text. */ } + const turnSkills = continuationTurn ? [] : this.skillInputs; const taskText = this.conversationMode === "direct" ? input.message.text - : dispositionOnlyRecovery + : dispositionOnlyRecovery || continuationTurn ? input.message.text : JSON.stringify({ task: this.taskEnvelope, @@ -192,7 +198,7 @@ export class CodexHarnessSession this.emit("turn.submitted", { envelopeSchema: this.taskEnvelope.schema, text: input.message.text, - ...(this.skillInputs.length ? { skillInputs: this.skillInputs } : {}), + ...(turnSkills.length ? { skillInputs: turnSkills } : {}), requestedCollaborationMode: input.requestedCollaborationMode ?? effectiveCollaborationMode, effectiveCollaborationMode, @@ -219,11 +225,11 @@ export class CodexHarnessSession input: [ userInput({ role: "user", - text: this.skillInputs.length - ? `${this.skillInputs.map((skill) => `$${skill.name}`).join(" ")}\n\n${taskText}` + text: turnSkills.length + ? `${turnSkills.map((skill) => `$${skill.name}`).join(" ")}\n\n${taskText}` : taskText, }), - ...this.skillInputs, + ...turnSkills, ], ...(this.conversationMode === "direct" ? {} diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 024858c6ed6..f618d226fad 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -6143,6 +6143,13 @@ describe("executeNativeSession recovery", () => { }); it("replaces a provider session that already ended with a failed terminal", async () => { + const digest = "0".repeat(64); + const context = { + prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } }, + skills: [], + mcp: { assignmentSetId: "none", digest, bindingId: null }, + } as const; const checkpoint: PersistedNativeSession = { backendKind: "mock", sessionId: "driver-failed", @@ -6205,6 +6212,7 @@ describe("executeNativeSession recovery", () => { kind: "mock", name: "replacement-backend", version: "1", + runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" }, capabilities: { resume: true, typedEvents: true, @@ -6243,7 +6251,10 @@ describe("executeNativeSession recovery", () => { await expect( executeNativeSession({ - input, + input: { ...input, schema: "paperclip.native-execution-input.v4", executionMode: "default", planningContext: null, + provider: { kind: "codex", model: null, approvalPolicy: "never" }, + runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, + continuationPrompt: "ONLY_NEW_COMMENT" }, backend, controlPlane: port, runnerInstanceId: "runner-replacement", @@ -6258,6 +6269,7 @@ describe("executeNativeSession recovery", () => { startTurn.mock.calls[0]![0].message.text, ) as { task: { prompt: string } }; expect(replacementEnvelope.task.prompt).toBe(input.task.prompt); + expect(JSON.stringify(replacementEnvelope)).not.toContain("ONLY_NEW_COMMENT"); expect(onContinuityBreak).toHaveBeenCalledWith({ reason: "provider session ended with a failed terminal", previousDriverSessionId: "driver-failed", diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index 159bd9d7517..cb386765041 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -2329,7 +2329,9 @@ export async function executeNativeSession( } await checkpoint(); } else if (shouldStartFreshTurn) { - const modelEnvelope = buildNativeModelEnvelope(input); + let modelEnvelope = recovered + ? buildNativeModelEnvelope(input, { resumedSession: true }) + : buildNativeModelEnvelope(input); const dispositionOnlyRecovery = Boolean( recovered && !recoveredSnapshot.semanticResult && @@ -2346,6 +2348,7 @@ export async function executeNativeSession( }) : false; if (dispositionOnlyRecovery && !effectFreeInitialAcpxTurn) { + modelEnvelope = buildNativeModelEnvelope(input); modelEnvelope.task.prompt = [ "Paperclip semantic-result recovery for a prior completed provider turn.", "The prior turn already performed the work and its user-facing final answer is recorded.", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bbb496068d4..6fc77e2423e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -21642,6 +21642,11 @@ export function heartbeatService( selectedEnvironmentForConfig?.driver === "sandbox" && selectedEnvironmentConfigForFingerprint.reuseLease === true && selectedEnvironmentConfigForFingerprint.runnerLifecycleMode === "warm"; + // Native provider checkpoints bind to the workspace row, including ordinary + // local shared workspaces. Persist that binding independently of the opt-in + // isolated-workspace UI, just as warm sandbox continuity already does. + const nativeSharedWorkspace = agent.adapterType === "paperclip_runner" && + requestedExecutionWorkspaceMode === "shared_workspace"; const bindIssueToPersistedExecutionWorkspace = async ( workspace: ExecutionWorkspace | null, ) => { @@ -21655,7 +21660,7 @@ export function heartbeatService( issueRef?.executionWorkspacePreference === "reuse_existing" || requestedExecutionWorkspaceMode === "isolated_workspace" || requestedExecutionWorkspaceMode === "operator_branch" || - warmReusableExecutionWorkspace; + warmReusableExecutionWorkspace || nativeSharedWorkspace; const nextIssuePatch: Record = {}; if (issueExecutionWorkspaceIdForRun !== workspace.id) { nextIssuePatch.executionWorkspaceId = workspace.id; @@ -21684,7 +21689,7 @@ export function heartbeatService( db, undefined, undefined, - { bindRuntimeSharedWorkspace: warmReusableExecutionWorkspace && workspace.mode === "shared_workspace" }, + { bindRuntimeSharedWorkspace: (warmReusableExecutionWorkspace || nativeSharedWorkspace) && workspace.mode === "shared_workspace" }, ); issueExecutionWorkspaceIdForRun = workspace.id; issueProjectWorkspaceIdForRun = @@ -23294,7 +23299,7 @@ export function heartbeatService( taskPrompt: [ nativeReviewRequest ?? readNonEmptyString( selectPaperclipTaskMarkdown(context, { - resumedSession, + resumedSession: false, }), ) ?? `# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`, @@ -23304,6 +23309,18 @@ export function heartbeatService( ].filter(Boolean).join("\n\n"), wakePayload: context.paperclipWake, resumedSession, + previousTurn: (() => { + if (!previousNativeRun || nativeReviewRequest) return null; + try { + return { + runId: previousNativeRun.id, + task: parseNativeExecutionInput(parseObject(previousNativeRun.runnerProfileJson).nativeExecutionInput).task, + }; + } catch { + // An invalid prior snapshot must use the fresh bootstrap. + return null; + } + })(), conversationMode: context.conversationMode === true, agentId: agent.id, workspace: { diff --git a/server/src/services/native-runtime/native-continuation.test.ts b/server/src/services/native-runtime/native-continuation.test.ts new file mode 100644 index 00000000000..dd52896fd29 --- /dev/null +++ b/server/src/services/native-runtime/native-continuation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildNativeContinuationPrompt } from "./native-continuation.js"; + +const issue = { title: "Original task", description: "Retain my long task brief." }; +const message = { id: "new", body: "Yes, please proceed.", authorType: "user", authorId: "board", sourceTrust: "user", createdByRunId: null }; +const wake = { + reason: "issue_commented", + executionContinuation: { + version: 1, + objective: message.body, + messages: [{ ...message, id: "old", body: "OLD_HISTORY" }, message], + resumeDelta: { baseRunId: "prior", messages: [message] }, + completedWork: { summary: "OLD_SUMMARY" }, + humanResponses: [{ id: "old-question", result: { answer: "OLD_ANSWER" } }], + }, + comments: [message], continuationSummary: { markdown: "OLD_SUMMARY" }, +}; +const build = (value: unknown = wake, previousIssue = issue) => buildNativeContinuationPrompt({ wakePayload: value, previousRunId: "prior", issue, previousIssue }); + +describe("native continuation event projection", () => { + it("sends a new comment once without the unchanged brief, objective, history or summary", () => { + const text = build()!; + expect(text.split(message.body)).toHaveLength(2); + for (const old of [issue.description, issue.title, "OLD_HISTORY", "OLD_SUMMARY", "OLD_ANSWER"]) expect(text).not.toContain(old); + expect(JSON.parse(text).messages[0]).toMatchObject({ authorType: "user", body: message.body }); + expect(text.length).toBeLessThan(600); + }); + it("only uses a delta whose base matches the actual previous run", () => { + expect(build({ ...wake, executionContinuation: { ...wake.executionContinuation, resumeDelta: { baseRunId: "different", messages: [message] } } })).toBeNull(); + expect(build({ ...wake, executionContinuation: null })).toBeNull(); + }); + it("delivers the current authenticated answer once, not all prior answers", () => { + const text = build({ ...wake, interactionId: "new-question", executionContinuation: { ...wake.executionContinuation, resumeDelta: { baseRunId: "prior", messages: [] }, humanResponses: [...wake.executionContinuation.humanResponses, { id: "new-question", kind: "ask_user_questions", status: "answered", resolvedByUserId: "board", result: { answer: "NEW_ANSWER" } }] } })!; + expect(text.split("NEW_ANSWER")).toHaveLength(2); + expect(text).not.toContain("OLD_ANSWER"); + expect(JSON.parse(text).humanResponses[0].resolvedByUserId).toBe("board"); + }); + it("includes external child completion and actual brief edits", () => { + const text = build({ ...wake, reason: "issue_children_completed", childIssueSummaries: [{ id: "child", status: "done", summary: "CHILD_RESULT" }] }, { ...issue, description: "old brief" })!; + expect(JSON.parse(text).taskChanges).toEqual({ description: issue.description }); + expect(text).toContain("CHILD_RESULT"); + }); + it.each([{ fallbackFetchNeeded: true }, { recovery: { cause: "interrupted" } }, { externalChatExecutionBound: true }, { reason: "issue_assigned" }])("retains bootstrap framing for special wakes: %j", (extra) => { + expect(build({ ...wake, ...extra })).toBeNull(); + }); +}); diff --git a/server/src/services/native-runtime/native-continuation.ts b/server/src/services/native-runtime/native-continuation.ts new file mode 100644 index 00000000000..ee8714aee17 --- /dev/null +++ b/server/src/services/native-runtime/native-continuation.ts @@ -0,0 +1,43 @@ +import { normalizePaperclipWakePayload } from "@paperclipai/adapter-utils/server-utils"; + +/** Only new, authorized events belong in an already retained provider conversation. + * Full bootstrap input is kept separately for an actual provider resume failure. + */ +export function buildNativeContinuationPrompt(input: { + wakePayload: unknown; + previousRunId: string; + issue: { title: string; description: string | null }; + previousIssue: { title: string; description: string | null }; +}): string | null { + const wake = normalizePaperclipWakePayload(input.wakePayload); + const continuation = wake?.executionContinuation; + const delta = continuation?.resumeDelta; + if (!wake || !delta || delta.baseRunId !== input.previousRunId) return null; + // These paths have specialized delivery/recovery contracts. Preserve their + // existing framing until they have an event-specific continuation projection. + if (!['issue_commented', 'issue_children_completed'].includes(wake.reason ?? '') || + wake.fallbackFetchNeeded || wake.truncated || wake.recovery || continuation?.interruptedRunId || + wake.externalChatExecutionBound || wake.externalChatQuestionResponse || + wake.taskWatchdog || wake.livenessContinuation || wake.activeTreeHold || + wake.skillTest || wake.executionStage || wake.agentMessage || + wake.documentReviewContext || wake.planReviewContext || wake.annotationDeltas.length > 0 + ) return null; + const taskChanges = Object.fromEntries( + (["title", "description"] as const) + .filter((key) => input.issue[key] !== input.previousIssue[key]) + .map((key) => [key, input.issue[key]]), + ); + const humanResponses = (continuation?.humanResponses ?? []).filter((answer) => answer.id === wake.interactionId); + // Do not substitute an unverified interaction outcome for an authorized answer. + if (wake.interactionId && humanResponses.length === 0) return null; + const events = { + messages: delta.messages, + ...(humanResponses.length ? { humanResponses } : {}), + ...(Object.keys(taskChanges).length ? { taskChanges } : {}), + ...(wake.childIssueSummaries.length ? { childResults: wake.childIssueSummaries } : {}), + ...(wake.childIssueSummaryTruncated ? { childResultsTruncated: true } : {}), + ...(wake.unresolvedBlockerIssueIds.length ? { unresolvedBlockerIssueIds: wake.unresolvedBlockerIssueIds } : {}), + }; + if (!events.messages.length && !humanResponses.length && !wake.childIssueSummaries.length && !Object.keys(taskChanges).length) return null; + return JSON.stringify(events); +} diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 27bdf5c082c..4fe577aa1df 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -1,3 +1,4 @@ +import { buildNativeContinuationPrompt } from "./native-continuation.js"; import type { NativeAcpxAgent, NativeAcpxPermissionMode, @@ -45,6 +46,7 @@ export function buildNativeExecutionInput(input: { */ wakePayload?: unknown; resumedSession?: boolean; + previousTurn?: { runId: string; task: { title: string; description: string | null } } | null; conversationMode?: boolean; agentId: string; workspace: { @@ -146,7 +148,7 @@ export function buildNativeExecutionInput(input: { } : input.wakePayload; const wakePrompt = renderPaperclipWakePrompt(wakePayload, { - resumedSession: input.resumedSession === true, + resumedSession: false, conversationMode: input.conversationMode === true, suppressIssueDescription: input.taskPrompt.trim().length > 0, nativeWakeReaderAvailable: true, @@ -168,6 +170,14 @@ export function buildNativeExecutionInput(input: { .join("\n\n"); return parseNativeExecutionInput({ schema: "paperclip.native-execution-input.v4", + ...(input.resumedSession && input.previousTurn && !input.conversationMode ? { + continuationPrompt: buildNativeContinuationPrompt({ + wakePayload: input.wakePayload, + previousRunId: input.previousTurn.runId, + previousIssue: input.previousTurn.task, + issue: input.issue, + }), + } : {}), executionMode, planningContext: input.planningContext ?? null, binding: { diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index 07db32b2ff8..7e8b8383479 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -2598,9 +2598,10 @@ describe("buildNativeExecutionInput wake projection", () => { runtimeContext: nativeRuntimeContextFixture(), }); - expect(input.task.prompt.includes("Execution contract:")).toBe(!conversationMode); - expect(input.task.prompt.includes("Use child issues")).toBe(!conversationMode); - expect(input.task.prompt).toContain("## Paperclip Resume Delta"); + expect(input.task.prompt).not.toContain("Execution contract:"); + expect(input.task.prompt).not.toContain("Use child issues"); + // Full bootstrap stays available if provider recovery fails after admission. + expect(input.task.prompt).toContain("## Paperclip Wake Payload"); expect(input.task.prompt).toContain("reason: issue_children_completed"); expect(input.task.prompt).toContain("DOT-147 Build utility (done)"); expect(input.task.prompt).toContain( diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 7faf0d49dd8..1403d98ad7c 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -742,3 +742,18 @@ pnpm test:e2e:runner:browser-support # To use an installed Chrome instead of Playwright's Chromium: PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome pnpm test:e2e:runner:browser-support ``` + +### Native provider continuity + +The first-task `task-reply-accept` and `task-card-accept` journeys also verify that +ordinary native follow-ups retain the parent task's workspace, native session, +and provider session identities. A generic `sessionReused` flag is insufficient. +The check excludes child runs and applies only to native profiles. + +For ordinary native comment and child-completion wakes, a verified provider resume +receives only new attributed messages, the current authenticated interaction result, +actual task edits, child results, and completion-report identifiers. The provider +retains conversation history. Paperclip retains task state and authorization. A new +or replacement session still receives the full bootstrap; specialized recovery, +review, external-chat and planning paths retain their existing context. Legacy +adapter prompts are unchanged. diff --git a/tests/runner-e2e/first-task-scoring.ts b/tests/runner-e2e/first-task-scoring.ts index 79531d3aec7..39625afb5e3 100644 --- a/tests/runner-e2e/first-task-scoring.ts +++ b/tests/runner-e2e/first-task-scoring.ts @@ -179,6 +179,23 @@ function isPlanningAttachment(a: Row): boolean { function verifiedFirstTaskOutputs(checkpoint: FirstTaskCheckpoint): Row[] { return [...checkpoint.documents, ...(checkpoint.attachments ?? []).filter(isVerifiedAttachment).map(attachmentDocument)]; } +/** Provider identities, not generic sessionReused flags, prove continuity. */ +export function gradeNativeSessionContinuity(runs: Row[], issueId: string): FirstTaskCheck { + const parent = [...new Map(runs.filter((run) => run.nativeIssueId === issueId).map((run) => [run.id, run])).values()]; + const identities = parent.map((run) => ({ + run: run.id, + session: run.nativeSessionId, + provider: run.runnerProfileJson?.sessionCheckpoint?.providerSessionId, + workspace: run.runnerProfileJson?.nativeExecutionInput?.binding?.executionWorkspaceId, + })); + const passed = parent.length >= 2 && ["session", "provider", "workspace"].every((key) => + identities.every((identity) => typeof identity[key as "session"] === "string" && identity[key as "session"].length > 0) && + new Set(identities.map((identity) => identity[key as "session"])).size === 1, + ); + return { id: "native-session-continuity", passed, evidence: ["finished"], + detail: `Same-task follow-ups must retain native/provider/workspace identities: ${JSON.stringify(identities)}` }; +} + export function gradeFirstTask(e: FirstTaskEvidence): FirstTaskCheck[] { const scenario = firstTaskScenario(e.caseId, e.nonce); const checks: FirstTaskCheck[] = []; @@ -440,5 +457,9 @@ export function gradeFirstTask(e: FirstTaskEvidence): FirstTaskCheck[] { "All observed provider runs settled successfully", [last.id], ); + if (e.runtimeSettings?.adapterType === "paperclip_runner" && + ["task-reply-accept", "task-card-accept"].includes(e.caseId) && last.phase === "finished") { + checks.push({ ...gradeNativeSessionContinuity(e.checkpoints.flatMap((checkpoint) => checkpoint.runs), e.onboardingIssueId), evidence: [last.id] }); + } return checks; } diff --git a/tests/runner-e2e/first-task.test.ts b/tests/runner-e2e/first-task.test.ts index 9f7dc5d86dc..d9f961a6025 100644 --- a/tests/runner-e2e/first-task.test.ts +++ b/tests/runner-e2e/first-task.test.ts @@ -20,6 +20,7 @@ import { digestText, snapshotInstruction, gradeFirstTask, + gradeNativeSessionContinuity, firstTaskCompletionSettled, type FirstTaskEvidence, type FirstTaskCheckpoint, @@ -1216,3 +1217,16 @@ describe("accept-while-running overlap evidence", () => { expect(Boolean(check.notReached)).toBe(!overlap); }); }); + + +describe("native provider session continuity", () => { + const row = (id: string) => ({ id, nativeIssueId: "parent", nativeSessionId: "native", usageJson: { sessionReused: true }, runnerProfileJson: { sessionCheckpoint: { providerSessionId: "provider" }, nativeExecutionInput: { binding: { executionWorkspaceId: "workspace" } } } }); + it("accepts stable parent identity, deduplicates checkpoints, and excludes children", () => { + expect(gradeNativeSessionContinuity([row("one"), row("one"), row("two"), { ...row("child"), nativeIssueId: "child", nativeSessionId: "different" }], "parent").passed).toBe(true); + }); + it("rejects a fresh provider despite generic sessionReused metadata", () => { + const next = row("two"); next.runnerProfileJson.sessionCheckpoint.providerSessionId = "fresh"; + expect(gradeNativeSessionContinuity([row("one"), next], "parent").passed).toBe(false); + expect(gradeNativeSessionContinuity([row("one")], "parent").passed).toBe(false); + }); +});