mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
test: verify necessary free-text questions through one real answer
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d942a5c874
commit
9d322bfa21
12 files changed
+216
-11
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Paperclip evaluation guide
|
||||
|
||||
The explicit-only [necessary free-text question journey](../tests/runner-e2e/README.md#necessary-free-text-question)
|
||||
checks one essential input, the rendered prompt and context, the real answer,
|
||||
one bound continuation, and an exact saved confirmation. Its four development
|
||||
cells use the recorded Codex and Claude models. Registration and calibration do
|
||||
not establish Slack, staging authentication, warm lifecycle, or live qualification.
|
||||
|
||||
The explicit-only [case handoff workflow](../tests/runner-e2e/README.md#persistent-case-handoffs-explicit-only)
|
||||
contains arithmetic and receipt smoke cells for a same-task round trip among three native agents using real case/task
|
||||
APIs, saved documents, handoff receipts, and browser readback. Receipt smoke has
|
||||
|
||||
@@ -156,3 +156,13 @@ through the real service and prepares verified bytes on the reused task root,
|
||||
with only remote clone transport replaced by an owned local repository. The
|
||||
queued sixth campaign was cancelled during its build, before provider execution,
|
||||
when this review finding made that candidate an uninformative paid retry.
|
||||
|
||||
|
||||
The necessary-question journey now extends the existing continuation flow. Four
|
||||
explicit development cells require a visible free-text prompt and context, one
|
||||
real user answer retained on the unchanged question, an exactly bound provider
|
||||
resume or semantic successor, and an independently read exact saved confirmation
|
||||
with a visible link. Calibration preserves the existing continuation and question
|
||||
suites. These cells use production instructions and the recorded models, but
|
||||
retain explicit API-key/per-turn differences and do not count as Slack or staging
|
||||
qualification. Actual provider measurement is still required.
|
||||
@@ -64,6 +64,29 @@ pnpm test:e2e:runner -- --id legacy-conversion.runner-codex.local.convert-and-de
|
||||
pnpm test:e2e:runner -- --id automatic-repository-configured-models.runner-codex.local.automatic-repository-resume --max-automatic-retries 0
|
||||
```
|
||||
|
||||
## Necessary free-text question
|
||||
|
||||
The explicit-only `necessary-question` suite has four development cells: the
|
||||
recorded Codex and Claude models, on local and Daytona environments. It requests
|
||||
one essential booking reference that only the test user knows. The browser must
|
||||
show the actual free-text prompt and its explanation, then submit the real answer.
|
||||
The oracle checks the unchanged question, its server-owned provider or semantic
|
||||
receipt, the exact stored user answer, and one bound continuation. The saved
|
||||
confirmation must contain that answer exactly and have a visible document link.
|
||||
A title, success claim, replacement card, duplicate wake, or uncommitted result
|
||||
cannot establish a pass.
|
||||
|
||||
This extends the existing continuation flow and keeps the older question suites
|
||||
unchanged. It uses production hire instructions, one attempt, one or two provider
|
||||
admissions, and 1,000-cent company and agent budgets. API-key authentication and
|
||||
per-turn lifecycle remain explicit development differences. It does not qualify
|
||||
Slack, staging subscription credentials, warm reuse, or the complete checkpoint.
|
||||
|
||||
```sh
|
||||
pnpm test:e2e:runner -- --list --suite necessary-question
|
||||
pnpm test:e2e:runner -- --id necessary-question.runner-codex.local.necessary-free-text --max-automatic-retries 0
|
||||
```
|
||||
|
||||
## Live provider connection journeys
|
||||
|
||||
See [PROVIDER-CONNECTIONS.md](PROVIDER-CONNECTIONS.md) for the explicit-only
|
||||
|
||||
@@ -211,10 +211,10 @@ describe("runner E2E catalog", () => {
|
||||
expect(localIntegrityTasks).toHaveLength(2);
|
||||
expect(openRouterBreadthTasks).toHaveLength(3);
|
||||
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
|
||||
1, 3, 4, 4, 4, 4, 4, 8, 10, 6, 63, 12, 6, 4, 2, 4, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 3, 116, 2,
|
||||
1, 3, 4, 4, 4, 4, 4, 4, 8, 10, 6, 63, 12, 6, 4, 2, 4, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 3, 116, 2,
|
||||
]);
|
||||
expect(validateRunnerCatalog()).toHaveLength(752);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(752);
|
||||
expect(validateRunnerCatalog()).toHaveLength(756);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(756);
|
||||
expect(
|
||||
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
|
||||
).toHaveLength(48);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { necessaryQuestionTask, necessaryQuestionDigest } from "./necessary-question.js";
|
||||
import { caseHandoffTask, caseHandoffDefinitionDigest } from "./case-handoffs-cases.js";
|
||||
import { caseReceiptTask, caseReceiptDefinitionDigest, CASE_RECEIPT_BUDGET_CENTS, CASE_RECEIPT_MAX_TURNS, CASE_RECEIPT_RUN_TIMEOUT_MS } from "./case-receipt-cases.js";
|
||||
import { TASK_WORKSPACES_SUITE, taskWorkspaceTask, taskWorkspaceProfile, taskWorkspaceDefinitionDigest } from "./task-workspaces-cases.js";
|
||||
@@ -1185,6 +1186,16 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
definitionMetadata: { version: 1, definitionDigest: taskWorkspaceDefinitionDigest(), scheduling: "explicit-only", providerTurns: 3,
|
||||
attempts: 1, budgetMonthlyCents: 1000, project: "none", cwd: "unconfigured-task-default", oracle: "public-file-download-and-host-nested-git", remoteLifecycle: "per_turn" },
|
||||
},
|
||||
{
|
||||
id: "necessary-question", label: "Necessary free-text question and verified confirmation", manualOnly: true,
|
||||
description: "One required private input, a visible question and context, and one exactly bound answer continuation.",
|
||||
groups: [], profiles: repositoryModelProfiles.map(({ native }) => productionDefaultHireProfile(native)),
|
||||
environments: runnerEnvironments, tasks: [necessaryQuestionTask], expectedMatrixSize: 4,
|
||||
definitionMetadata: { version: 1, definitionDigest: necessaryQuestionDigest(), scheduling: "explicit-only", attempts: 1,
|
||||
providerTurns: "1-2; provider pause or one semantic successor", budgetMonthlyCents: 1000, instructions: "production-default",
|
||||
surface: "paperclip", target: "isolated-development-instance", authentication: "api-key", stagingAuthentication: "subscription",
|
||||
remoteLifecycle: "per_turn", stagingQualification: false },
|
||||
},
|
||||
...([false, true] as const).map(conversion => ({
|
||||
id: conversion ? "legacy-conversion" : "configured-repository",
|
||||
label: conversion ? "Legacy conversion with verified delivery" : "Configured repository with verified delivery",
|
||||
|
||||
@@ -10,9 +10,13 @@ export const CONTINUATION_CASES = [
|
||||
"question-tool-documentation",
|
||||
"provider-question-bridge",
|
||||
] as const;
|
||||
export type ContinuationCase = (typeof CONTINUATION_CASES)[number] | "question-answer-resume";
|
||||
export type ContinuationCase = (typeof CONTINUATION_CASES)[number] | "question-answer-resume" | "necessary-free-text";
|
||||
export function necessaryQuestionContract() {
|
||||
return { prompt: "What booking reference should appear on the confirmation?",
|
||||
context: "Only you have the booking reference. I need it to write the confirmation correctly." };
|
||||
}
|
||||
export function continuationScenario(id: string, nonce: string) {
|
||||
if (id !== "question-answer-resume" && !CONTINUATION_CASES.includes(id as (typeof CONTINUATION_CASES)[number]))
|
||||
if (id !== "question-answer-resume" && id !== "necessary-free-text" && !CONTINUATION_CASES.includes(id as (typeof CONTINUATION_CASES)[number]))
|
||||
throw new Error(`Unknown continuation case: ${id}`);
|
||||
const marker = `AMBER${nonce.replace(/[^a-z0-9]/gi, "")}`;
|
||||
const old = `COBALT${nonce.replace(/[^a-z0-9]/gi, "")}`;
|
||||
@@ -22,7 +26,9 @@ export function continuationScenario(id: string, nonce: string) {
|
||||
const document =
|
||||
"Save the final note as a task document. No other deliverables or child tasks are needed.";
|
||||
const questionPrompt = "Help me write a one-sentence welcome note for a club meetup. First let me choose Morning or Afternoon using clickable choices. After I choose, ask me for a reference to include using an open text field. Ask only one question at a time and wait for my answers. Then save the note as a task document, including the selected time and my reference exactly as supplied, and finish. Do not create any other tasks or deliverables.";
|
||||
const necessary = necessaryQuestionContract();
|
||||
const prompts: Record<ContinuationCase, string> = {
|
||||
"necessary-free-text": `Prepare a booking confirmation. The booking reference is required, is known only to me, and has not been supplied. Ask one open text question with exactly this prompt: ${necessary.prompt} Explain this context in the question: ${necessary.context} Wait for my answer. Then save one task document with exactly the text Booking confirmed: followed by a space and my complete answer, unchanged. Deliver its accessible link here and finish. Do not create other tasks or deliverables.`,
|
||||
"question-answer-resume": questionPrompt,
|
||||
"provider-question-bridge": `Use your built-in AskUserQuestion tool (not Paperclip's request_human_input) to ask which reference to include, with two choices: ${marker} and ${old}. Wait for my real answer, then save a one-sentence welcome note including only my selected reference as a task document and finish. No other tasks or deliverables are needed.`,
|
||||
"question-tool-documentation": questionPrompt,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { gradeNecessaryQuestion } from "./necessary-question.js";
|
||||
import { necessaryQuestionContract } from "./continuation-cases.js";
|
||||
import { validResumeQuestionForm } from "./question-resume-scoring.js";
|
||||
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
|
||||
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
|
||||
@@ -107,7 +109,7 @@ export async function runContinuationFlow(input: {
|
||||
return ready;
|
||||
},
|
||||
reject: (state) =>
|
||||
state.runs.length > (scenario.id === "question-answer-resume" ? 3 : 12)
|
||||
state.runs.length > (scenario.id === "necessary-free-text" ? 2 : scenario.id === "question-answer-resume" ? 3 : 12)
|
||||
? "Bounded continuation run count exceeded"
|
||||
: state.runs.some((r) =>
|
||||
["failed", "timed_out", "cancelled"].includes(r.status),
|
||||
@@ -171,6 +173,18 @@ export async function runContinuationFlow(input: {
|
||||
});
|
||||
await input.evidence("api-state.json", checkpoints.at(-1));
|
||||
await open();
|
||||
if (scenario.id === "necessary-free-text") {
|
||||
if (phase === "initial") {
|
||||
const contract = necessaryQuestionContract();
|
||||
await expect(page.getByText(contract.prompt, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(contract.context, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
|
||||
const question = interactions.find(interaction => interaction.kind === "ask_user_questions" && interaction.status === "pending");
|
||||
checkpoints.at(-1)!.questionPresentation = { interactionId: question?.id ?? "", promptVisible: true, contextVisible: true };
|
||||
} else if (phase === "final") {
|
||||
await expect(page.locator('a[href*="#document-"]').last()).toBeVisible({ timeout: 30_000 });
|
||||
checkpoints.at(-1)!.outputLinkVisible = true;
|
||||
}
|
||||
}
|
||||
await captureLoadedContinuation(page, String(issue!.title), () => input.capture(
|
||||
phase,
|
||||
`Continuation: ${phase}`,
|
||||
@@ -186,7 +200,9 @@ export async function runContinuationFlow(input: {
|
||||
);
|
||||
expect(questions, "one real question must be shown").toHaveLength(1);
|
||||
const set = chatQuestionPresentation(questions[0].payload);
|
||||
if (scenario.id === "question-answer-resume") {
|
||||
if (scenario.id === "necessary-free-text") {
|
||||
expect(validResumeQuestionForm(checkpoints[0]!, questions[0], false), "a bound text-only question").toBe(true);
|
||||
} else if (scenario.id === "question-answer-resume") {
|
||||
expect(validResumeQuestionForm(checkpoints.at(-1)!, questions[0], !!choice), "a verified native question path and the requested form").toBe(true);
|
||||
} else if (scenario.id === "provider-question-bridge") {
|
||||
expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true);
|
||||
@@ -294,6 +310,7 @@ export async function runContinuationFlow(input: {
|
||||
runtimeMode: execution.profile.expectedRuntimeMode,
|
||||
});
|
||||
checks.push(...gradeLifecycleBaseline(checkpoints));
|
||||
if (scenario.id === "necessary-free-text") checks.push(...gradeNecessaryQuestion(checkpoints, scenario.answer));
|
||||
if (lifecycleProbe) checks.push(gradeLifecycleNarrative({
|
||||
narrative: lifecycleProbe.narrative,
|
||||
agentId: fixtures.agent.id,
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface ContinuationCheckpoint {
|
||||
documents: Array<{ key: string; body: string; latestRevisionId?: string }>;
|
||||
attachments: unknown[];
|
||||
comments: unknown[];
|
||||
questionPresentation?: { interactionId: string; promptVisible: boolean; contextVisible: boolean };
|
||||
outputLinkVisible?: boolean;
|
||||
interactions: unknown[];
|
||||
runs: Array<{ id: string; status: string; runtimeMode?: string }>;
|
||||
}
|
||||
@@ -50,7 +52,7 @@ export function gradeContinuation(input: {
|
||||
const before = input.checkpoints.filter((c) => c.phase !== "final");
|
||||
check(
|
||||
"recorded-continuation",
|
||||
before.length > 0 && !!final && final.runs.length >= (["provider-question-bridge", "question-answer-resume"].includes(input.id) ? 1 : 2),
|
||||
before.length > 0 && !!final && final.runs.length >= (["provider-question-bridge", "question-answer-resume", "necessary-free-text"].includes(input.id) ? 1 : 2),
|
||||
"Initial and final turns must both be recorded.",
|
||||
);
|
||||
for (const c of before) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { continuationScenario, necessaryQuestionContract } from "./continuation-cases.js";
|
||||
import { gradeNecessaryQuestion } from "./necessary-question.js";
|
||||
import { gradeContinuation, type ContinuationCheckpoint } from "./continuation-scoring.js";
|
||||
import { gradeLifecycleBaseline } from "./lifecycle-baseline.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
function recording(provider = false): any[] {
|
||||
const contract = necessaryQuestionContract(), scenario = continuationScenario("necessary-free-text", "nonce");
|
||||
const card: Row = { id: "card", kind: "ask_user_questions", status: "pending", companyId: "company", issueId: "task", createdByAgentId: "agent",
|
||||
sourceRunId: "first", continuationPolicy: provider ? "none" : "wake_assignee",
|
||||
idempotencyKey: provider ? "paperclip-runner-question:first:request" : "question",
|
||||
payload: { ...(provider ? { runtimeRequestId: "request" } : {}), questionSet: { schema: "paperclip.question_set.v1", description: contract.context,
|
||||
questions: [{ id: "reference", answerMode: "text", prompt: contract.prompt, required: true }] } } };
|
||||
const run: Row = { id: "first", companyId: "company", issueId: "task", agentId: "agent", status: provider ? "running" : "succeeded", runtimeMode: "native",
|
||||
nativePhase: provider ? "running" : "committed", resultJson: { finalizationPhase: provider ? null : "committed", semanticToolReceipts: provider ? {} : {
|
||||
question: { operationId: "request_human_input", result: { disposition: "applied", interaction: structuredClone(card) } },
|
||||
} } };
|
||||
const initial = { phase: "initial", issue: { id: "task", companyId: "company", assigneeAgentId: "agent", status: provider ? "in_progress" : "in_review" },
|
||||
lifecycle: { executionRunId: provider ? "first" : null, scheduledRetry: null, activeRecoveryAction: null, monitorNextCheckAt: null },
|
||||
runs: [run], interactions: [card], documents: [], children: [], attachments: [], comments: [],
|
||||
questionPresentation: { interactionId: "card", promptVisible: true, contextVisible: true } };
|
||||
const final = structuredClone(initial) as Row;
|
||||
final.phase = "final"; final.issue.status = "done"; final.lifecycle.executionRunId = null; final.outputLinkVisible = true;
|
||||
final.interactions[0] = { ...final.interactions[0], status: "answered", resolvedByUserId: "local-board",
|
||||
result: { answers: [{ questionId: "reference", optionIds: [], otherText: scenario.answer }] } };
|
||||
if (provider) { final.runs[0].status = "succeeded"; final.runs[0].nativePhase = "committed"; final.runs[0].resultJson.finalizationPhase = "committed"; }
|
||||
else final.runs.push({ ...structuredClone(run), id: "second", contextSnapshot: { issueId: "task", sourceRunId: "first",
|
||||
interactionId: "card", interactionKind: "ask_user_questions", interactionStatus: "answered" } });
|
||||
final.documents = [{ key: "confirmation", body: `Booking confirmed: ${scenario.answer}`, latestRevisionId: "revision" }];
|
||||
return [structuredClone(initial), final];
|
||||
}
|
||||
const failures = (points: ContinuationCheckpoint[]) => gradeNecessaryQuestion(points, continuationScenario("necessary-free-text", "nonce").answer).filter(check => !check.passed);
|
||||
|
||||
describe("necessary free-text question journey", () => {
|
||||
it.each([false, true])("accepts one exactly bound answer on the provider path=%s", provider => {
|
||||
const points = recording(provider);
|
||||
expect(failures(points)).toEqual([]);
|
||||
expect(gradeContinuation({ ...continuationScenario("necessary-free-text", "nonce"), runtimeMode: "native", checkpoints: points }).filter(check => !check.passed)).toEqual([]);
|
||||
expect(gradeLifecycleBaseline(points).filter(check => !check.passed)).toEqual([]);
|
||||
});
|
||||
it.each([
|
||||
["title without question", (points: Row[]) => { points[0].interactions[0].payload.questionSet.questions[0].prompt = "Booking reference"; }],
|
||||
["missing explanation", (points: Row[]) => { delete points[0].interactions[0].payload.questionSet.description; }],
|
||||
["choice instead of text", (points: Row[]) => { points[0].interactions[0].payload.questionSet.questions[0].answerMode = "single_select"; }],
|
||||
["optional question", (points: Row[]) => { points[0].interactions[0].payload.questionSet.questions[0].required = false; }],
|
||||
["hidden context", (points: Row[]) => { points[0].questionPresentation.contextVisible = false; }],
|
||||
["unrelated rendered card", (points: Row[]) => { points[0].questionPresentation.interactionId = "foreign"; }],
|
||||
["missing semantic receipt", (points: Row[]) => { points[0].runs[0].resultJson.semanticToolReceipts = {}; }],
|
||||
["foreign question", (points: Row[]) => { points[0].interactions[0].companyId = "foreign"; }],
|
||||
["fabricated agent answer", (points: Row[]) => { points[1].interactions[0].resolvedByAgentId = "agent"; }],
|
||||
["wrong answer", (points: Row[]) => { points[1].interactions[0].result.answers[0].otherText = "invented"; }],
|
||||
["duplicate answer", (points: Row[]) => { points[1].interactions[0].result.answers.push(points[1].interactions[0].result.answers[0]); }],
|
||||
["changed question", (points: Row[]) => { points[1].interactions[0].payload.questionSet.questions[0].prompt = "Different question"; }],
|
||||
["duplicate wake", (points: Row[]) => { points[1].runs.push({ ...points[1].runs[1], id: "third" }); }],
|
||||
["wrong wake binding", (points: Row[]) => { points[1].runs[1].contextSnapshot.interactionId = "foreign"; }],
|
||||
["uncommitted result", (points: Row[]) => { points[1].runs[1].resultJson.finalizationPhase = "pending"; }],
|
||||
["wrong saved result", (points: Row[]) => { points[1].documents[0].body = "Claimed complete"; }],
|
||||
["inaccessible result", (points: Row[]) => { points[1].outputLinkVisible = false; }],
|
||||
["duplicate question", (points: Row[]) => { points[1].interactions.push(points[1].interactions[0]); }],
|
||||
] as const)("rejects %s", (_name, change) => { const points = recording(); change(points); expect(failures(points).length).toBeGreaterThan(0); });
|
||||
it("does not put the required answer in the original request", () => {
|
||||
const scenario = continuationScenario("necessary-free-text", "nonce");
|
||||
expect(scenario.prompt).not.toContain(scenario.marker);
|
||||
expect(scenario.prompt).toContain("has not been supplied");
|
||||
});
|
||||
it("registers four explicit development cells without claiming staging or Slack proof", () => {
|
||||
const cells = runnerMatrix.filter(cell => cell.suite.id === "necessary-question");
|
||||
expect(cells).toHaveLength(4);
|
||||
expect(new Set(cells.map(cell => cell.profile.model))).toEqual(new Set(["gpt-6.1-sol", "claude-opus-5-5"]));
|
||||
expect(cells.every(cell => cell.suite.manualOnly && cell.suite.groups.length === 0 && cell.suite.definitionMetadata?.stagingQualification === false && cell.task.automaticRetryPolicy === "single_attempt")).toBe(true);
|
||||
expect(runnerMatrix.filter(cell => cell.suite.id === "question-resume")).toHaveLength(2);
|
||||
expect(runnerMatrix.filter(cell => cell.suite.id === "continuation")).toHaveLength(23);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { continuationScenario, continuationTasks, necessaryQuestionContract } from "./continuation-cases.js";
|
||||
import { questionResumedOnce, validResumeQuestionForm } from "./question-resume-scoring.js";
|
||||
import type { ContinuationCheckpoint } from "./continuation-scoring.js";
|
||||
import type { RunnerTaskFixture } from "./types.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
export const necessaryQuestionTask: RunnerTaskFixture = {
|
||||
...continuationTasks.find(task => task.id === "answer-updates-scope")!,
|
||||
id: "necessary-free-text", label: "Necessary free-text question and one continuation",
|
||||
expectedRunCount: 2, minimumExpectedRunCount: 1, attemptTimeoutMs: { local: 10 * 60_000, daytona: 15 * 60_000 }, turnTimeoutMs: 4 * 60_000,
|
||||
buildTitle: nonce => `Booking confirmation ${nonce}`,
|
||||
buildPrompt: nonce => continuationScenario("necessary-free-text", nonce).prompt,
|
||||
buildVisibleMarker: nonce => continuationScenario("necessary-free-text", nonce).marker,
|
||||
};
|
||||
|
||||
export function gradeNecessaryQuestion(checkpoints: ContinuationCheckpoint[], answer: string) {
|
||||
const initial = checkpoints.find(point => point.phase === "initial"), final = checkpoints.find(point => point.phase === "final");
|
||||
const cards = (point?: ContinuationCheckpoint) => (point?.interactions ?? []).filter((value: any) => value.kind === "ask_user_questions") as Row[];
|
||||
const pending = cards(initial), finished = cards(final), question = pending[0], resolved = finished[0];
|
||||
const field = question?.payload?.questionSet?.questions?.[0], contract = necessaryQuestionContract();
|
||||
const check = (id: string, passed: boolean, detail: string) => ({ id, passed: Boolean(passed), detail });
|
||||
const response = resolved?.result?.answers;
|
||||
return [
|
||||
check("necessary-question.form", checkpoints.length === 2 && !!initial && pending.length === 1 && question?.status === "pending" && initial.documents.length === 0 && initial.attachments.length === 0 &&
|
||||
validResumeQuestionForm(initial, question, false) && field?.required === true && field.prompt === contract.prompt &&
|
||||
[question.payload.questionSet.description, field.helpText].includes(contract.context),
|
||||
"One bound text-only question must show the required prompt and its explanation before delivery."),
|
||||
check("necessary-question.visible", initial?.questionPresentation?.interactionId === question?.id &&
|
||||
initial?.questionPresentation?.promptVisible === true && initial?.questionPresentation?.contextVisible === true,
|
||||
"The browser must observe the actual question and context, not only its title."),
|
||||
check("necessary-question.answer", finished.length === 1 && resolved?.id === question?.id && resolved?.sourceRunId === question?.sourceRunId &&
|
||||
isDeepStrictEqual(question?.payload, resolved?.payload) && resolved?.status === "answered" &&
|
||||
typeof resolved.resolvedByUserId === "string" && !!resolved.resolvedByUserId && !resolved.resolvedByAgentId &&
|
||||
Array.isArray(response) && response.length === 1 && response[0]?.questionId === field?.id && response[0]?.otherText === answer &&
|
||||
!response[0]?.optionIds?.length,
|
||||
"One real user answer must remain attached to the unchanged question."),
|
||||
check("necessary-question.one-continuation", questionResumedOnce(initial, final, question) && !!final && final.runs.length <= 2 &&
|
||||
final.runs.every((run: Row) => run.status === "succeeded" && run.runtimeMode === "native" && run.nativePhase === "committed" && run.resultJson?.finalizationPhase === "committed"),
|
||||
"The answer must resume its bound provider run or admit exactly one bound semantic successor, without duplicate work."),
|
||||
check("necessary-question.result", final?.issue.status === "done" && final.documents.length === 1 &&
|
||||
!!final.documents[0]?.latestRevisionId && final.documents[0]?.body.trim() === `Booking confirmed: ${answer}` && final.outputLinkVisible === true,
|
||||
"The saved confirmation must contain the exact answer and have an accessible link in the rendered task."),
|
||||
];
|
||||
}
|
||||
|
||||
export function necessaryQuestionDigest() {
|
||||
const hash = createHash("sha256");
|
||||
for (const file of ["necessary-question.ts", "continuation-cases.ts", "continuation-flow.ts", "continuation-scoring.ts", "question-resume-scoring.ts", "stock-harness.ts"])
|
||||
hash.update(file).update(readFileSync(new URL(file, import.meta.url)));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
@@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => {
|
||||
expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true);
|
||||
expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false);
|
||||
expect(validateRunnerCatalog()).toHaveLength(752);
|
||||
expect(validateRunnerCatalog()).toHaveLength(756);
|
||||
for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined();
|
||||
expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow();
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ function sameQuestion(a: Row, b?: Row) {
|
||||
function answeredByUser(i?: Row) {
|
||||
return i?.status === "answered" && nonempty(i.resolvedByUserId) && !i.resolvedByAgentId;
|
||||
}
|
||||
function resumedRun(before: ContinuationCheckpoint | undefined, after: ContinuationCheckpoint | undefined, i?: Row) {
|
||||
export function questionResumedOnce(before: ContinuationCheckpoint | undefined, after: ContinuationCheckpoint | undefined, i?: Row) {
|
||||
if (!before || !after || !i) return false;
|
||||
const path = questionPath(before, i);
|
||||
const prior = new Set(before.runs.map(r => r.id));
|
||||
@@ -112,7 +112,7 @@ export function gradeQuestionResume(checkpoints: ContinuationCheckpoint[], marke
|
||||
detail: "The real board answer must remain bound to the unchanged question, with no fabricated or substituted response.",
|
||||
}, {
|
||||
id: `question-resume.${answer}.path`,
|
||||
passed: resumedRun(before, after, i),
|
||||
passed: questionResumedOnce(before, after, i),
|
||||
detail: `Observed path: ${questionPath(before, i) ?? "unknown"}; provider answers resume the same run; semantic answers require one exactly bound response wake.`,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user