mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(runner): retain provider sessions and send only new continuation events
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4197c9f9c0
commit
3c66c65f9c
14 files changed
+251
-15
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface NativeExecutionInputV3 extends Omit<NativeExecutionInputV2, "sc
|
||||
export interface NativeExecutionInputV4 extends Omit<NativeExecutionInputV3, "schema" | "provider"> {
|
||||
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,
|
||||
|
||||
@@ -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], {
|
||||
|
||||
@@ -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"
|
||||
? {}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user