Files
PaperClipAI/tests/runner-e2e/question-resume-scoring.ts
T
Dotta 3937215a05 Qualify explicit native written references in the basic question journey (#18459)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Basic task journeys must prove that people can answer an agent and
receive saved work.
> - The question journey asks for a choice, then a typed reference, then
a saved note.
> - A native choice card can offer a real written-answer control as well
as predefined options.
> - The old observer rejected that form before a person could type the
reference.
> - This pull request accepts that explicit native control and keeps
exact answer and work evidence.
> - The separate text-only and named-tool qualifications keep their
stricter contracts.

## Linked Issues or Issue Description

Refs #18346 and #18456. The integration inherits the reviewed fixture
prerequisite #18387 and Agent Chat recovery #18465 through their actual
merged parent. The producer fix in #18456 is separate from this basic
journey contract correction. Public duplicate PR and issue searches
found no other correction for this form equivalence.

**What happened?**
The basic question journey requires the reference card to have
`answerMode: text`. It rejects a native single-select card even when its
canonical form explicitly enables a written answer. This makes the
observer test a widget shape in addition to the requested behavior.

**Expected behavior**
The person must make a real choice and type the complete reference. A
native card with an explicit written-answer control can satisfy the
second step. Selecting a predefined option cannot satisfy it. The agent
must resume the bound work and save both actual answers in its own task
document. The document must survive reload.

**Steps to reproduce**
Use the focused recording and real QuestionForm browser test in this PR.
Keep the reference as a single-select native question with one
predefined option and `customAnswer.enabled: true`. The previous grader
rejects `question-resume.forms`. The previous browser helper rejects
`single_select` before it uses the written-answer control.

**Paperclip version / commit**
Original source base `f2db909393de124caf6a5380a52d4e0e1d621661`. Current
integration `a417fd827347ed6f7998774a9de6aee70b76d867` merges exactly
`12b0cc630471ee151ec5bc8e41a34f09236d6723` into reviewed question source
`faec4ce6d775a34d303d200987e4e84d6dc26caf`. The scheduled failure on
`82d15946c5844d3ae41c6ade62fa224053f20a9a` remains a failure. Its saved
card had no canonical written-answer capability and would still fail
this change. The retained normalized events do not prove whether the
original provider flag was omitted or false.

**Deployment mode**
Attached native task journeys, including QA Cloud. This PR changes the
observer and grader. It does not change the app, provider prompt, model,
budget, timeouts, cleanup, or runtime deployment.

## What Changed

- Accept one provider-bound single-select reference card with explicit
canonical custom answers and valid, distinct options. Keep pure-text
reference support.
- Make the browser select the actual written-answer control and fill the
real rich input. Opt in only for the basic question journey.
- Keep the exact no-option typed response, immutable card, board actor,
native request and continuation, saved agent document, reload, model,
environment, checkpoints, and existing one-to-three-run checks.
- Reject missing or disabled custom capability, inferred legacy
capability, malformed or extra questions, foreign native identities,
changed cards, substituted options, and wrong or missing reference text.
- Add a real production QuestionForm and TaskChatRichInput Chromium
probe. It first selects a predefined option, then proves the helper
clears it and submits only the exact custom text.
- Document the contract correction and preserve the historical failed
attempt and its separate cleanup outcome. Keep necessary-question and
named-tool documentation qualification unchanged.

## Verification

- Before the observer change, the focused grader regression failed only
the new valid-written-reference case; the other 108 cases passed.
- Before the browser helper change, real Chromium passed the text
control and four rejection controls but failed the valid native
written-answer control at the old pure-text assertion.
- Initial focused Runner E2E unit selection at `af07f4de`: **256 passed
across 9 files**. This includes question/resumption, clarification
evidence/flow/collection/deadline, necessary question, documentation,
contracts, and report catalog tests.
- Initial real Chromium selection at `af07f4de`: **12 passed**. Six new
production QuestionForm/control checks and all six existing
document-readiness checks pass. No form or rich-input component is
mocked.
- Historical test-only head `faec4ce6d775a34d303d200987e4e84d6dc26caf`
adds a direct canonical-absent legacy `freeText` grader negative and
renders the actual legacy presentation in the browser. All four
production helper blobs remain identical to the reviewed head. A
temporary legacy inference mutant makes the new negative fail. After
restoring production source, **179 focused unit tests and all 6 real
QuestionForm checks pass**. The six document-readiness specs and their
helper are unchanged.
- Original [CI run
41205748589](https://github.com/paperclipai/paperclip/actions/runs/41205748589)
failed the staged-listen Runner handoff test; that failure is preserved
and was not retried. Its Runner source closure was identical to an
earlier passing canonical lane. The symptom also recurred on other heads
and is tracked independently; no infrastructure attribution or live pass
is claimed.
- Current integration `a417fd827347ed6f7998774a9de6aee70b76d867`
preserves **all 12 question files byte-for-byte** from faec. Every other
tracked path is identical to exact merged parent
`12b0cc630471ee151ec5bc8e41a34f09236d6723`, which contains actual
prerequisite `baf651c8d0122efc93f3ef03e891919eeb666215`. There is no
manual conflict resolution or additional production edit.
- On that integrated head: **257 focused Vitest tests across 9 files,
206 required Node source/reporter tests, and 12 real Chromium tests
pass**. Map/module-boundary checks and catalog discovery pass. The
actual `--preview` command passes with a synthetic question target and
an intentionally unavailable adapter; it performs no credential read,
adapter loading, provisioning or model call. It retains maxRuns3, a
600000ms campaign bound and zero retries.
- Exact-head [CI run
41210806950](https://github.com/paperclipai/paperclip/actions/runs/41210806950)
passes, including official typecheck, build, all eight browser shards,
both Runner Vitest lanes and aggregate verify. All 59 current PR
contexts are terminal with no failures. Fresh [Greptile
summary](https://github.com/paperclipai/paperclip/pull/18459#issuecomment-6543878408)
explicitly reviews `a417fd827347ed6f7998774a9de6aee70b76d867` at 5/5
with no findings; its exact-head check succeeds and all threads are
resolved. No fresh deployed journey pass is claimed yet.
- Catalog discovery lists the existing question journey. Map checks and
module-boundary checks pass. Diff checks pass.
- `tsc -p tests/runner-e2e/tsconfig.json` was attempted. The fresh
integrated checkout still lacks compiled Runner, plugin SDK, and adapter
declarations. The attempted local check reports 344 diagnostic lines
from dependency/type closure and zero diagnostics in all 12
question-owned paths. Official exact-head CI establishes the full
typecheck/build gates. Full repository build, typecheck, and test suites
were not repeated locally for this observer change.
- The first broad local report selection lacked the CLI loader and then
hit sandbox IPC restrictions. Locked dependency installation and an
authorized local IPC run resolved those setup errors; the full selected
256 tests pass. The initial localhost probe also needed the normal
local-listen permission.
- No fresh QA pass is claimed. The coordinator must qualify the final
reviewed runtime and harness together. Prior failed results remain
preserved.

## Risks

The basic journey accepts one additional form with explicit native
provenance. It still rejects the original closed card without a
written-answer capability. The separate producer default fix must reach
the selected remote provider pack before it can affect a deployed run.
Local browser and grader tests do not prove that artifact served or that
a model completed the live journey. Existing cleanup and physical-stop
obligations remain separate.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, source inspection, browser
testing, and code execution. The host does not expose the exact
underlying model version or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with available version and
capability details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
related work above
- [x] I have described the issue in-PR following the bug template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket or instance-derived details
- [x] I have run the selected tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-10-31 13:16:40 -05:00

164 lines
11 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import type { ContinuationCheckpoint } from "./continuation-scoring.js";
import { isSingleClaudeQuestion } from "./runtime-question-readiness.js";
type Row = Record<string, any>;
export type QuestionPath = "provider" | "semantic" | "api";
const nonempty = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
const questions = (c?: ContinuationCheckpoint): Row[] =>
(c?.interactions ?? []).filter((i: any) => i.kind === "ask_user_questions") as Row[];
export const resumeQuestionFields = (i?: Row): Row[] =>
Array.isArray(i?.payload?.questionSet?.questions) ? i.payload.questionSet.questions : [];
function nativeApiQuestionReceipt(run: Row | undefined, question: Row) {
const hash = (value: unknown) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
return Object.entries(run?.resultJson?.apiToolReceipts ?? {}).find(([key, value]) => {
const receipt = value as Row, original = receipt?.result?.data;
return hash(key) && hash(receipt?.digest) && receipt?.state === "completed"
&& receipt.operationId === "POST /api/issues/{id}/interactions" && receipt.result?.operationId === receipt.operationId
&& receipt.result.ok === true && receipt.result.status === 201 && original?.status === "pending"
&& original.kind === "ask_user_questions" && original.createdByUserId === null && question.createdByUserId === null
&& sameQuestion(original, question);
});
}
function sameNativeApiQuestionReceipt(before: ContinuationCheckpoint | undefined, after: ContinuationCheckpoint | undefined, question?: Row) {
if (!question) return false;
const receipt = nativeApiQuestionReceipt(before?.runs.find(run => run.id === question.sourceRunId), question);
return !!receipt && isDeepStrictEqual(receipt,
nativeApiQuestionReceipt(after?.runs.find(run => run.id === question.sourceRunId), question));
}
/** Classify by server-owned identities and an applied native mutation receipt. A
* missing receipt is unknown, never inferred from form shape or run count. */
export function questionPath(checkpoint: ContinuationCheckpoint | undefined, i?: Row): QuestionPath | null {
if (!checkpoint || !i || !nonempty(i.id) || !nonempty(i.sourceRunId) || !nonempty(i.idempotencyKey)) return null;
const matches = checkpoint.runs.filter(r => r.id === i.sourceRunId) as Row[];
const run = matches.length === 1 ? matches[0] : undefined;
if (!run || run.runtimeMode !== "native" || !nonempty(run.companyId) ||
run.companyId !== (checkpoint.issue as Row).companyId ||
i.companyId !== run.companyId || i.issueId !== checkpoint.issue.id || run.issueId !== i.issueId ||
!nonempty(checkpoint.issue.assigneeAgentId) || i.createdByAgentId !== checkpoint.issue.assigneeAgentId ||
run.agentId !== checkpoint.issue.assigneeAgentId) return null;
const request = i.payload?.runtimeRequestId;
if (request !== undefined) {
return nonempty(request) && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(request) &&
i.idempotencyKey === `paperclip-runner-question:${run.id}:${request}` &&
i.continuationPolicy === "none" && run.status === "running" ? "provider" : null;
}
const receipt = run.resultJson?.semanticToolReceipts?.[i.idempotencyKey];
const created = receipt?.result?.interaction;
if (run.status !== "succeeded" || i.continuationPolicy !== "wake_assignee") return null;
if (
receipt?.operationId === "request_human_input" && receipt.result?.disposition === "applied" &&
created?.id === i.id && created?.sourceRunId === run.id && created?.issueId === i.issueId &&
created?.companyId === i.companyId && created?.kind === "ask_user_questions" &&
isDeepStrictEqual(created?.payload?.questionSet, i.payload?.questionSet)) return "semantic";
return nativeApiQuestionReceipt(run, i) ? "api" : null;
}
export function validResumeQuestionForm(checkpoint: ContinuationCheckpoint, i: Row, choice: boolean): boolean {
const path = questionPath(checkpoint, i);
const fields = resumeQuestionFields(i);
if (!path || fields.some(f => !nonempty(f.id)) || new Set(fields.map(f => f.id)).size !== fields.length) return false;
if (!choice) return fields.length === 1 && fields[0].answerMode === "text" &&
!fields[0].options?.length && !fields[0].customAnswer;
return fields[0]?.answerMode === "single_select" &&
(fields.length === 1 || (path === "provider" && isSingleClaudeQuestion(fields)));
}
export function validResumeReferenceForm(checkpoint: ContinuationCheckpoint, i: Row): boolean {
if (validResumeQuestionForm(checkpoint, i, false)) return true;
const fields = resumeQuestionFields(i), field = fields[0], options: Row[] = Array.isArray(field?.options) ? field.options : [];
return questionPath(checkpoint, i) === "provider" && fields.length === 1 && nonempty(field.id)
&& field.answerMode === "single_select" && field.customAnswer?.enabled === true
&& options.length > 0 && options.every(option => nonempty(option.id) && nonempty(option.label))
&& new Set(options.map(option => option.id)).size === options.length;
}
export function sameQuestion(a: Row, b?: Row) {
return !!b && ["id", "kind", "sourceRunId", "issueId", "companyId", "createdByAgentId", "idempotencyKey", "continuationPolicy"]
.every(k => a[k] === b[k]) && isDeepStrictEqual(a.payload, b.payload);
}
function answeredByUser(i?: Row) {
return i?.status === "answered" && nonempty(i.resolvedByUserId) && !i.resolvedByAgentId;
}
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));
const next = after.runs.filter(r => !prior.has(r.id)) as Row[];
if (prior.size !== before.runs.length || new Set(after.runs.map(r => r.id)).size !== after.runs.length ||
before.runs.some(r => !after.runs.some(n => n.id === r.id))) return false;
if (path === "provider") return next.length === 0 &&
after.runs.some(r => r.id === i.sourceRunId && ["running", "succeeded"].includes(r.status));
if ((path !== "semantic" && path !== "api") || next.length !== 1) return false;
if (path === "api" && !sameNativeApiQuestionReceipt(before, after, i)) return false;
const context = next[0].contextSnapshot;
return context?.issueId === i.issueId && context?.sourceRunId === i.sourceRunId &&
context?.interactionId === i.id && context?.interactionKind === "ask_user_questions" &&
context?.interactionStatus === "answered";
}
/** Behavioral qualification supports each verified native path. It does not
* replace the separate documentation-placement/semantic-tool qualification. */
export function gradeQuestionResume(checkpoints: ContinuationCheckpoint[], marker: string) {
const initial = checkpoints.find(c => c.phase === "initial");
const middle = checkpoints.find(c => c.phase === "answered");
const final = checkpoints.find(c => c.phase === "final");
const first = questions(initial)[0];
const second = questions(middle).find(i => i.status === "pending");
const choice = resumeQuestionFields(first)[0];
const text = resumeQuestionFields(second)[0];
const options: Row[] = choice?.options ?? [];
const afternoon = options.find(o => /^afternoon\b/i.test(String(o.label).trim()));
const checks = [{
id: "question-resume.forms",
passed: !!initial && !!middle && questions(initial).length === 1 && first?.status === "pending" &&
validResumeQuestionForm(initial, first, true) && options.length >= 2 &&
options.every(o => nonempty(o.id)) && new Set(options.map(o => o.id)).size === options.length &&
options.some(o => /^morning\b/i.test(String(o.label).trim())) && !!afternoon &&
questions(middle).length === 2 && questions(middle).filter(i => i.status === "pending").length === 1 &&
nonempty(second?.id) && second?.id !== first?.id && validResumeReferenceForm(middle, second),
detail: "One choice plus only a verified provider Other companion, then a distinct typed reference with an explicit provider written-answer control or a text field.",
}];
for (const [before, after, i, field, answer] of [
[initial, middle, first, choice, "choice"], [middle, final, second, text, "text"],
] as const) {
const committed = questions(after).filter(q => q.id === i?.id);
const retained = questions(final).filter(q => q.id === i?.id);
const response: Row[] = committed[0]?.result?.answers ?? [];
const primary = response.filter(a => a.questionId === field?.id);
const optionalIds = resumeQuestionFields(i).slice(1).map(f => f.id);
const validAnswer = primary.length === 1 && (answer === "choice"
? primary[0].optionIds?.length === 1 && primary[0].optionIds[0] === afternoon?.id && !primary[0].otherText
: !primary[0].optionIds?.length && primary[0].otherText === `Use ${marker}.`);
checks.push({
id: `question-resume.${answer}.answer`,
passed: !!i && committed.length === 1 && retained.length === 1 &&
sameQuestion(i, committed[0]) && sameQuestion(i, retained[0]) && answeredByUser(committed[0]) &&
answeredByUser(retained[0]) && isDeepStrictEqual(committed[0].result, retained[0].result) &&
validAnswer && response.every(a => a.questionId === field?.id ||
(optionalIds.includes(a.questionId) && !a.optionIds?.length && !a.otherText)),
detail: "The real board answer must remain bound to the unchanged question, with no fabricated or substituted response.",
}, {
id: `question-resume.${answer}.path`,
passed: questionResumedOnce(before, after, i) &&
(questionPath(before, i) !== "api" || sameNativeApiQuestionReceipt(before, final, i)),
detail: `Observed path: ${questionPath(before, i) ?? "unknown"}; provider answers resume the same run; durable native questions require one exactly bound response wake.`,
});
}
checks.push({
id: "question-resume.complete-evidence",
passed: checkpoints.length === 3 && !!initial && !!middle && !!final && initial.runs.length === 1 &&
questions(final).length === 2 && final.runs.length >= 1 && final.runs.length <= 3 &&
final.runs.every((r: Row) => r.companyId === (final.issue as Row).companyId &&
r.issueId === final.issue.id && r.agentId === final.issue.assigneeAgentId &&
typeof r.runnerProfileJson?.nativeExecutionInput?.task?.prompt === "string") &&
final.documents.length === 1 && !!final.documents[0].latestRevisionId &&
final.documents[0].body.includes(`Use ${marker}.`) && /\bafternoon\b/i.test(final.documents[0].body),
detail: "Retain all three checkpoints, every native input and exactly one saved document containing the choice and the complete submitted reference.",
});
return checks.map(c => ({ ...c, passed: Boolean(c.passed) }));
}