mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path
> - Paperclip helps people manage agent work and keep human decisions
attached to tasks.
> - Product E2E journeys verify the browser, native agent, saved state,
and cleanup.
> - The classic suite already checks a choice question followed by a
text question.
> - Deployment-targeted journeys need the same behavior on an attached
instance.
> - Production questions can use provider input, the dedicated semantic
tool, or the authenticated native API fallback.
> - The basic journey must verify the original card and native
continuation for each supported path.
> - This pull request shares the scenario and browser actions, adds
strict API receipt evidence, and verifies the saved document after
reload.
> - Separate tool-documentation qualification and the original failed
measurements keep their existing scope and outcomes.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
It adds portable coverage for answering agent questions and resuming the
same task on an attached deployment.
**Current behavior**
The classic suite has the two-question scenario. The portable catalog
does not expose it. Its shared native-path grader recognizes provider
and dedicated semantic receipts, but rejects a valid completed native
API creation receipt.
**Proposed behavior**
Expose `question-answer-resume` as a deployment-targeted journey. Answer
one choice question and one text question in the browser. Require
server-owned creation evidence, unchanged forms, exact board answers,
bound continuation, an agent-authored task document, and readback after
reload.
**Reason and benefit**
The same story can qualify local and hosted targets through their normal
provider setup and cleanup. It catches lost answers, duplicate or
foreign work, premature output, and stale documents.
## What Changed
- Register the journey with a three-run maximum and positive model
allowance. Accept one to three attributed work runs at both collection
and retained-result boundaries.
- Share the existing two-question scenario, browser answer actions, and
behavioral grader. Keep separate tool-documentation grading unchanged.
- Recognize a completed native API creation receipt only when its HTTP
201 result retains the original pending card, exact
task/company/agent/source run, idempotency key, and full normalized
form. Require the receipt to remain unchanged after the answer and
through the final checkpoint. Missing, pending, foreign, or altered
proof still fails.
- Check signed-in board ownership, each native continuation, no
premature or extra output, document authorship, and the saved revision
after reload.
- Wait for the authenticated product region before screenshots. Recheck
origin and path after readiness. Bound readiness and capture by the
campaign deadline.
- Add closed stage-specific errors, catalog coverage, sanitized receipt
calibration, and documentation. Include the provider-form and
browser-question presentation helpers in the definition fingerprint.
## Verification
- Fresh QA staging qualification passed at
`7f7de8be286a436010a48af89aba1727b4451493`: all 11 required checks
passed, cleanup passed, and all three native runs used the configured QA
target and model. The [sanitized run
report](https://pages.paperclip.ing/evals-qa-smoke-41121966520-1/)
records the measured pass. This is one successful qualification, not a
reliability claim.
- Final integration head `ae0d9d289831c770a413658d3520fb1dc8ad60e3`
contains the one main merge through
`fd008979f5794fda3d5f54151d36acd31d0de140` and an exact cherry-pick of
the four-file qualified search fix
`4edd10804e2b42941aa2038e90c362c13a0b98dc` from #18370. Patch identity
matches. It preserves the project and search registrations and atomic
search readback. The question live qualification ran at the earlier SHA
above; the final integration head has separate local and CI
verification.
- Eighteen question journey, helper, and test file blobs are identical
between the qualified and integration SHAs. This includes clarification
flow and evidence, question grader and browser helper, the sanitized
receipt fixture, screenshot helper, provider-form helper, browser
question presentation, continuation scenario/readiness/flow, shared
agent validation, and user actions. The question run-count contract and
dispatch branch are also unchanged. Reviewers can compare those paths
directly between the two commits.
- All 622 selected Product E2E, catalog, question-resumption,
necessary-question, report, and readiness tests pass after the main
merge. After the exact search delta, all 145 narrow
question/search/project/contract tests and all nine real Chromium search
regressions pass. Map checks, combined credential-free preview, source
fingerprints, and the earlier workspace-link preflight pass. The local
TypeScript attempt reports stale Runner declaration errors for
`onAccountingEvent` in unchanged native-session code; fresh official CI
provides the clean-build type gate. The three search source/test blobs
match the qualified search merge, and the three project source blobs are
unchanged.
- An in-memory probe executes the actual launcher fingerprint
expression. Each project, search, and question implementation input
changes the digest. Separate red probes first reproduced the omitted
provider-form and browser presentation helpers; both pass after their
source inputs are added.
- The sanitized retained native API response-shape regression fails
before the grading correction and passes afterward. Negative controls
reject missing, pending, foreign, changed, spoofed, or wrongly bound
receipts, answers, continuation, model, environment, and document
evidence. All nine provider/semantic/API path combinations pass
calibration.
- The real local Chromium screenshot probe fails before the readiness
repair and passes afterward. Login redirects and sign-in pages at task
URLs remain excluded with no screenshot files.
- The two original attached failures remain retained: the first stopped
at early screenshot capture; the second stopped before answering at the
former API provenance gate. An earlier sandbox run also retained one
report subprocess failure; the full selected tests then passed with
local subprocess IPC access.
- The prior integration CI had an unchanged canary mock `EADDRINUSE`
failure. That history is retained; its documented retry is owned by the
coordinator. No application API, schema, production UI, model, runtime
policy, credential, or deployment configuration change is included.
Final integration head ae0d9d2898 passed
all 58 CI checks, the external Snyk status, and fresh Greptile 5/5 with
no unresolved findings. This clean CI includes the official TypeScript
and build gates. Recurring selection follows merge and a separate normal
TestOps job verification.
## Risks
An API result must prove native creation; form shape and run count
cannot establish it. The grader checks the server receipt, exact scope,
unchanged card and receipt, real board answers, and bound native
continuation. The shared answer helper still serves classic journeys.
The screenshot wait affects attached journeys and remains limited to the
authenticated product region and campaign deadline. One live QA pass
establishes the measured journey outcome at the qualified SHA. It does
not establish reliability across models or deployments. The integration
SHA preserves its journey and helper blobs, while shared registry
changes receive fresh CI and review.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning, source inspection, 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 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
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
155 lines
10 KiB
TypeScript
155 lines
10 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 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 && validResumeQuestionForm(middle, second, false),
|
|
detail: "One choice plus only a verified provider Other companion, then a distinct text-only question.",
|
|
}];
|
|
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) }));
|
|
}
|