Files
PaperClipAI/tests/runner-e2e/chat-catalog-flow.ts
T
DottaandPaperclip 192d83e828 Fix chat catalog prerequisite clocks and retained evidence (#18377)
## Thinking Path

> - Paperclip evals must prove actual work and retain inspectable
evidence.
> - Project defaults must reach new work while prior work remains
active.
> - A hidden HTTP deadline invalidated that prerequisite before the case
clock.
> - Report rewriting and missing evidence files obscured real outcomes.
> - Observer assumptions also rejected valid history, reads and lease
bookkeeping.
> - This PR repairs those layers and keeps the original feature
requirements and clocks.

## Linked Issues or Issue Description

Refs #18364 for product repairs. Refs #18170 for protected catalog
evidence.

The held worker's native fetch had a five-minute header timeout.
Successful cases lacked required files. Restart compared stored Markdown
to rendered text. Diagnostic redaction changed trusted Playwright
program files, causing the existing producer guard to reject
failed-report retention. Valid dedicated project reads and automatic
environment leases were also misclassified.

This PR targets **`codex/chat-catalog-master-integration`**, not master.
Its base combines master `51ea624344`, preserved catalog `e2b35be76f`
and all product/test changes from #18364, including the native chat
response-stage contract repair. The frozen preservation branch remains
unchanged. The full catalog stays off master. Shipped subscription
aliases retain their 120-second turn limit.

## What Changed

- Hold the exact worker with native HTTP, a one-use agent and the
original absolute deadline. Keep its capability in an owned 0700
directory / 0600 script outside evidence trees. Put only its path in
task/tool inputs. Register the token with the existing secret scanner.
Remove file/listener after normal execution and failed setup.
- Use the already-selected original native case deadline for
prerequisite readiness. Consume only public lifecycle state and fixed
recovery diagnostics, preserve manual-repair/terminal failures, and
record bounded state transitions and recovery flags. Other lanes retain
their authored 45-second setup cap. Nine tests pass actual server
diagnostics through the public agent normalizer.
- Poll canonical source state with the existing settlement predicate;
collect full domain/browser evidence after settlement.
- Capture required `final-state.png` and actual `api-state.json`. Keep
restart logs inside owned roots and recover abnormal evidence from the
actual capture root.
- Compare attributed rendered history before/after restart.
Independently require exact stored bytes, identity and no duplicate
admissions.
- Authenticate and scan canonical Playwright program files with the
existing validator before preserving their exact bytes. Keep ordinary
redaction and every original path, producer, secret, size and transfer
guard. Trusted protocol files remain unchanged.
- Expose only fixed diagnostics, recognized tool names and bounded
counts. Keep raw provider output private.
- Accept correlated `list_projects` reads only when the same native call
delivered the exact full saved target-project defaults. Cover real
OpenCode and server event producers; reject stale, truncated, foreign
and unpaired reads.
- Version project-defaults as **v4**. Accept natural “newly admitted”
future-scope wording; still reject replies missing the future/existing
distinction. Exclude only automatic lease acquisition/release with the
exact `environment_lease` entity type from settings-change rejection.
Retain rejection of actual budget/environment edits, mismatched types
and unknown events. Keep active-work, inheritance and reply checks
strict.

## Verification

**Qualified: [pilot
41256592937](https://github.com/paperclipai/paperclip/actions/runs/41256592937)
passes 3/3, with all 35 independent checks passing.**

| Case | Checks | Full case duration | Evidence and cleanup |
| --- | --- | --- | --- |
| Instance restart | 9/9 | 140.1 s | complete |
| Project defaults | 11/11 | 204.2 s | complete |
| Project work | 15/15 | 204.0 s | complete |

All three are original-attempt results from source
`c51e079f7f2364305b131ffd549e263ff78c5335`, containing the exact product
files from `13c2b61f31d873a475ea0c3c026b19926ff42301`. Actual saved
conversation contracts were inspected, as were ordinary native worker
contracts where present. Each protected archive includes the actual API
state and final PNG; cleanup completes. Original queries,
`openrouter/deepseek/deepseek-v4.1-flash-1017`, finite case clocks,
concurrency one and zero eval retries are preserved. Earlier passes do
not transfer into this qualification.

Both current heads pass all checks: product **62** (56 success, one
neutral, five skipped), catalog **56** (54 success, one neutral, one
skipped). Both have exact-head Greptile **5/5**, zero unresolved review
threads and clean mergeability. The three trusted private-evidence
protocol files remain byte-identical to the dispatch workflow. Raw
evidence stays protected.

- **202 focused catalog/evidence tests and catalog typecheck pass.**
Nine readiness controls consume actual lifecycle diagnostics through the
public agent normalizer, which omits private operation state. Manual
repair, terminal states and unknown errors still fail.
- Real Node 24 control: old fetch fails at 301,464 ms; native HTTP
completes after release at 310,054 ms under the same 340-second control
deadline. Actual native OpenCode/Runner also completes a separate source
while the held worker stays blocked. Failed-setup regressions verify
resource cleanup.
- All **45 privacy/report controls pass**. The old failed-report trace
rewriting reproduces the producer-guard rejection; authenticated
canonical program bytes now survive unchanged. Contaminated assets still
fail. No secret, producer, path, transfer, expansion or size guard is
weakened.
- Oracle **`paperclip.chat-catalog.project-defaults.v4`** retains
active-work, inheritance, actual mutation/read and reply requirements.
Positive and negative controls cover correlated full saved-default
reads, automatic lease bookkeeping and natural “newly admitted” wording.
Original failed grades remain retained alongside explicit versioned
regrades.
- Product validation in #18364 includes 2,044 discovery tests, 112
real-database/HTTP/native-runner/OpenAPI consumer tests, and passing
typecheck/build. Task-owned Docker PostgreSQL unblocked local DB checks
and was removed afterward.

Retained attempt ledger; no earlier result substitutes for the qualified
run:

| Attempt | Recorded outcome / finding |
| --- | --- |
| 41114636294 / 41119215255 / 41203017450 | Initial report/setup and
unavailable-archive failures. Functional partial success did not qualify
the reports. |
|
[41207155025](https://github.com/paperclipai/paperclip/actions/runs/41207155025)
| 0/3. Rendered-Markdown mismatch; other archives unavailable. |
|
[41211541655](https://github.com/paperclipai/paperclip/actions/runs/41211541655)
| Work-only diagnostic: 15/15 with required files and cleanup. |
|
[41212946325](https://github.com/paperclipai/paperclip/actions/runs/41212946325)
| 2/3. Defaults did not settle; transfer failed. |
|
[41216880087](https://github.com/paperclipai/paperclip/actions/runs/41216880087)
| Defaults failed; counters did not identify finish outcomes. |
|
[41219519639](https://github.com/paperclipai/paperclip/actions/runs/41219519639)
| Native work succeeded but grader did not; report assets and expansion
limits rejected transfer. |
|
[41222196525](https://github.com/paperclipai/paperclip/actions/runs/41222196525)
| 1/3. Restart exposed fabricated empty-response success; defaults
exposed backfill and missing dedicated-read recognition. All archives
retained. |
| 41226224666 / 41226710271 | Cancelled before paid cells to repair
generated outputs, credential custody and native event-shape coverage. |
|
[41227965230](https://github.com/paperclipai/paperclip/actions/runs/41227965230)
| 2/3. Defaults 9/11 correctly rejected existing-task backfill and reply
scope. |
|
[41233723348](https://github.com/paperclipai/paperclip/actions/runs/41233723348)
| Restart 9/9. Defaults 10/11: v2 misclassified automatic lease events.
Work was cancelled because replacement was dispatched prematurely; no
work pass inferred. |
|
[41236679567](https://github.com/paperclipai/paperclip/actions/runs/41236679567)
| 1/3. Defaults 11/11. Restart 4/9: no answer. Work failed and the
unchanged ZIP expansion guard rejected full retention; missing outcome
remains inconclusive. |
|
[41240010122](https://github.com/paperclipai/paperclip/actions/runs/41240010122)
| Restart 4/9 under authority guidance: its literal native criterion
still required the unavailable action. Defaults 10/11: v3 rejected valid
“newly admitted” wording; v4 explicitly regrades 11/11 while retaining
the original grade and privacy findings. Work 10/15: created/repaired a
task without final chat reply. All three archives retained. |
|
[41243833425](https://github.com/paperclipai/paperclip/actions/runs/41243833425)
| Source `6fa0c83674` / product `dddd2d5206`: 2/3. Restart 9/9 and work
15/15; defaults failed prerequisite setup before any canonical
query/native run because its 45-second helper cap cut short a normal
60-second probe. |
| 41247798414 | Cancelled before paid cells after review caught a
private-field assumption in the readiness helper. The repair uses public
lifecycle state and fixed recovery diagnostics. |
| 41248428190 | Cancelled before paid cells after the unresolved-thread
sweep caught a conversation identity edge case. The repair uses
canonical identity and carries the user ID through heartbeat. |
|
[41250502608](https://github.com/paperclipai/paperclip/actions/runs/41250502608)
| Source `7b1de24ce1` / product `c0bf249807`: 1/3. Work 15/15. Restart
4/9 despite the repaired contract actually being admitted; it continued
discovery after board-only 403 and had no reply. Nearby HTTP 200 reads
are not call-correlated to the MCP timeouts. Defaults timed out before
settlement (zero finish proposals), then exceeded the unchanged 256 MiB
original-evidence bound; detailed domain behavior remains inconclusive.
|

## Risks

The prerequisite needs native Node in the worker environment. Trusted
program bytes remain subject to the existing authentication and
scanners. The audit exception rejects unknown events. Oversized ZIPs can
still be rejected, as in the retained work attempt; this PR does not
weaken that guard or claim an unobserved outcome. A passing pilot
qualifies the selected attempts, not full-catalog reliability.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, tool use, code
execution and live eval tooling. Exact hosted model revision and
context-window size are not exposed.

## 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
#` 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

- [x] Final same-source three-cell pilot passes 3/3 with required
evidence and cleanup

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-31 19:52:57 -05:00

213 lines
17 KiB
TypeScript

import { assertSdkCanaryFingerprintMatches } from "./sdk-canary-prefixture.js";
import { collectConversationLinks, type ConversationLinkInput } from "./chat-rendered-links.js";
import { chatCatalogAgentIsReady, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "./chat-catalog-contract.js";
import { chatNativeTimeoutEvidence } from "./chat-native-timeout-policy.js";
import { completeNativeSlackFileConditional, dispatchNativeSlackIngress, prepareNativeSlackOtherThread, slackOtherThreadBoundaryMatches, slackNativeRunCheck } from "./chat-slack-ingress.js";
import { runScopedCompanionSurface } from "./chat-companion-surface.js";
import { chatQueryWithAttachments, resolveChatQueryAttachments, verifyChatQueryAttachments, type ChatQueryAttachmentReceipt } from "./chat-query-attachments.js";
import { chatCatalogRunStatusAllowed, gradeChatCatalogRuns, createChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
import { chatCatalogCaseFingerprint, normalizedChatCatalogExecution } from "./chat-catalog-fingerprint.js";
import { expect } from "@playwright/test";
import { pollUntil } from "./api.js";
import { collectChatRunEvidence, sendChatMessage } from "./chat-flow.js";
import { prepareNativeStopCanonicalBoundary } from "./chat-native-stop-boundary.js";
import { chatCatalogHumanHistoryMatches, validateChatCatalogGrade, type ChatCatalogSetupBoundary, type ChatCatalogContext, type ChatCatalogGrade } from "./chat-catalog-contract.js";
import { findChatCatalogCase, loadChatCatalogImplementation } from "./chat-catalog-registry.js";
import { chatCatalogInventoryDigest } from "./chat-catalog-inventory.js";
import { resolveChatCatalogQuery } from "./chat-query-bindings.js";
import { chatRunEvidenceApi } from "./chat-run-evidence-observer.js";
/** The case owns product semantics; this flow owns the actual chat entry point and evidence lifecycle. */
export async function runChatCatalogFlow(context: ChatCatalogContext) {
const { input, scenario } = context;
const descriptor = findChatCatalogCase(scenario.id);
if (!descriptor) throw new Error(`No executable chat scenario: ${scenario.id}`);
const fingerprint = chatCatalogCaseFingerprint(descriptor, normalizedChatCatalogExecution(input.execution));
assertSdkCanaryFingerprintMatches(input.execution, fingerprint);
const implementation = await loadChatCatalogImplementation(descriptor);
if (implementation.companionSurface) return runScopedCompanionSurface(context, implementation, fingerprint);
const slack = implementation.journeyKind === "native-slack-thread";
const deadlineAt = Date.now() + implementation.attemptTimeoutMs - 30_000;
Object.assign(context, { observationDeadlineAt: deadlineAt });
let state: Record<string, unknown> | undefined;
let observation: unknown;
let grade: ChatCatalogGrade | undefined;
let failed = false;
let renderedQuery = scenario.query;
let persistedQuery = scenario.query;
let queryAttachments: ChatQueryAttachmentReceipt[] = [];
let setupBoundary: ChatCatalogSetupBoundary = { humanCommentIds: [], runIds: [] };
async function conversation() {
await context.refreshIssue();
const issue = context.issue();
const runs = await context.allRuns();
const comments = issue ? await input.api.get<Array<{ id: string; body: string; authorAgentId?: string; authorUserId?: string; issueId?: string; companyId?: string; createdByRunId?: string }>>(
`/api/issues/${issue.id}/comments?order=asc`,
) : [];
const hasCreationCard = await input.page.locator('[data-testid="task-chat-thread"] article[aria-label^="Project created: "]').count() > 0;
const [projects, activity] = hasCreationCard ? await Promise.all([
input.api.get<ConversationLinkInput["projects"]>(`/api/companies/${input.fixtures.company.id}/projects?includeArchived=true`),
input.api.get<ConversationLinkInput["activity"]>(`/api/companies/${input.fixtures.company.id}/activity?limit=1000`),
]) : [[], []];
const renderedLinks = issue ? await collectConversationLinks(input.page, {
origin: input.api.baseURL, companyId: input.fixtures.company.id, companyPrefix: input.fixtures.company.issuePrefix,
sourceId: issue.id, leadId: input.fixtures.agent.id, comments, runs, projects, activity,
}) : [];
const evidence = { issue, runs, comments, renderedLinks };
await input.evidence("chat-catalog-conversation.json", evidence);
return evidence;
}
async function refresh() {
const evidence = await conversation();
observation = await implementation!.observe(context, state!);
grade = validateChatCatalogGrade(implementation!.grade(observation));
const human = evidence.comments.filter(comment => !comment.authorAgentId);
const canonicalHuman = human[implementation!.setupHumanMessageCount ?? 0];
const attachmentsValid = !queryAttachments.length || !!canonicalHuman && await verifyChatQueryAttachments(context, queryAttachments, canonicalHuman);
if (queryAttachments.length) await input.evidence("chat-catalog-query-attachment-receipts.json", { receipts: queryAttachments,
canonicalCommentId: canonicalHuman?.id ?? null, verified: attachmentsValid });
const checks = [...grade.checks,
{ id: "catalog-complete-conditions", passed: !implementation!.coverageGaps?.length,
detail: implementation!.coverageGaps?.length ? `Missing conditions: ${implementation!.coverageGaps.join(", ")}` : "No known catalog condition is omitted." },
{ id: "catalog-original-human-query", passed: chatCatalogHumanHistoryMatches({ implementation: implementation!, human, runIds: evidence.runs.map(run => run.id), boundary: setupBoundary, renderedQuery: persistedQuery }),
detail: "The persisted request preserves the catalog query with only audited public-fixture identity bindings; all human turns were declared." },
{ id: "catalog-query-attachment-receipts", passed: attachmentsValid,
detail: "Only declared production attachment suffixes are allowed, backed by same-source human receipts and independently retrieved matching bytes." },
...(slack ? [slackNativeRunCheck(context, evidence.runs, state)] : []),
...gradeChatCatalogRuns({ implementation: implementation!, companyId: input.fixtures.company.id,
leadId: input.fixtures.agent.id, source: evidence.issue, runs: evidence.runs, observation }).checks,
];
grade = { passed: checks.every(check => check.passed), checks };
await input.evidence("chat-catalog-observation.json", { scenarioId: scenario.id, inventoryDigest: chatCatalogInventoryDigest,
oracleVersion: implementation!.oracleVersion, observation, grade });
return { grade, runs: evidence.runs, issue: evidence.issue };
}
try {
await input.evidence("chat-catalog-definition.json", { scenarioId: scenario.id, ...fingerprint,
suiteDefinitionHash: input.execution.suiteDefinitionHash, journeyKind: implementation.journeyKind ?? "agent-chat" });
state = await implementation.setup(context);
const query = await resolveChatCatalogQuery(context, implementation.queryBindings, state);
renderedQuery = query.renderedQuery;
if (implementation.queryOutcome === "rejected" && implementation.queryAttachments?.length) throw new Error("Rejected-query attachment branches need an explicit contract");
queryAttachments = await resolveChatQueryAttachments(context, implementation.queryAttachments, state);
persistedQuery = chatQueryWithAttachments(renderedQuery, queryAttachments);
if (queryAttachments.length) await input.evidence("chat-catalog-query-attachments-before-send.json", {
originalQuery: scenario.query, renderedQuery, persistedQuery, receipts: queryAttachments });
await input.evidence("chat-catalog-query-binding.json", { scenarioId: scenario.id, ...query });
const company = `/api/companies/${input.fixtures.company.id}`;
const agents = await input.api.get<Array<{ id: string }>>(`${company}/agents`);
if (implementation.setupPolicy?.readiness !== "preserve-scenario") for (const agent of agents) await pollUntil({ label: `catalog agent readiness ${agent.id}`,
deadlineAt: Math.min(deadlineAt, Date.now() + 45_000), intervalMs: 500,
load: () => input.api.get<{ lifecycleState: string; status: string; lifecycleError?: string }>(`/api/agents/${agent.id}`),
accept: chatCatalogAgentIsReady,
reject: value => value.lifecycleError ? "Agent setup failed; diagnostic details withheld" : undefined,
});
const scopes = [{ scopeType: "company", scopeId: input.fixtures.company.id },
...agents.map(agent => ({ scopeType: "agent", scopeId: agent.id }))];
if (implementation.setupPolicy?.funding !== "preserve-scenario") for (const scope of scopes) await input.api.post(`${company}/budgets/policies`, { ...scope, amount: 500,
hardStopEnabled: true, isActive: true, unpricedUsagePolicy: "allow" });
const funding = await input.api.get<{ policies: Array<{ scopeType: string; scopeId: string; amount: number; hardStopEnabled: boolean; isActive: boolean; unpricedUsagePolicy: string }> }>(`${company}/budgets/overview`);
if (implementation.setupPolicy?.funding !== "preserve-scenario") expect(scopes.every(scope => funding.policies.some(policy => policy.scopeType === scope.scopeType && policy.scopeId === scope.scopeId
&& policy.amount === 500 && policy.hardStopEnabled && policy.isActive && policy.unpricedUsagePolicy === "allow")), "Public funding policy persisted").toBe(true);
await input.evidence("chat-catalog-funding.json", { scopes, mode: implementation.setupPolicy?.funding ?? "standard",
policies: funding.policies.filter(policy => scopes.some(scope => scope.scopeType === policy.scopeType && scope.scopeId === policy.scopeId)), setupProbeUsage: "unknown; production hello probes are outside heartbeat accounting" });
if (slack && implementation.slackIngress?.setupThread === "same-agent-distractor") await prepareNativeSlackOtherThread(context,implementation,state,deadlineAt);
const savedLead = await input.api.get<{ adapterConfig?: { timeoutSec?: unknown } }>(`/api/agents/${input.fixtures.agent.id}`);
const nativeTimeoutPolicy = chatNativeTimeoutEvidence({ selectedTurnTimeoutMs: input.execution.task.turnTimeoutMs,
authoredLegacyTurnTimeoutMs: implementation.turnTimeoutMs, attemptTimeoutMs: implementation.attemptTimeoutMs,
observationBudgetMs: implementation.attemptTimeoutMs - 30_000,
savedAgentTimeoutSec: savedLead.adapterConfig?.timeoutSec });
await input.evidence("chat-catalog-setup.json", { nativeTimeoutPolicy, scenario, state, inventoryDigest: chatCatalogInventoryDigest,
oracleVersion: implementation.oracleVersion, expectedRunCount: implementation.expectedRunCount,
minimumRunCount: implementation.minimumRunCount, effectiveNativeTurnTimeoutMs: input.execution.task.turnTimeoutMs,
authoredLegacyTurnTimeoutMs: implementation.turnTimeoutMs,
attemptTimeoutMs: implementation.attemptTimeoutMs });
if (implementation.verifySetup) {
const setupGrade = validateChatCatalogGrade(await implementation.verifySetup(context, state));
await input.evidence("chat-catalog-prepared-state.json", setupGrade);
expect(setupGrade.passed && (implementation.setupPolicy?.invariantCheckIds ?? []).every(id => setupGrade.checks.some(check => check.id === id && check.passed)), "Independent scenario prerequisites").toBe(true);
}
const setupConversation = await conversation();
const setupHuman = setupConversation.comments.filter(comment => !comment.authorAgentId);
expect(setupHuman.length, "Only declared genuine pre-query human turns exist").toBe(implementation.setupHumanMessageCount ?? 0);
expect(setupConversation.runs.length <= implementation.expectedRunCount, "Setup runs count toward the total native run bound").toBe(true);
setupBoundary = { humanCommentIds: setupHuman.map(comment => comment.id), runIds: setupConversation.runs.map(run => run.id) };
await input.evidence("chat-catalog-setup-boundary.json", { ...setupBoundary, setupHumanMessageCount: implementation.setupHumanMessageCount ?? 0,
expectedHumanMessageCount: implementation.expectedHumanMessageCount ?? 1, expectedRunCount: implementation.expectedRunCount });
await input.evidence("chat-catalog-query.json", { query: scenario.query, expectedOutcome: implementation.queryOutcome ?? "persisted", journeyKind: implementation.journeyKind ?? "agent-chat" });
if (slack) {
const other=state.slackOtherThreadSetupReceipt as (import("./chat-catalog-contract.js").ChatSlackIngressReceipt & {runId:string})|undefined;
const declaredOther=implementation.scenarioId==="chat-channels.reply-in-thread"&&implementation.slackIngress?.setupThread==="same-agent-distractor";
if (setupBoundary.humanCommentIds.length || (declaredOther ? !other || !slackOtherThreadBoundaryMatches({receipt:other,runs:setupConversation.runs,companyId:input.fixtures.company.id,agentId:input.fixtures.agent.id}) : setupBoundary.runIds.length!==0)) throw new Error("Native Slack setup cannot admit undeclared canonical or unrelated work");
state.slackIngressReceipt = await dispatchNativeSlackIngress(context, implementation, state, deadlineAt);
await input.page.goto(new URL(`/${input.fixtures.company.issuePrefix}/issues/${context.issue()!.id}`, input.api.baseURL).href);
if (implementation.scenarioId === "chat-channels.channel-file") await completeNativeSlackFileConditional(context, implementation, state, deadlineAt);
} else {
await prepareNativeStopCanonicalBoundary(context, implementation, state, renderedQuery, setupBoundary);
await sendChatMessage(input.page, renderedQuery, chatCatalogNativePhaseRemainingBudget(context));
}
if (implementation.queryOutcome === "rejected") {
const rejection = validateChatCatalogGrade(await implementation.verifyQueryRejection!(context, state));
await input.evidence("chat-catalog-query-rejection.json", rejection);
expect(rejection.passed, "Real browser/API query rejection").toBe(true);
}
if (implementation.act) await implementation.act(context, state);
let settledAt: number | undefined;
let passedAt: number | undefined;
await pollUntil({ label: scenario.id, deadlineAt, intervalMs: 1_000, load: refresh,
accept: result => {
if (!chatCatalogNativePhaseIsTimely(context)) return false;
if (!result.grade.passed) { passedAt = undefined; return false; }
passedAt ??= Date.now();
return Date.now() - passedAt >= 5_000;
},
reject: result => {
if (result.runs.length > implementation.expectedRunCount) return "Scenario exceeded its declared run bound";
if (result.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status) && !chatCatalogRunStatusAllowed({ implementation,
run, companyId: input.fixtures.company.id, leadId: input.fixtures.agent.id, source: result.issue, observation }))) return "Observed provider run failed";
if (result.runs.length && result.runs.every(run => !["queued", "running"].includes(run.status)) && result.issue?.conversationState === "waiting") settledAt ??= Date.now();
else settledAt = undefined;
return settledAt && Date.now() - settledAt >= 15_000
? `Settled scenario failed: ${result.grade.checks.filter(check => !check.passed).map(check => check.id).join(", ")}` : undefined;
}, timeoutDetail: result => result?.grade.checks.filter(check => !check.passed).map(check => check.id).join(", "),
});
expect(chatCatalogNativePhaseIsTimely(context), "Original selected native observation budget before final proof").toBe(true);
const originalIssueId = context.issue()?.id;
await input.page.reload({ waitUntil: "domcontentloaded" });
if (!slack) await expect(input.page.getByTestId("task-chat-composer-input")).toBeVisible();
const persisted = await refresh();
expect(context.issue()?.id, "Source chat survives browser reload").toBe(originalIssueId);
expect(persisted.grade.passed, "Independent oracle survives browser reload").toBe(true);
await implementation.verifyResult(context, state, observation);
for (const check of persisted.grade.checks) input.check?.(check.id, check.passed, check.detail);
await input.capture("final-state", scenario.id, "final-state.png");
expect(chatCatalogNativePhaseIsTimely(context), "Original selected native observation budget includes complete final proof").toBe(true);
return createChatCatalogFinalRunVerifier({ implementation, companyId: input.fixtures.company.id,
leadId: input.fixtures.agent.id, observation });
} catch (error) {
failed = true;
throw error;
} finally {
try {
const finalConversation = await conversation();
if (state) await refresh();
const runs = await context.allRuns();
const runEvidence = await Promise.all(runs.map(run => collectChatRunEvidence(chatRunEvidenceApi(input), run)));
await input.evidence("chat-catalog-runs.json", runEvidence);
await input.evidence("api-state.json", { capturePhase: failed ? "failure" : "final",
...finalConversation, runs, runEvidence });
} catch {
await input.evidence("chat-catalog-evidence-error.json", { incomplete: true, scenarioId: scenario.id });
if (!failed) throw new Error("Scenario evidence collection failed");
} finally {
try {
if (state) await implementation.cleanup?.(context, state);
await input.evidence("chat-catalog-cleanup.json", { scenarioId: scenario.id, caseCleanup: "complete", instanceCleanup: "owned by existing launcher" });
} catch (error) {
await input.evidence("chat-catalog-cleanup.json", { scenarioId: scenario.id, caseCleanup: "failed" });
if (!failed) throw error;
}
}
}
}