mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
132 lines
8.5 KiB
TypeScript
132 lines
8.5 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";
|
|
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 : [];
|
|
|
|
/** Classify by server-owned identities and the applied semantic 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;
|
|
return run.status === "succeeded" && i.continuationPolicy === "wake_assignee" &&
|
|
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) ? "semantic" : 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" || next.length !== 1) 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 either valid 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),
|
|
detail: `Observed path: ${questionPath(before, i) ?? "unknown"}; provider answers resume the same run; semantic answers 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) }));
|
|
}
|