mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Merge current journey candidate into the qualification checkpoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
09a45d29bb
146 files changed
+6892
-1264
No files matched your search
@@ -98,6 +98,71 @@ 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. The final reply's link must open
|
||||
that same task document and display its confirmation.
|
||||
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
|
||||
```
|
||||
|
||||
## Latency observation bounds
|
||||
|
||||
The explicit `codie-latency-diagnostic` arms its observation recorder before
|
||||
dispatching task creation and measures elapsed time with `performance.now()`.
|
||||
The first browser read starts before dispatch; subsequent reads run every 100 ms
|
||||
without overlapping. The observer stops and drains before fixture teardown,
|
||||
with one one-second deadline for its pending and final reads. On expiry, it
|
||||
records `observationTimeouts` and a failed observation for each waiting probe,
|
||||
starts no more reads, and ignores late completions. Fixture cleanup owns the
|
||||
browser operation that can still be running after that deadline.
|
||||
It retains `latencyProbes` in the existing result JSON and dashboard, separately
|
||||
for the first visible substantive response and final task/run settlement, with
|
||||
an inclusive 30-second target. Platform-generated receipts do not count as the
|
||||
substantive response. Browser polls bound persistent visible output; this does
|
||||
not measure Slack delivery or qualify transient output between observations.
|
||||
These timing verdicts are diagnostic dimensions and do not replace the existing
|
||||
workflow matchers or rewrite the original attempt grade.
|
||||
|
||||
`latency-timing.ts` is shared by response and acknowledgement timing evidence.
|
||||
The browser adapter samples both from one snapshot when an acknowledgement
|
||||
target is explicitly configured; the Daytona diagnostic does not enable that
|
||||
feature or infer a missing Slack acknowledgement from its browser observations.
|
||||
An absent observation and a present observation give `(lastAbsent, firstPresent]`;
|
||||
an asynchronous absent read uses its start time, while a present read uses its
|
||||
completion time. Each bound is widened by the declared clock resolution.
|
||||
Targets specify `<` or `<=`; open and closed endpoints determine equality.
|
||||
Pass requires the entire interval to satisfy the target, fail requires the
|
||||
entire interval to violate it, and all other cases are inconclusive. Missing
|
||||
observations remain null; unknown resolution and invalid/empty intervals cannot
|
||||
produce a conclusive verdict. Failed reads are not absent observations.
|
||||
|
||||
The historical acknowledgement `(1901, 2427] ms` is inconclusive against
|
||||
`<= 2000 ms`; the reply `(52965, 53334] ms` fails `<= 30000 ms`. These are
|
||||
deterministic calibration examples, not fresh live measurements. Provider
|
||||
message creation/edit timestamps remain separate metadata; an edit can expose
|
||||
new response text on an older message. Neither those timestamps nor final run
|
||||
settlement substitutes for first visible response. The legacy warm-turn
|
||||
`responseLatencyMs` field continues to mean final settlement and is labelled
|
||||
accordingly. Report regeneration recomputes interval verdicts, retains changed
|
||||
`recordedVerdict` values and all bounds, and leaves the original files intact.
|
||||
|
||||
## 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";
|
||||
@@ -1173,7 +1174,8 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
tasks: runnerTasks.filter(task => task.id === "message-marker")
|
||||
.map(task => ({ ...task, automaticRetryPolicy: "single_attempt" as const })),
|
||||
expectedMatrixSize: 1,
|
||||
definitionMetadata: { diagnostic: true, modelSource: "observed-staging-run", providerAuth: "api_key", stagingAuth: "subscription", companyData: "fresh-fixture", slackDelivery: false, maximumAutomaticRetries: 0 },
|
||||
definitionMetadata: { diagnostic: true, modelSource: "observed-staging-run", providerAuth: "api_key", stagingAuth: "subscription", companyData: "fresh-fixture", slackDelivery: false, maximumAutomaticRetries: 0,
|
||||
timing: { version: 1, clock: "monotonic", responseTargetMs: 30_000, event: "first_visible_response", settlementSeparate: true } },
|
||||
},
|
||||
{
|
||||
id: TASK_WORKSPACES_SUITE, label: "Task workspaces without projects", manualOnly: true,
|
||||
@@ -1185,6 +1187,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,34 @@ 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") {
|
||||
expect(documents).toHaveLength(1);
|
||||
const document = documents[0]!;
|
||||
expect(document.latestRevisionId).toBeTruthy();
|
||||
const href = `/${encodeURIComponent(fixtures.company.issuePrefix!)}/issues/${encodeURIComponent(issue!.identifier ?? issue!.id)}#document-${encodeURIComponent(document.key)}`;
|
||||
const url = new URL(href, page.url()).href;
|
||||
const link = page.getByTestId("task-chat-agent-bubble").locator(`a[href=${JSON.stringify(href)}]`).last();
|
||||
await expect(link).toBeVisible({ timeout: 30_000 });
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(url);
|
||||
const target = page.locator([
|
||||
`[id=${JSON.stringify(`document-${document.key}`)}]:visible`,
|
||||
`[id=${JSON.stringify(`side-panel-content-document:${document.key}`)}]:visible`,
|
||||
].join(", "));
|
||||
await expect(target).toHaveCount(1);
|
||||
await expect(target).toBeVisible();
|
||||
const visibleBody = `Booking confirmed: ${scenario.answer}`;
|
||||
await expect(target).toContainText(visibleBody);
|
||||
checkpoints.at(-1)!.outputDocument = { issueId: issue!.id, key: document.key, revisionId: document.latestRevisionId, href, visibleBody };
|
||||
}
|
||||
}
|
||||
await captureLoadedContinuation(page, String(issue!.title), () => input.capture(
|
||||
phase,
|
||||
`Continuation: ${phase}`,
|
||||
@@ -186,7 +216,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 +326,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 };
|
||||
outputDocument?: { issueId: string; key: string; revisionId: string; href: string; visibleBody: string };
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, durationLabel, tokenLabel, llmCostLabel, usdLabel } from "./repor
|
||||
import { renderReportShell, reportNavigationStyles, reportShellScript } from "./report-navigation.js";
|
||||
import { reportBrowserScript } from "./report-browser.js";
|
||||
import { isIncompleteFirstTaskResult, renderCaseOutcome } from "./case-outcome.js";
|
||||
import { latencyIntervalLabel } from "./latency-timing.js";
|
||||
import { campaignSuiteSummaryStyles, renderCampaignSuiteSummary, renderSuiteCaseIndex } from "./campaign-summary.js";
|
||||
import { journeyCapabilities, journeyFeatureIds } from "./journey-features.js";
|
||||
import { journeyFeatureStyles, renderJourneyFeatureCoverage, renderJourneyFeatureOptions } from "./feature-coverage.js";
|
||||
@@ -217,6 +218,11 @@ function renderCase(
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const latencyRows = (entry?.result.latencyProbes ?? []).map(probe => `<tr>
|
||||
<td>${html(probe.event)}</td><td>${html(probe.verdict)}</td>
|
||||
<td>${html(latencyIntervalLabel(probe))}</td><td>${html(`${probe.target.operator} ${probe.target.ms} ms`)}</td>
|
||||
<td>${html(probe.resolutionMs === null ? "unknown" : `${probe.resolutionMs} ms`)}</td>
|
||||
</tr>`).join("");
|
||||
const startupReuseRows = (entry?.result.startupReuse ?? (entry?.result.runIds ?? []).map(runId => ({
|
||||
runId, ...summarizeStartupReuse([]),
|
||||
}))).map(reuse => `<tr>
|
||||
@@ -310,7 +316,8 @@ function renderCase(
|
||||
: ""
|
||||
}
|
||||
<p class="detail">${html(detail)}</p>
|
||||
${turnTimingRows ? `<div class="matcher-wrap"><table class="matchers"><thead><tr><th>Turn</th><th>Run</th><th>Lease</th><th>Scheduler</th><th>Run duration</th><th>Response</th></tr></thead><tbody>${turnTimingRows}</tbody></table></div>` : ""}
|
||||
${turnTimingRows ? `<div class="matcher-wrap"><table class="matchers"><thead><tr><th>Turn</th><th>Run</th><th>Lease</th><th>Scheduler</th><th>Run duration</th><th>Final settlement (legacy response field)</th></tr></thead><tbody>${turnTimingRows}</tbody></table></div>` : ""}
|
||||
${latencyRows ? `<div class="matcher-wrap"><table class="matchers"><thead><tr><th>Event</th><th>Timing verdict</th><th>Possible latency</th><th>Target</th><th>Resolution</th></tr></thead><tbody>${latencyRows}</tbody></table></div>` : ""}
|
||||
${startupReuseRows ? `<div class="matcher-wrap"><table class="matchers"><caption>Startup reuse evidence</caption><thead><tr><th>Run</th><th>Warmth</th><th>Environment/lease</th><th>Workspace</th><th>Runner</th><th>Provider process</th><th>Provider session</th><th>Provider launches</th></tr></thead><tbody>${startupReuseRows}</tbody></table></div>` : ""}
|
||||
${matcherRows ? `<div class="matcher-wrap"><table class="matchers"><thead><tr><th>Result</th><th>Matcher</th><th>Expectation</th><th>Detail</th></tr></thead><tbody>${matcherRows}</tbody></table></div>` : `<p class="detail">No matcher result was recorded.</p>`}
|
||||
${entry ? `<details class="usage"><summary>Usage and billing metadata</summary><pre>${html(JSON.stringify({ billing, rawUsage: entry.result.usage ?? null }, null, 2))}</pre></details>` : ""}
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("extended ACP harness qualification", () => {
|
||||
const server = buildPaperclipServerEnvironment(env);
|
||||
for (const name of Object.keys(ambient).filter(name => name !== "PAPERCLIP_RUNNER_ACPX_QUALIFICATION")) expect(server[name]).toBeUndefined();
|
||||
expect(buildRunnerE2EProcessEnvironment(ambient, []).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined();
|
||||
const pending = selected.find(value => value.profile.qualificationCandidate === "copilot")!;
|
||||
expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...pending, suite: { ...pending.suite, manualOnly: false } }])).toThrow("explicit");
|
||||
const copilot = selected.find(value => value.profile.qualificationCandidate === "copilot")!;
|
||||
expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...copilot, suite: { ...copilot.suite, manualOnly: false } }])).toThrow("explicit");
|
||||
});
|
||||
it("adds six explicit local/Daytona warm cells without admitting them implicitly", () => {
|
||||
const warm = runnerMatrix.filter(cell => cell.suite.id === "rich-acp-warm-continuity");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isIncompleteFirstTaskResult } from "./case-outcome.js";
|
||||
import { classifyLatency } from "./latency-timing.js";
|
||||
import { summarizeStartupReuse } from "../../packages/adapter-utils/src/startup-reuse.js";
|
||||
import { validateRetainedRunnerResult } from "./result-validation.js";
|
||||
import {
|
||||
@@ -29,6 +30,11 @@ export function canonicalExecutionId(id: string) {
|
||||
|
||||
export function upgradeRunnerResult(result: RunnerE2EResult): RunnerE2EResult {
|
||||
validateRetainedRunnerResult(result);
|
||||
if (result.latencyProbes) result = { ...result, latencyProbes: result.latencyProbes.map(probe => {
|
||||
const corrected = classifyLatency(probe, probe.target);
|
||||
return probe.verdict === corrected.verdict ? corrected
|
||||
: { ...corrected, recordedVerdict: probe.recordedVerdict ?? probe.verdict };
|
||||
}) };
|
||||
result = { ...result, startupReuse: result.startupReuse ?? (result.runIds ?? []).map(runId => ({
|
||||
runId, ...summarizeStartupReuse([]),
|
||||
})) };
|
||||
|
||||
@@ -60,7 +60,7 @@ it("admits only the 20 agreed Copilot cells through normal installed resolution
|
||||
expect(() => assertInstalledCliSelection(env, [{ ...cell, id: `${cell.id}-unreviewed` }])).toThrow("explicit supported");
|
||||
}
|
||||
});
|
||||
it("current qualified providers drop ambient admission, including explicit Copilot cells", () => {
|
||||
it("qualified providers drop ambient admission without granting custom model overrides", () => {
|
||||
for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined();
|
||||
for (const agent of ["copilot"]) {
|
||||
const cell = runnerMatrix.find(e => e.profile.qualificationCandidate === agent)!;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { armLatencyProbe, classifyLatency, observationTiming } from "./latency-timing.js";
|
||||
|
||||
const target = { operator: "<=" as const, ms: 2_000 };
|
||||
const observed = (lastAbsentMs: number | null, firstPresentMs: number | null, resolutionMs: number | null = 0) =>
|
||||
observationTiming({ event: "acknowledgement", lastAbsentMs, firstPresentMs, resolutionMs });
|
||||
|
||||
describe("interval latency classification", () => {
|
||||
it("retains the historical acknowledgement bounds as inconclusive", () => {
|
||||
expect(classifyLatency(observed(1_901, 2_427), target)).toMatchObject({
|
||||
verdict: "inconclusive", lastAbsentMs: 1_901, firstPresentMs: 2_427,
|
||||
interval: { lower: { ms: 1_901, inclusive: false }, upper: { ms: 2_427, inclusive: true } },
|
||||
});
|
||||
expect(classifyLatency({ ...observed(52_965, 53_334), event: "first_visible_response" }, { operator: "<=", ms: 30_000 }).verdict).toBe("fail");
|
||||
});
|
||||
|
||||
it.each(["<", "<="] as const)("classifies every endpoint equality for %s", operator => {
|
||||
// The same number is not sufficient: endpoint inclusion determines equality.
|
||||
for (const lowerInclusive of [false, true]) for (const upperInclusive of [false, true]) {
|
||||
const verdict = (lower: number, upper: number) => classifyLatency({
|
||||
...observed(null, null), interval: {
|
||||
lower: { ms: lower, inclusive: lowerInclusive }, upper: { ms: upper, inclusive: upperInclusive },
|
||||
},
|
||||
}, { operator, ms: 2_000 }).verdict;
|
||||
expect(verdict(1_000, 2_000)).toBe(operator === "<=" || !upperInclusive ? "pass" : "inconclusive");
|
||||
expect(verdict(2_000, 3_000)).toBe(operator === "<" || !lowerInclusive ? "fail" : "inconclusive");
|
||||
expect(verdict(1_999, 2_001)).toBe("inconclusive");
|
||||
expect(verdict(2_000, 2_000)).toBe(lowerInclusive && upperInclusive ? (operator === "<=" ? "pass" : "fail") : "inconclusive");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null, "inconclusive"], [null, 1_999, "pass"], [null, 2_001, "inconclusive"],
|
||||
[1_999, null, "inconclusive"], [2_000, null, "fail"], [2_001, null, "fail"],
|
||||
] as const)("handles missing observations (%s, %s)", (lower, upper, verdict) => {
|
||||
const result = classifyLatency(observed(lower, upper), target);
|
||||
expect(result.verdict).toBe(verdict);
|
||||
expect(result.lastAbsentMs).toBe(lower);
|
||||
expect(result.firstPresentMs).toBe(upper);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[-1, 1_000], [NaN, 1_000], [1_000, Infinity], [2_001, 2_000], [2_000, 2_000],
|
||||
])("does not turn invalid observations %s, %s into exact latency", (lower, upper) => {
|
||||
expect(classifyLatency(observed(lower!, upper!), target)).toMatchObject({ verdict: "inconclusive", reason: "invalid_evidence" });
|
||||
});
|
||||
|
||||
it("keeps unknown resolution and widens rounded measurements before grading", () => {
|
||||
expect(classifyLatency(observed(1_900, 2_000, null), target).verdict).toBe("inconclusive");
|
||||
expect(classifyLatency(observed(1_900, 2_000, 1), target)).toMatchObject({
|
||||
verdict: "inconclusive", resolutionMs: 1, firstPresentMs: 2_000,
|
||||
interval: { lower: { ms: 1_899 }, upper: { ms: 2_001 } },
|
||||
});
|
||||
expect(classifyLatency(observed(0, 1, 1), { operator: "<=", ms: 0 }).verdict).toBe("inconclusive");
|
||||
for (const resolution of [-1, NaN, Infinity]) expect(classifyLatency(observed(1, 2, resolution), target).reason).toBe("invalid_evidence");
|
||||
for (const ms of [-1, NaN, Infinity]) expect(classifyLatency(observed(1, 2), { ...target, ms }).reason).toBe("invalid_evidence");
|
||||
});
|
||||
|
||||
it("matches an independent finite-domain oracle on strict and inclusive targets", () => {
|
||||
for (const operator of ["<", "<="] as const) for (const lowerInclusive of [false, true]) for (const upperInclusive of [false, true]) {
|
||||
for (let lower = 0; lower <= 4; lower++) for (let upper = lower; upper <= 4; upper++) {
|
||||
const values = Array.from({ length: 9 }, (_, n) => n / 2)
|
||||
.filter(n => (n > lower || (lowerInclusive && n === lower)) && (n < upper || (upperInclusive && n === upper)));
|
||||
const passes = values.map(n => operator === "<" ? n < 2 : n <= 2);
|
||||
const expected = !values.length ? "inconclusive" : passes.every(Boolean) ? "pass" : passes.every(p => !p) ? "fail" : "inconclusive";
|
||||
const interval = { lower: { ms: lower, inclusive: lowerInclusive }, upper: { ms: upper, inclusive: upperInclusive } };
|
||||
expect(classifyLatency({ ...observed(null, null), interval }, { operator, ms: 2 }).verdict).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("monotonic observation probes", () => {
|
||||
it("measures an edited message's first matching visibility independently of creation and settlement", async () => {
|
||||
let clock = 10_000;
|
||||
const now = () => clock;
|
||||
const reply = armLatencyProbe({ event: "first_visible_response", target, resolutionMs: 0, now });
|
||||
const settled = armLatencyProbe({ event: "final_settlement", target, resolutionMs: 0, now });
|
||||
// The observer is armed before dispatch; the response can arrive before send returns.
|
||||
const send = async () => {
|
||||
clock += 1_901;
|
||||
await reply.sample(async () => ({ present: false }));
|
||||
clock = 12_427;
|
||||
await reply.sample(async () => ({ present: true, messageTimestamps: { createdAt: "2026-01-01T00:00:00Z", editedAt: "2026-01-01T00:00:10Z" } }));
|
||||
};
|
||||
await send();
|
||||
clock = 63_334;
|
||||
await reply.sample(async () => ({ present: false }));
|
||||
await settled.sample(async () => ({ present: true }));
|
||||
expect(reply.result()).toMatchObject({ event: "first_visible_response", verdict: "inconclusive", lastAbsentMs: 1_901, firstPresentMs: 2_427,
|
||||
messageTimestamps: { createdAt: "2026-01-01T00:00:00Z", editedAt: "2026-01-01T00:00:10Z" } });
|
||||
expect(settled.result()).toMatchObject({ event: "final_settlement", lastAbsentMs: null, firstPresentMs: 53_334 });
|
||||
});
|
||||
|
||||
it("uses poll start for absence and poll completion for presence", async () => {
|
||||
let clock = 0;
|
||||
const probe = armLatencyProbe({ event: "acknowledgement", target, resolutionMs: 0, now: () => clock });
|
||||
clock = 1_901;
|
||||
await probe.sample(async () => { clock = 2_100; return { present: false }; });
|
||||
clock = 2_200;
|
||||
await probe.sample(async () => { clock = 2_427; return { present: true }; });
|
||||
expect(probe.result()).toMatchObject({ verdict: "inconclusive", lastAbsentMs: 1_901, firstPresentMs: 2_427 });
|
||||
});
|
||||
|
||||
it("does not count failed reads as absence or use wall-clock message metadata", async () => {
|
||||
let clock = 0;
|
||||
const probe = armLatencyProbe({ event: "acknowledgement", target, resolutionMs: 0, now: () => clock });
|
||||
clock = 3_000;
|
||||
await expect(probe.sample(async () => { throw new Error("unavailable"); })).rejects.toThrow("unavailable");
|
||||
expect(probe.result()).toMatchObject({ verdict: "inconclusive", lastAbsentMs: null, firstPresentMs: null });
|
||||
await probe.sample(async () => ({ present: true, messageTimestamps: { createdAt: "invalid", editedAt: "2099-01-01" } }));
|
||||
expect(probe.result()).toMatchObject({ verdict: "inconclusive", firstPresentMs: 3_000 });
|
||||
});
|
||||
|
||||
it("rejects a backwards clock instead of clamping latency to zero", async () => {
|
||||
let clock = 100;
|
||||
const probe = armLatencyProbe({ event: "acknowledgement", target, resolutionMs: 0, now: () => clock });
|
||||
clock = 99;
|
||||
await probe.sample(async () => ({ present: true }));
|
||||
expect(probe.result()).toMatchObject({ verdict: "inconclusive", reason: "invalid_evidence" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Probe timing is an interval, never the timestamp of the poll that found it. */
|
||||
export type LatencyEvent = "acknowledgement" | "first_visible_response" | "message_creation" | "final_settlement";
|
||||
export type LatencyVerdict = "pass" | "fail" | "inconclusive";
|
||||
export interface TimingBound { ms: number; inclusive: boolean }
|
||||
export interface LatencyTarget { operator: "<" | "<="; ms: number }
|
||||
export interface LatencyEvidence {
|
||||
event: LatencyEvent;
|
||||
interval: { lower: TimingBound | null; upper: TimingBound | null };
|
||||
lastAbsentMs: number | null;
|
||||
firstPresentMs: number | null;
|
||||
/** Conservative uncertainty on each elapsed observation; null means unknown. */
|
||||
resolutionMs: number | null;
|
||||
invalid?: boolean;
|
||||
observationFailures?: number;
|
||||
/** Reads abandoned when the browser observer's shutdown deadline expires. */
|
||||
observationTimeouts?: number;
|
||||
/** Provider wall-clock metadata is retained separately and never measures visibility. */
|
||||
messageTimestamps?: { createdAt?: string; editedAt?: string };
|
||||
}
|
||||
export interface LatencyResult extends LatencyEvidence {
|
||||
target: LatencyTarget;
|
||||
verdict: LatencyVerdict;
|
||||
/** Original retained verdict, if report regeneration corrects the timing grade. */
|
||||
recordedVerdict?: LatencyVerdict;
|
||||
reason: "within_target" | "outside_target" | "threshold_overlap" | "missing_observations" | "invalid_evidence" | "unknown_resolution";
|
||||
}
|
||||
|
||||
const nonnegative = (value: unknown): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
|
||||
export function classifyLatency(evidence: LatencyEvidence, target: LatencyTarget): LatencyResult {
|
||||
const { lower, upper } = evidence.interval ?? {};
|
||||
const result = (verdict: LatencyVerdict, reason: LatencyResult["reason"]): LatencyResult =>
|
||||
({ ...evidence, target, verdict, reason });
|
||||
const validBound = (bound: TimingBound | null) => bound === null
|
||||
|| (bound && nonnegative(bound.ms) && typeof bound.inclusive === "boolean");
|
||||
if (evidence.invalid || !nonnegative(target.ms) || !["<", "<="].includes(target.operator)
|
||||
|| !validBound(lower) || !validBound(upper)
|
||||
|| (evidence.lastAbsentMs !== null && !nonnegative(evidence.lastAbsentMs))
|
||||
|| (evidence.firstPresentMs !== null && !nonnegative(evidence.firstPresentMs))
|
||||
|| (evidence.lastAbsentMs !== null && evidence.firstPresentMs !== null && evidence.lastAbsentMs > evidence.firstPresentMs)
|
||||
|| (lower && upper && (lower.ms > upper.ms || (lower.ms === upper.ms && !(lower.inclusive && upper.inclusive))))
|
||||
|| (evidence.resolutionMs !== null && !nonnegative(evidence.resolutionMs))) {
|
||||
return result("inconclusive", "invalid_evidence");
|
||||
}
|
||||
if (evidence.resolutionMs === null) return result("inconclusive", "unknown_resolution");
|
||||
if (!lower && !upper) return result("inconclusive", "missing_observations");
|
||||
if (upper && (upper.ms < target.ms || (upper.ms === target.ms && (target.operator === "<=" || !upper.inclusive)))) {
|
||||
return result("pass", "within_target");
|
||||
}
|
||||
if (lower && (lower.ms > target.ms || (lower.ms === target.ms && (target.operator === "<" || !lower.inclusive)))) {
|
||||
return result("fail", "outside_target");
|
||||
}
|
||||
return result("inconclusive", "threshold_overlap");
|
||||
}
|
||||
|
||||
export function observationTiming(input: Omit<LatencyEvidence, "interval">): LatencyEvidence {
|
||||
const uncertainty = input.resolutionMs ?? 0;
|
||||
return {
|
||||
...input,
|
||||
interval: {
|
||||
lower: input.lastAbsentMs === null ? null : { ms: Math.max(0, input.lastAbsentMs - uncertainty), inclusive: input.lastAbsentMs - uncertainty < 0 },
|
||||
upper: input.firstPresentMs === null ? null : { ms: input.firstPresentMs + uncertainty, inclusive: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Arm immediately before dispatch. Inject a monotonic clock for deterministic tests.
|
||||
* The predicate must describe a persistent event (or a latched event observer).
|
||||
* A missing/failed read is not an absent observation. */
|
||||
export function armLatencyProbe(input: {
|
||||
event: LatencyEvent;
|
||||
target: LatencyTarget;
|
||||
resolutionMs: number;
|
||||
now?: () => number;
|
||||
}) {
|
||||
const now = input.now ?? (() => performance.now());
|
||||
const start = now();
|
||||
let lastClock = start;
|
||||
let invalid = !Number.isFinite(start);
|
||||
let lastAbsentMs: number | null = null;
|
||||
let firstPresentMs: number | null = null;
|
||||
let observationFailures = 0;
|
||||
let messageTimestamps: LatencyEvidence["messageTimestamps"];
|
||||
const elapsed = () => {
|
||||
const current = now();
|
||||
if (!Number.isFinite(current) || current < lastClock) invalid = true;
|
||||
lastClock = current;
|
||||
return current - start;
|
||||
};
|
||||
type Observation = { present: boolean; messageTimestamps?: LatencyEvidence["messageTimestamps"] };
|
||||
const beginObservation = () => {
|
||||
const before = elapsed();
|
||||
let completed = false;
|
||||
return (observation: Observation) => {
|
||||
if (completed) throw new Error("Observation already completed");
|
||||
completed = true;
|
||||
const after = elapsed();
|
||||
if (typeof observation.present !== "boolean" || after < before) invalid = true;
|
||||
// Concurrent or late reads cannot replace the first positive observation.
|
||||
if (firstPresentMs !== null) return;
|
||||
if (observation.present) {
|
||||
firstPresentMs = after;
|
||||
messageTimestamps = observation.messageTimestamps;
|
||||
} else {
|
||||
lastAbsentMs = Math.max(lastAbsentMs ?? 0, before);
|
||||
}
|
||||
};
|
||||
};
|
||||
return {
|
||||
beginObservation,
|
||||
async sample(load: () => Promise<Observation>) {
|
||||
if (firstPresentMs !== null) return;
|
||||
const complete = beginObservation();
|
||||
try { complete(await load()); }
|
||||
catch (error) { observationFailures++; throw error; }
|
||||
},
|
||||
result(): LatencyResult {
|
||||
return classifyLatency(observationTiming({ event: input.event, lastAbsentMs, firstPresentMs,
|
||||
resolutionMs: input.resolutionMs, ...(invalid ? { invalid: true } : {}),
|
||||
...(observationFailures ? { observationFailures } : {}),
|
||||
...(messageTimestamps ? { messageTimestamps } : {}) }), input.target);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function latencyIntervalLabel(evidence: LatencyEvidence): string {
|
||||
const { lower, upper } = evidence.interval;
|
||||
return `${lower?.inclusive ? "[" : "("}${lower?.ms ?? "unknown"}, ${upper?.ms ?? "unknown"}${upper?.inclusive ? "]" : ")"} ms`;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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.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.outputDocument = { issueId: "task", key: "confirmation", revisionId: "revision", href: "/QA/issues/task#document-confirmation", visibleBody: `Booking confirmed: ${scenario.answer}` };
|
||||
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"; }],
|
||||
["foreign resolved company", (points: Row[]) => { points[1].interactions[0].companyId = "foreign"; }],
|
||||
["changed creator", (points: Row[]) => { points[1].interactions[0].createdByAgentId = "foreign"; }],
|
||||
["changed idempotency key", (points: Row[]) => { points[1].interactions[0].idempotencyKey = "foreign"; }],
|
||||
["changed continuation policy", (points: Row[]) => { points[1].interactions[0].continuationPolicy = "none"; }],
|
||||
["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[]) => { delete points[1].outputDocument; }],
|
||||
["foreign linked task", (points: Row[]) => { points[1].outputDocument.issueId = "foreign"; }],
|
||||
["different linked document", (points: Row[]) => { points[1].outputDocument.key = "other"; }],
|
||||
["stale linked revision", (points: Row[]) => { points[1].outputDocument.revisionId = "older"; }],
|
||||
["wrong displayed content", (points: Row[]) => { points[1].outputDocument.visibleBody = "Claimed complete"; }],
|
||||
["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 { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { continuationScenario, continuationTasks, necessaryQuestionContract } from "./continuation-cases.js";
|
||||
import { questionResumedOnce, sameQuestion, 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, document = final?.documents[0], navigation = final?.outputDocument;
|
||||
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 && !!question && sameQuestion(question, resolved) && 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 &&
|
||||
!!document?.latestRevisionId && document?.body.trim() === `Booking confirmed: ${answer}` &&
|
||||
navigation?.issueId === final.issue.id && navigation?.key === document.key && navigation?.revisionId === document.latestRevisionId &&
|
||||
navigation?.visibleBody === document.body.trim(),
|
||||
"The final reply must open this task's saved confirmation and show its exact answer."),
|
||||
];
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -46,14 +46,14 @@ export function validResumeQuestionForm(checkpoint: ContinuationCheckpoint, i: R
|
||||
(fields.length === 1 || (path === "provider" && isSingleClaudeQuestion(fields)));
|
||||
}
|
||||
|
||||
function sameQuestion(a: Row, b?: Row) {
|
||||
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;
|
||||
}
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { STARTUP_REUSE_LAYERS, STARTUP_REUSE_SCHEMA, summarizeStartupReuse } from "../../packages/adapter-utils/src/startup-reuse.js";
|
||||
import type { RunnerE2EResult } from "./types.js";
|
||||
import { classifyLatency, observationTiming } from "./latency-timing.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
||||
@@ -19,6 +20,41 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("runner E2E report aggregation", () => {
|
||||
it("corrects overconfident timing verdicts without changing retained bounds or original files", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-latency-report-"));
|
||||
cleanupDirectories.push(root);
|
||||
const executionId = "core-compatibility.runner-codex.local.message-marker";
|
||||
const directory = path.join(root, "attempt-1");
|
||||
await mkdir(directory);
|
||||
const acknowledgement = classifyLatency(observationTiming({ event: "acknowledgement", lastAbsentMs: 1_901, firstPresentMs: 2_427, resolutionMs: 0 }), { operator: "<=", ms: 2_000 });
|
||||
const response = classifyLatency(observationTiming({ event: "first_visible_response", lastAbsentMs: 52_965, firstPresentMs: 53_334, resolutionMs: 0 }), { operator: "<=", ms: 30_000 });
|
||||
const result: RunnerE2EResult = {
|
||||
schema: "paperclip.runner-e2e.result/v2", executionId, suiteId: "core-compatibility", attempt: 1, status: "passed",
|
||||
profileId: "runner-codex", environmentId: "local", caseId: "message-marker", provider: "codex", model: "fixture-model", runtimeMode: "native",
|
||||
startedAt: "2026-10-18T00:00:00Z", finishedAt: "2026-10-18T00:01:00Z", durationMs: 60_000, cleanup: "passed",
|
||||
// An old report called the crossing interval a failure (or a pass).
|
||||
latencyProbes: [{ ...acknowledgement, verdict: "fail", reason: "outside_target", observationTimeouts: 1 }, { ...acknowledgement, verdict: "pass", reason: "within_target" }, response],
|
||||
};
|
||||
const raw = JSON.stringify(result);
|
||||
await writeFile(path.join(directory, "result.json"), raw);
|
||||
await writeFile(path.join(directory, "final-state.png"), "fixture-png");
|
||||
await writeFile(path.join(directory, "evidence-manifest.json"), JSON.stringify({ files: ["final-state.png"], leaks: [], missing: [] }));
|
||||
const output = path.join(root, "merged");
|
||||
await execFileAsync(process.execPath, [path.join(repositoryRoot, "cli/node_modules/tsx/dist/cli.mjs"), path.join(repositoryRoot, "tests/runner-e2e/report.ts")], {
|
||||
cwd: repositoryRoot, env: { ...process.env, PAPERCLIP_RUNNER_E2E_REPORT_ROOT: root, PAPERCLIP_RUNNER_E2E_REPORT_OUT: output, PAPERCLIP_RUNNER_E2E_EXPECTED_IDS: JSON.stringify([executionId]) },
|
||||
});
|
||||
const normalized = JSON.parse(await readFile(path.join(output, "normalized-results.json"), "utf8"));
|
||||
expect(normalized.results[0].latencyProbes).toEqual([
|
||||
{ ...acknowledgement, recordedVerdict: "fail", observationTimeouts: 1 }, { ...acknowledgement, recordedVerdict: "pass" }, response,
|
||||
]);
|
||||
expect(normalized.passed).toBe(1);
|
||||
expect(await readFile(path.join(directory, "result.json"), "utf8")).toBe(raw);
|
||||
const dashboard = await readFile(path.join(output, "index.html"), "utf8");
|
||||
expect(dashboard).toContain("inconclusive");
|
||||
expect(dashboard).toContain("(1901, 2427] ms");
|
||||
expect(dashboard).toContain("(52965, 53334] ms");
|
||||
expect(dashboard).toContain("first_visible_response");
|
||||
});
|
||||
it("retains full reuse and a workspace-only invalidation in the generated report", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "runner-reuse-report-"));
|
||||
cleanupDirectories.push(root);
|
||||
|
||||
@@ -58,7 +58,19 @@ export async function runRepositoryVerificationFlow(input: {
|
||||
}
|
||||
async function save(phase: string) {
|
||||
const checks = gradeRepositoryProof(observation);
|
||||
if (issue) input.observe(issue, runs, checks);
|
||||
if (issue) {
|
||||
input.observe(issue, runs, checks);
|
||||
await input.evidence("api-state.json", {
|
||||
capturePhase: phase,
|
||||
issue: { id: issue.id, companyId: issue.companyId, identifier: issue.identifier, status: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId, executionWorkspaceId: issue.executionWorkspaceId, statusVersion: issue.statusVersion },
|
||||
runs: runs.map(run => ({ id: run.id, companyId: run.companyId, agentId: run.agentId, issueId: run.issueId,
|
||||
nativeIssueId: run.nativeIssueId, status: run.status, runtimeMode: run.runtimeMode, nativePhase: run.nativePhase,
|
||||
startedAt: run.startedAt, finishedAt: run.finishedAt, errorCode: run.errorCode,
|
||||
finalizationPhase: run.resultJson?.finalizationPhase })),
|
||||
checks,
|
||||
});
|
||||
}
|
||||
await input.evidence("repository-verification.json", { schema: "paperclip.e2e.repository-verification.v1", phase, observation, checks,
|
||||
runs: runs.map(run => ({ id: run.id, status: run.status, nativePhase: run.nativePhase, source: run.contextSnapshot?.source,
|
||||
startedAt: run.startedAt, finishedAt: run.finishedAt })) });
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { armResponseTiming } from "./response-timing.js";
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||
|
||||
it("starts observation before dispatch and drains without overlapping reads on teardown", async () => {
|
||||
vi.useFakeTimers();
|
||||
const absent = { response: false, acknowledgement: false };
|
||||
let complete: (value: typeof absent) => void = () => {};
|
||||
const read = vi.fn().mockImplementationOnce(() => new Promise<typeof absent>(resolve => { complete = resolve; }))
|
||||
.mockResolvedValue(absent);
|
||||
const locator = { getByTestId: () => locator, evaluateAll: read };
|
||||
const timing = armResponseTiming({ page: locator as unknown as Page });
|
||||
expect(read).toHaveBeenCalledTimes(1); // No dispatch or awaited acknowledgement yet.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
let stopped = false;
|
||||
const stopping = timing.stop().then(() => { stopped = true; });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(stopped).toBe(false);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
complete(absent);
|
||||
await stopping;
|
||||
expect(stopped).toBe(true);
|
||||
// One final read after draining the in-flight request, then no more ticks.
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["pending", "final"])("bounds shutdown when the %s read never returns and ignores late completions", async phase => {
|
||||
vi.useFakeTimers();
|
||||
const absent = { response: false, acknowledgement: false };
|
||||
let complete!: (value: typeof absent) => void;
|
||||
const stuck = () => new Promise<typeof absent>(resolve => { complete = resolve; });
|
||||
const read = phase === "pending" ? vi.fn(stuck) : vi.fn().mockResolvedValueOnce(absent).mockImplementation(stuck);
|
||||
const locator = { getByTestId: () => locator, evaluateAll: read };
|
||||
const timing = armResponseTiming({ page: locator as unknown as Page, acknowledgementTarget: { operator: "<=", ms: 2_000 } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const stopping = timing.stop();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await stopping;
|
||||
const evidence = timing.results();
|
||||
for (const probe of evidence.filter(probe => probe.event !== "final_settlement")) {
|
||||
expect(probe).toMatchObject({ verdict: "inconclusive", firstPresentMs: null, observationFailures: 1, observationTimeouts: 1 });
|
||||
expect(probe.lastAbsentMs).toBe(phase === "pending" ? null : 0);
|
||||
}
|
||||
expect(read).toHaveBeenCalledTimes(phase === "pending" ? 1 : 2);
|
||||
complete({ response: true, acknowledgement: true });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await timing.stop();
|
||||
await timing.observeResponse(locator as unknown as Page);
|
||||
expect(timing.results()).toEqual(evidence);
|
||||
expect(read).toHaveBeenCalledTimes(phase === "pending" ? 1 : 2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("observes edited visible text separately from a platform acknowledgement and final settlement", async () => {
|
||||
let clock = 0;
|
||||
let receipt = true;
|
||||
let text = "Platform receipt";
|
||||
let visible = true;
|
||||
vi.stubGlobal("getComputedStyle", () => ({ display: "block", visibility: visible ? "visible" : "hidden" }));
|
||||
const element = {
|
||||
closest: () => ({ querySelector: () => receipt ? {} : null }),
|
||||
get textContent() { return text; },
|
||||
getClientRects: () => visible ? [{}] : [],
|
||||
};
|
||||
const locator = {
|
||||
getByTestId: () => locator,
|
||||
evaluateAll: async (read: (elements: Element[]) => unknown) => read([element as unknown as Element]),
|
||||
};
|
||||
const page = locator as unknown as Page;
|
||||
const timing = armResponseTiming({ now: () => clock });
|
||||
clock = 1_900;
|
||||
const firstSettlement = timing.beginSettlement();
|
||||
await timing.observeResponse(page);
|
||||
firstSettlement({ present: false });
|
||||
// The same message now has substantive content; creation time does not
|
||||
// change. Hidden content is still not a visible response.
|
||||
receipt = false;
|
||||
text = "The answer";
|
||||
visible = false;
|
||||
clock = 29_900;
|
||||
await timing.observeResponse(page);
|
||||
visible = true;
|
||||
clock = 30_100;
|
||||
await timing.observeResponse(page);
|
||||
clock = 52_965;
|
||||
const lastAbsent = timing.beginSettlement();
|
||||
clock = 53_000;
|
||||
lastAbsent({ present: false });
|
||||
const present = timing.beginSettlement();
|
||||
clock = 53_334;
|
||||
present({ present: true });
|
||||
expect(timing.results()).toMatchObject([
|
||||
{ event: "first_visible_response", verdict: "inconclusive", lastAbsentMs: 29_900, firstPresentMs: 30_100, resolutionMs: 1 },
|
||||
{ event: "final_settlement", verdict: "fail", lastAbsentMs: 52_965, firstPresentMs: 53_334 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the same observation bounds for an explicitly expected acknowledgement", async () => {
|
||||
let clock = 0;
|
||||
let acknowledgement = false;
|
||||
const read = vi.fn(async () => ({ response: false, acknowledgement }));
|
||||
const locator = { getByTestId: () => locator, evaluateAll: read };
|
||||
const timing = armResponseTiming({ now: () => clock, acknowledgementTarget: { operator: "<=", ms: 2_000 } });
|
||||
clock = 1_901;
|
||||
await timing.observeResponse(locator as unknown as Page);
|
||||
clock = 2_427;
|
||||
acknowledgement = true;
|
||||
await timing.observeResponse(locator as unknown as Page);
|
||||
expect(read).toHaveBeenCalledTimes(2); // One snapshot per poll shared by both events.
|
||||
expect(timing.results().find(probe => probe.event === "acknowledgement")).toMatchObject({
|
||||
verdict: "inconclusive", lastAbsentMs: 1_901, firstPresentMs: 2_427,
|
||||
interval: { lower: { ms: 1_900, inclusive: false }, upper: { ms: 2_428, inclusive: true } },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { armLatencyProbe, type LatencyTarget } from "./latency-timing.js";
|
||||
|
||||
/** Browser observations, independent of provider creation/edit timestamps and run settlement.
|
||||
* Used only by the explicit latency diagnostic; these are not Slack delivery probes. */
|
||||
export function armResponseTiming(input: { now?: () => number; page?: Page; acknowledgementTarget?: LatencyTarget } = {}) {
|
||||
const common = { now: input.now, resolutionMs: 1 };
|
||||
const response = armLatencyProbe({ ...common, event: "first_visible_response", target: { operator: "<=", ms: 30_000 } });
|
||||
const settlement = armLatencyProbe({ ...common, event: "final_settlement", target: { operator: "<=", ms: 30_000 } });
|
||||
// Only measure acknowledgement when the caller expects that feature. The
|
||||
// Daytona response diagnostic does not enable receipts or measure Slack.
|
||||
const acknowledgement = input.acknowledgementTarget
|
||||
? armLatencyProbe({ ...common, event: "acknowledgement", target: input.acknowledgementTarget }) : undefined;
|
||||
const shutdown = new AbortController();
|
||||
const timeouts = new Map<ReturnType<typeof armLatencyProbe>, number>();
|
||||
let stopped = false;
|
||||
const observeResponse = async (page: Page) => {
|
||||
if (stopped || shutdown.signal.aborted) return;
|
||||
const observing = [response, ...(acknowledgement ? [acknowledgement] : [])]
|
||||
.filter(probe => probe.result().firstPresentMs === null);
|
||||
if (!observing.length) return;
|
||||
const read = () => page.getByTestId("task-chat-thread")
|
||||
.getByTestId("task-chat-agent-bubble").evaluateAll(elements => {
|
||||
const visible = { response: false, acknowledgement: false };
|
||||
for (const element of elements) {
|
||||
// Receipt details live beside the bubble inside its thread anchor.
|
||||
// A platform receipt is not the agent's first substantive response.
|
||||
const anchor = element.closest("[data-thread-anchor]");
|
||||
const receipt = anchor?.querySelector('[aria-label="Message details"]');
|
||||
const style = getComputedStyle(element);
|
||||
if (element.textContent?.trim() && element.getClientRects().length > 0
|
||||
&& style.visibility !== "hidden" && style.display !== "none") {
|
||||
if (receipt) visible.acknowledgement = true;
|
||||
else visible.response = true;
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
});
|
||||
let resolve!: (value: Awaited<ReturnType<typeof read>>) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const snapshot = new Promise<Awaited<ReturnType<typeof read>>>((yes, no) => { resolve = yes; reject = no; });
|
||||
// Both lower observations must precede the shared read, including the
|
||||
// second probe. Starting that probe after read() could overstate absence.
|
||||
const samples = Promise.all([
|
||||
response.sample(async () => ({ present: (await snapshot).response })),
|
||||
acknowledgement?.sample(async () => ({ present: (await snapshot).acknowledgement })),
|
||||
]);
|
||||
const abandon = () => {
|
||||
for (const probe of observing) timeouts.set(probe, (timeouts.get(probe) ?? 0) + 1);
|
||||
// Reject the shared snapshot so a late browser completion cannot alter
|
||||
// evidence after cleanup resumes. The browser operation itself may still
|
||||
// be running; do not start another read after this deadline.
|
||||
reject(new Error("Latency observer shutdown deadline exceeded"));
|
||||
};
|
||||
shutdown.signal.addEventListener("abort", abandon, { once: true });
|
||||
try {
|
||||
try { read().then(resolve, reject); } catch (error) { reject(error); }
|
||||
await samples;
|
||||
} finally {
|
||||
shutdown.signal.removeEventListener("abort", abandon);
|
||||
}
|
||||
};
|
||||
let pending: Promise<void> | undefined;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let stopping: Promise<void> | undefined;
|
||||
if (input.page) {
|
||||
const page = input.page;
|
||||
const tick = () => {
|
||||
if (pending) return;
|
||||
// A failed browser read is counted by the probe, never recorded as absence.
|
||||
pending = observeResponse(page).catch(() => {}).finally(() => {
|
||||
pending = undefined;
|
||||
if (response.result().firstPresentMs !== null
|
||||
&& (!acknowledgement || acknowledgement.result().firstPresentMs !== null)) clearInterval(timer);
|
||||
});
|
||||
};
|
||||
tick(); // The first read starts before dispatch, not after its acknowledgement.
|
||||
timer = setInterval(tick, 100);
|
||||
}
|
||||
return {
|
||||
beginSettlement: () => settlement.beginObservation(),
|
||||
observeResponse,
|
||||
stop() {
|
||||
return stopping ??= (async () => {
|
||||
clearInterval(timer);
|
||||
// One deadline covers both draining and the final observation. Abort
|
||||
// only our snapshot wait, leaving page teardown to the fixture owner.
|
||||
const deadline = setTimeout(() => shutdown.abort(), 1_000);
|
||||
try {
|
||||
await pending;
|
||||
if (input.page) await observeResponse(input.page).catch(() => {});
|
||||
} finally {
|
||||
stopped = true;
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
})();
|
||||
},
|
||||
results: () => [response, settlement, ...(acknowledgement ? [acknowledgement] : [])].map(probe => ({
|
||||
...probe.result(), ...(timeouts.has(probe) ? { observationTimeouts: timeouts.get(probe)! } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,26 @@ const date: Rule = (value, at) => {
|
||||
string(value, at);
|
||||
if (!Number.isFinite(Date.parse(value as string))) invalid(at);
|
||||
};
|
||||
const finite: Rule = (value, at) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) invalid(at);
|
||||
};
|
||||
// Finite invalid bounds remain inspectable evidence with an inconclusive verdict.
|
||||
const timingBound = nullable(shape({ ms: finite, inclusive: boolean }));
|
||||
const latencyProbe: Rule = (value, at) => {
|
||||
shape({
|
||||
event: oneOf("acknowledgement", "first_visible_response", "message_creation", "final_settlement"),
|
||||
interval: shape({ lower: timingBound, upper: timingBound }),
|
||||
lastAbsentMs: nullable(finite), firstPresentMs: nullable(finite), resolutionMs: nullable(finite),
|
||||
invalid: optional(boolean),
|
||||
observationFailures: optional(integer),
|
||||
observationTimeouts: optional(integer),
|
||||
messageTimestamps: optional(shape({ createdAt: optional(string), editedAt: optional(string) })),
|
||||
target: shape({ operator: oneOf("<", "<="), ms: finite }),
|
||||
verdict: oneOf("pass", "fail", "inconclusive"),
|
||||
recordedVerdict: optional(oneOf("pass", "fail", "inconclusive")),
|
||||
reason: oneOf("within_target", "outside_target", "threshold_overlap", "missing_observations", "invalid_evidence", "unknown_resolution"),
|
||||
})(value, at);
|
||||
};
|
||||
const url: Rule = (value, at) => {
|
||||
string(value, at);
|
||||
try {
|
||||
@@ -211,6 +231,7 @@ const fields = {
|
||||
revisionAtStart: matching(/^[a-f0-9]{40}$/), revisionAtEnd: matching(/^[a-f0-9]{40}$/),
|
||||
target: oneOf("isolated-candidate", "isolated-staging") }),
|
||||
})),
|
||||
latencyProbes: optional(array(latencyProbe)),
|
||||
startupReuse: optional(array(shape({
|
||||
runId: string,
|
||||
warmth: oneOf("cold", "warm", "partial_reuse", "unknown"),
|
||||
|
||||
@@ -42,6 +42,7 @@ import { runRepositorySkillsFlow } from "./repository-skills.js";
|
||||
import { captureStockHarness, gradeStockHarness, gradeStockHire } from "./stock-harness.js";
|
||||
import { verifyStockHarnessPreflight, STOCK_PREFLIGHT_ENV } from "./stock-harness-admission.js";
|
||||
import { createTaskThroughUi, submitTaskReply } from "./user-actions.js";
|
||||
import { armResponseTiming } from "./response-timing.js";
|
||||
|
||||
import { projectFirstTaskResult, runFirstTaskFlow, setupFirstTaskFixtures } from "./first-task-flow.js";
|
||||
import { runChatFlow } from "./chat-flow.js";
|
||||
@@ -649,6 +650,7 @@ for (const execution of executions) {
|
||||
};
|
||||
let firstTaskEvidence: RunnerE2EResult["firstTask"];
|
||||
let turnTimings: NonNullable<RunnerE2EResult["turnTimings"]> | undefined;
|
||||
let responseTiming: ReturnType<typeof armResponseTiming> | undefined;
|
||||
let startupReuse: RunnerE2EResult["startupReuse"];
|
||||
const turnSubmissionTimesMs: number[] = [];
|
||||
const screenshots: NonNullable<RunnerE2EResult["screenshots"]> = [];
|
||||
@@ -1258,6 +1260,8 @@ for (const execution of executions) {
|
||||
workMode: execution.task.workMode,
|
||||
projectName: fixtures.project?.name,
|
||||
requireExplicitTitle: execution.suite.id === "task-titles" && Boolean(title),
|
||||
beforeDispatch: execution.suite.id === "codie-latency-diagnostic"
|
||||
? () => { responseTiming = armResponseTiming({ page }); } : undefined,
|
||||
});
|
||||
turnSubmissionTimesMs.push(createdTask.submittedAtMs);
|
||||
|
||||
@@ -1310,6 +1314,7 @@ for (const execution of executions) {
|
||||
);
|
||||
|
||||
const loadTaskState = async () => {
|
||||
const observeSettlement = responseTiming?.beginSettlement();
|
||||
const [currentIssue, runs, comments, interactions] = await Promise.all([
|
||||
api.get<IssueRecord>(`/api/issues/${issue!.id}`),
|
||||
api.get<RunRecord[]>(
|
||||
@@ -1322,6 +1327,9 @@ for (const execution of executions) {
|
||||
),
|
||||
api.get<InteractionRecord[]>(`/api/issues/${issue!.id}/interactions`),
|
||||
]);
|
||||
observeSettlement?.({ present: currentIssue.status === execution.task.expectedTerminalState.issue
|
||||
&& matchingRuns(runs, currentIssue).length >= minimumRunCount(execution.task)
|
||||
&& matchingRuns(runs, currentIssue).every(run => TERMINAL_RUN_STATUSES.has(run.status)) });
|
||||
return {
|
||||
currentIssue,
|
||||
taskRuns: matchingRuns(runs, currentIssue),
|
||||
@@ -3038,6 +3046,7 @@ for (const execution of executions) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await responseTiming?.stop();
|
||||
await reviewProvider?.close();
|
||||
try {
|
||||
await writeSanitizedJson(
|
||||
@@ -3202,6 +3211,7 @@ for (const execution of executions) {
|
||||
issueIdentifier: issue?.identifier ?? null,
|
||||
runIds: selectedRuns.map((run) => run.id),
|
||||
...(turnTimings ? { turnTimings } : {}),
|
||||
...(responseTiming ? { latencyProbes: responseTiming.results() } : {}),
|
||||
...(startupReuse ? { startupReuse } : {}),
|
||||
startedAt,
|
||||
finishedAt: new Date(finishedAtMs).toISOString(),
|
||||
|
||||
@@ -320,6 +320,7 @@ export interface RunnerE2EResult {
|
||||
issueId?: string;
|
||||
issueIdentifier?: string | null;
|
||||
runIds?: string[];
|
||||
latencyProbes?: import("./latency-timing.js").LatencyResult[];
|
||||
startupReuse?: Array<{ runId: string } & import("../../packages/adapter-utils/src/startup-reuse.js").StartupReuseSummary>;
|
||||
turnTimings?: Array<{
|
||||
turn: number;
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function createTaskThroughUi(input: {
|
||||
projectName?: string;
|
||||
requireExplicitTitle?: boolean;
|
||||
attachments?: readonly string[];
|
||||
beforeDispatch?: () => void;
|
||||
}) {
|
||||
// Search's public creation action exposes the explicit title field. Strict
|
||||
// native-operation fixtures use it so automatic task naming is not an extra
|
||||
@@ -92,7 +93,6 @@ export async function createTaskThroughUi(input: {
|
||||
await projectSearch.fill(input.projectName);
|
||||
await input.page.getByText(input.projectName, { exact: true }).last().click();
|
||||
}
|
||||
const submittedAtMs = Date.now();
|
||||
if (input.attachments?.length) {
|
||||
const chooser = input.page.waitForEvent("filechooser");
|
||||
const upload = dialog.getByRole("button", { name: "Upload", exact: true });
|
||||
@@ -106,6 +106,8 @@ export async function createTaskThroughUi(input: {
|
||||
}
|
||||
// The prompt-only composer generates a provisional title which the provider
|
||||
// can rename immediately. Bind the fixture to the actual creation response.
|
||||
input.beforeDispatch?.();
|
||||
const submittedAtMs = Date.now();
|
||||
const [response] = await Promise.all([
|
||||
input.page.waitForResponse(response => response.request().method() === "POST"
|
||||
&& /^\/api\/companies\/[^/]+\/issues$/.test(new URL(response.url()).pathname)),
|
||||
|
||||
Reference in new issue
Block a user