diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index cb5acf95d05..da689150188 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -1448,7 +1448,7 @@ "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml" ], - "oracleVersion": "paperclip.chat-catalog.integrator.case-facts.v6", + "oracleVersion": "paperclip.chat-catalog.integrator.case-facts.v7", "expectedRunCount": 6, "minimumRunCount": 4, "expectedHumanMessageCount": 2, @@ -5675,9 +5675,10 @@ "run-observations.ts", "chat-scenarios/task-coordination.yaml" ], - "oracleVersion": "paperclip.chat-catalog.runtime.block-on-design.v7", + "oracleVersion": "paperclip.chat-catalog.runtime.block-on-design.v8", "coverageGaps": [], - "expectedRunCount": 3, + "expectedRunCount": 4, + "minimumRunCount": 3, "expectedHumanMessageCount": 2, "turnTimeoutMs": 120000, "attemptTimeoutMs": 720000, diff --git a/tests/runner-e2e/chat-catalog-domains/integrator-case-facts.ts b/tests/runner-e2e/chat-catalog-domains/integrator-case-facts.ts index 8d905a0339b..5df2bee4fc5 100644 --- a/tests/runner-e2e/chat-catalog-domains/integrator-case-facts.ts +++ b/tests/runner-e2e/chat-catalog-domains/integrator-case-facts.ts @@ -113,7 +113,8 @@ export const integratorCaseFactsCases = [integratorDefinition("cases", "cases.up const worker = await fixtureWorker(ctx); const reviewer = await fixtureWorker(ctx); await ctx.input.api.patch(`/api/agents/${reviewer.id}`, { name: "Riley" }); for (const scope of [{ scopeType: "company", scopeId: ctx.input.fixtures.company.id }, ...[worker.id, reviewer.id].map(scopeId => ({ scopeType: "agent", scopeId }))]) await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/budgets/policies`, { ...scope, amount: 500, hardStopEnabled: true, isActive: true, unpricedUsagePolicy: "allow" }); - const gate = await startRunBriefGate(ctx, worker.id); settings.get(ctx)!.gate = gate; + const gate = await startRunBriefGate(ctx, worker.id, "Customer evidence for Acorn Test Shop: no further reproduction evidence has been received yet. The affected device is still unconfirmed."); + settings.get(ctx)!.gate = gate; const kickoff = await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/case-handoffs`, { idempotencyKey: `facts-${ctx.input.nonce}`, case: { create: { caseType: "customer", title: "Checkout reproduction case", projectId: p.id, fields: { browser: "Safari; affected device not yet confirmed", customer: "Acorn Test Shop", severity: "normal", expected: "Pay should submit a single order" } } }, toAgentId: worker.id, phase: "investigation", @@ -136,7 +137,7 @@ export const integratorCaseFactsCases = [integratorDefinition("cases", "cases.up const row = state.case as CaseRecord; await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/cases/${row.identifier}`, row.title); const task = await ctx.input.api.get(`/api/issues/${row.workIssueId}`); await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/issues/${task.identifier}`, task.title); -}, { oracleVersion: "paperclip.chat-catalog.integrator.case-facts.v6", expectedRunCount: 6, minimumRunCount: 4, expectedHumanMessageCount: 2, attemptTimeoutMs: 900_000, +}, { oracleVersion: "paperclip.chat-catalog.integrator.case-facts.v7", expectedRunCount: 6, minimumRunCount: 4, expectedHumanMessageCount: 2, attemptTimeoutMs: 900_000, runPolicy: { kind: "terminal-counts", terminalStatusCounts: { cancelled: 1, succeeded: 3 }, maximumTerminalStatusCounts: { succeeded: 5 } }, sourceFiles: ["chat-catalog-domains/integrator-case-facts.ts", "chat-catalog-domains/run-roles.ts", "chat-catalog-domains/internal-run-brief.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml"], cleanup, async act(ctx, state) { @@ -154,7 +155,7 @@ export const integratorCaseFactsCases = [integratorDefinition("cases", "cases.up const row = first.caseRecord; await ctx.input.api.patch(`/api/cases/${row.id}`, { fields: { ...(row.fields as Record), supportEscalation: state.collaboratorValue } }); const { sendChatMessage } = await import("../chat-flow.js"); - await sendChatMessage(ctx.input.page, "Support updated this case while we were talking. Read its current saved facts: does it still include my corrected reproduction step, and what support escalation is saved now?"); + await sendChatMessage(ctx.input.page, "Support updated this case while we were talking. Read its current saved facts: does it still include my corrected reproduction detail, and what support escalation is saved now?"); const fresh = await settle(2); state.collaborationCheckpoint = { ...fresh, state: structuredClone(state) }; await ctx.input.evidence("chat-case-concurrent-change.json", state.collaborationCheckpoint); if (!retained(fresh, true)) throw new Error("Chat overwrote or lost the separately saved collaboration change"); const task = await ctx.input.api.get(`/api/issues/${row.workIssueId}`); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-run-brief.ts b/tests/runner-e2e/chat-catalog-domains/internal-run-brief.ts index d52bf591417..612ad093ed7 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-run-brief.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-run-brief.ts @@ -2,13 +2,14 @@ import { createServer, type ServerResponse } from "node:http"; import { randomBytes } from "node:crypto"; import { listenOnFetchAllowedPort } from "../../e2e/fetch-allowed-port.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +const WEBSITE_BRIEF = "The website brief is now available: Acorn Notes needs a semantic heading and an accessible signup link. For this prerequisite, record a short implementation note and complete the task. No website file or delivery is requested yet."; /** A real read-only app holds its first tool response while the product retains the actual worker checkout. */ -export async function startRunBriefGate(ctx: ChatCatalogContext, workerId: string) { +export async function startRunBriefGate(ctx: ChatCatalogContext, workerId: string, briefText = WEBSITE_BRIEF) { const token = randomBytes(32).toString("hex"); ctx.input.privateSecrets?.push(token); const calls: Array<{ sequence: number; authorized: boolean; operation: string; receivedAt: string }> = []; let released = false; const pending: Array<{ response: ServerResponse; id: unknown }> = []; const reply = (response: ServerResponse, id: unknown, result: unknown) => { if (!response.destroyed) { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ jsonrpc: "2.0", id, result })); } }; - const brief = { content: [{ type: "text", text: "The website brief is now available: Acorn Notes needs a semantic heading and an accessible signup link. For this prerequisite, record a short implementation note and complete the task. No website file or delivery is requested yet." }] }; + const brief = { content: [{ type: "text", text: briefText }] }; const server = createServer(async (request, response) => { try { let size = 0; const chunks: Buffer[] = []; for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error("Oversized app message"); chunks.push(chunk); } diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-dependency.test.ts b/tests/runner-e2e/chat-catalog-domains/runtime-dependency.test.ts index fe51397b670..7662fe4f3f4 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-dependency.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-dependency.test.ts @@ -1,11 +1,11 @@ import { expect, it } from "vitest"; -import { gradeDesignDependency, readTaskState } from "./runtime-dependency.js"; +import { gradeDesignDependency, isBlockedInteractionRun, readTaskState } from "./runtime-dependency.js"; import { savedCall } from "./api-read-receipts.test-fixtures.js"; function valid() { return { scenarioId: "task-coordination.block-on-design", companyId: "company", leadId: "ceo", agents: [], links: [], humanQueries: [], reply: "", - tasks: [{ id: "website", assigneeAgentId: "morgan", status: "done" }, { id: "design", status: "done" }], runs: ["save-edge", "implement", "report"].map(id => ({ id, status: "succeeded", agentId: id === "implement" ? "morgan" : "ceo" })), + tasks: [{ id: "website", assigneeAgentId: "morgan", status: "done" }, { id: "design", status: "done" }], runs: ["save-edge", "implement", "report"].map(id => ({ id, status: "succeeded", agentId: id === "implement" ? "morgan" : "ceo" })) as Array<{ id: string; status: string; agentId: string; contextSnapshot?: Record }>, activity: [{ id: "edge", action: "issue.blockers_updated", entityId: "website", agentId: "ceo", details: { addedBlockedByIssueIds: ["design"] } }], state: { task: { id: "website" }, design: { id: "design" }, worker: { id: "morgan" }, waitingTask: { id: "website", companyId: "company", assigneeAgentId: "morgan", blockedBy: [{ id: "design" }] }, - designUnfinishedAtWait: true, blockerVisible: true, noWorkerBeforeApproval: true, attemptedBeforeApproval: true, beforeApprovalStatus: 200, + designUnfinishedAtWait: true, blockerVisible: true, noWorkerBeforeApproval: true, noWebsiteResultBeforeApproval: true, preApprovalInteractionRunIds: [] as string[], attemptedBeforeApproval: true, beforeApprovalStatus: 200, queryReply: "Website now depends on Design and will wait for approval.", humanApprovedDesign: true, workerAdmissionAfterApproval: true, actualWebsiteResult: true, finalReplyAttributed: true, finalReadTaskState: true, finalReply: "Website is complete. Morgan delivered the saved plan after Design approval." } }; } it("accepts a saved first-class blocker, held early admission and one eligible actual result", () => expect(gradeDesignDependency(valid()).passed).toBe(true)); @@ -35,3 +35,31 @@ it("reads Website's saved state only from completed product reads tied to Websit expect(readTaskState([{ eventType: "item.completed", payload: { prpEvent: { sourceKind: "runner", payload: { text: "I read paperclip_get_task website" } } } }], website)).toBe(false); }); +const interaction = (overrides: Record = {}) => ({ id: "triage", status: "succeeded", agentId: "morgan", + contextSnapshot: { issueId: "website", dependencyBlockedInteraction: true, unresolvedBlockerIssueIds: ["design"], wakeReason: "issue_commented", ...overrides } }); +function withInteraction(run = interaction()) { const o = valid(); o.runs.splice(1, 0, run); o.state.preApprovalInteractionRunIds = [run.id]; return o; } +it("accepts a server-marked blocked-interaction reply from Morgan before approval", () => expect(gradeDesignDependency(withInteraction()).passed).toBe(true)); +it("rejects a pre-approval Morgan run the server did not mark as a blocked interaction", () => { + expect(gradeDesignDependency(withInteraction(interaction({ dependencyBlockedInteraction: undefined }))).passed).toBe(false); + expect(gradeDesignDependency(withInteraction(interaction({ unresolvedBlockerIssueIds: [] }))).passed).toBe(false); + expect(gradeDesignDependency(withInteraction(interaction({ issueId: "design" }))).passed).toBe(false); + expect(gradeDesignDependency(withInteraction({ ...interaction(), status: "failed" })).passed).toBe(false); +}); +it("rejects a declared interaction run that is missing from the saved runs", () => { const o = valid(); o.state.preApprovalInteractionRunIds = ["triage"]; expect(gradeDesignDependency(o).passed).toBe(false); }); +it("rejects Website work saved before approval even from an interaction reply", () => { const o = withInteraction(); o.state.noWebsiteResultBeforeApproval = false; expect(gradeDesignDependency(o).passed).toBe(false); }); +it("still requires exactly one Morgan work run after approval alongside an interaction reply", () => { + const o = withInteraction(); o.runs.push({ id: "again", status: "succeeded", agentId: "morgan" }); expect(gradeDesignDependency(o).passed).toBe(false); +}); +it("identifies blocked-interaction runs only from server-stamped context", () => { + expect(isBlockedInteractionRun(interaction(), { id: "website" }, { id: "design" }, "morgan")).toBe(true); + expect(isBlockedInteractionRun(interaction(), { id: "website" }, { id: "design" }, "ceo")).toBe(false); + expect(isBlockedInteractionRun({ id: "x", status: "succeeded", agentId: "morgan" }, { id: "website" }, { id: "design" }, "morgan")).toBe(false); +}); +it("declares a run bound that admits one blocked-interaction reply and no other extra run", async () => { + const { gradeChatCatalogRuns } = await import("../chat-catalog-run-policy.js"); + const { runtimeDependencyCases } = await import("./runtime-dependency.js"); + const run = (id: string, agentId: string) => ({ id, companyId: "company", agentId, status: "succeeded", runtimeMode: "native" }); + const grade = (count: number) => gradeChatCatalogRuns({ implementation: runtimeDependencyCases[0]!, companyId: "company", leadId: "ceo", + runs: ["save-edge", "triage", "implement", "report", "extra"].slice(0, count).map(id => run(id, id === "triage" || id === "implement" ? "morgan" : "ceo")) }).passed; + expect([2, 3, 4, 5].map(grade)).toEqual([false, true, true, false]); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-dependency.ts b/tests/runner-e2e/chat-catalog-domains/runtime-dependency.ts index 05f1d426daa..0f1c2fd50e4 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-dependency.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-dependency.ts @@ -29,9 +29,13 @@ export function readTaskState(events: readonly Record[], task: } async function chooseStatus(page: Page, status: "Todo" | "Done") { await page.getByRole("button", { name: /^Change status \(current:/ }).first().click(); - // Each option's accessible name also carries its status icon's label, so match the visible option text. - // (gha-42937394973: the exact-name locator never resolved while the menu showed "Todo".) - await page.getByRole("button").filter({ visible: true, hasText: new RegExp(`^\\s*${status}\\s*$`) }).first().click(); + await page.getByRole("button", { name: new RegExp(`^${status}(?:\\s*${status})?$`) }).filter({ visible: true }).first().click(); +} +type RunLike = { id: string; agentId?: string | null; status: string; contextSnapshot?: Record | null }; +export function isBlockedInteractionRun(run: RunLike, task: { id: string }, design: { id: string }, workerId: string) { + const context = run.contextSnapshot ?? {}; + return run.agentId === workerId && run.status === "succeeded" && context.dependencyBlockedInteraction === true && context.issueId === task.id + && Array.isArray(context.unresolvedBlockerIssueIds) && context.unresolvedBlockerIssueIds.includes(design.id); } export function gradeDesignDependency(value: unknown) { const c = checks(), o = runtimeObservation(value); @@ -42,12 +46,15 @@ export function gradeDesignDependency(value: unknown) { && o.activity.some(row => row.action === "issue.blockers_updated" && row.entityId === task.id && row.agentId === o.leadId && (row.details?.addedBlockedByIssueIds as string[] | undefined)?.includes(design.id)), "The CEO actually installs the Design edge on the intended Website task; a comment alone is insufficient."); + const interactionIds = Array.isArray(s.preApprovalInteractionRunIds) ? s.preApprovalInteractionRunIds as string[] : []; + const interactions = o.runs.filter(row => interactionIds.includes(row.id)), work = o.runs.filter(row => !interactionIds.includes(row.id)); c.add("blocked-ordinary-admission-and-usable-ui", s.blockerVisible === true && s.noWorkerBeforeApproval === true && s.attemptedBeforeApproval === true - && [200, 422, 409].includes(Number(s.beforeApprovalStatus)), - "Ordinary human start is attempted while Design is unfinished, but no Website native run is admitted; the task UI exposes the exact blocker."); + && [200, 422, 409].includes(Number(s.beforeApprovalStatus)) && s.noWebsiteResultBeforeApproval === true + && interactions.length === interactionIds.length && interactions.every(row => isBlockedInteractionRun(row, task, design, (s.worker as Entity).id)), + "Ordinary human start is attempted while Design is unfinished, but no Website work is admitted (a server-marked blocked-interaction reply to a comment is allowed) and nothing is saved on Website; the task UI exposes the exact blocker."); c.add("one-eligible-worker-after-real-human-approval", s.humanApprovedDesign === true && s.workerAdmissionAfterApproval === true && finished?.status === "done" && finished.assigneeAgentId === (s.worker as Entity).id && o.tasks.length === 2 && s.actualWebsiteResult === true - && o.runs.length === 3 && o.runs.every(row => row.status === "succeeded") && o.runs.filter(row => row.agentId === (s.worker as Entity).id).length === 1, + && work.length === 3 && o.runs.every(row => row.status === "succeeded") && work.filter(row => row.agentId === (s.worker as Entity).id).length === 1, "After actual human Design completion clears the prerequisite, ordinary admission runs Morgan once and produces the genuine saved Website result."); // The saved edge, admission and document prove the outcome; a person reads the replies for meaning. c.add("later-own-phase-reports-actual-result", s.finalReplyAttributed === true && String(s.finalReply ?? "").trim().length > 0 && s.finalReadTaskState === true, @@ -63,12 +70,18 @@ export const runtimeDependencyCases = [runtimeDefinition("task-coordination.bloc description: `Implement the approved Design as a small website plan. Once eligible, read the actual Design task and save a task document with the exact reference ${marker}, accessible labels and a clear primary action, then finish normally. Do not deploy, create other tasks or invent approval.` }); return { worker, task, design, marker }; }, gradeDesignDependency, { - expectedHumanMessageCount: 2, expectedRunCount: 3, attemptTimeoutMs: 720_000, oracleVersion: "paperclip.chat-catalog.runtime.block-on-design.v7", + expectedHumanMessageCount: 2, expectedRunCount: 4, minimumRunCount: 3, attemptTimeoutMs: 720_000, oracleVersion: "paperclip.chat-catalog.runtime.block-on-design.v8", sourceFiles: ["chat-catalog-domains/runtime-dependency.ts", "chat-catalog-domains/api-read-receipts.ts", "chat-catalog-domains/runtime-support.ts", "run-observations.ts", "chat-scenarios/task-coordination.yaml"], async act(ctx, state) { - await pollUntil({ label: "Saved dependency and canonical reply before human approval", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000), + const workerId = (state.worker as Entity | undefined)?.id ?? "", websiteTask = state.task as Entity, gate = state.design as Entity; + const settled = await pollUntil({ label: "Saved dependency and canonical reply before human approval", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000), load: async () => { await ctx.refreshIssue(); return { source: ctx.issue(), runs: await ctx.allRuns() }; }, - accept: v => chatCatalogNativePhaseIsTimely(ctx) && v.source?.conversationState === "waiting" && v.runs.length === 1 && v.runs[0]?.status === "succeeded" }); + accept: v => chatCatalogNativePhaseIsTimely(ctx) && v.source?.conversationState === "waiting" && v.runs.filter(row => row.agentId !== workerId).length === 1 + && v.runs.every(row => row.status === "succeeded") && v.runs.filter(row => row.agentId === workerId).every(row => isBlockedInteractionRun(row, websiteTask, gate, workerId)), + reject: v => v.runs.filter(row => row.agentId !== workerId).length > 1 || v.runs.some(row => ["failed", "cancelled", "timed_out"].includes(row.status)) + || v.runs.some(row => row.agentId === workerId && !isBlockedInteractionRun(row, websiteTask, gate, workerId) && row.status !== "queued" && row.status !== "running") + ? "Work was admitted on Website, or a pre-approval run failed or duplicated, before Design approval" : undefined }); + state.preApprovalInteractionRunIds = settled.runs.filter(row => row.agentId === workerId).map(row => row.id); state.queryReply = (await observeRuntime(ctx, state)).reply; const source = ctx.input.page.url(), task = state.task as Entity, design = state.design as Entity, websiteRoot = `/api/issues/${task.id}`; state.waitingTask = await ctx.input.api.get(websiteRoot); @@ -86,12 +99,15 @@ export const runtimeDependencyCases = [runtimeDefinition("task-coordination.bloc await expect.poll(async () => await blockedBy.isVisible() || await propertiesTab.isVisible()).toBe(true); if (!await blockedBy.isVisible()) await propertiesTab.click(); await expect(blockedBy.getByText(design.identifier ?? "Design").first()).toBeVisible(); state.blockerVisible = true; - const pending = ctx.input.page.waitForResponse(row => row.request().method() === "PATCH" && new URL(row.url()).pathname === websiteRoot); + const websitePaths = [websiteRoot, ...(task.identifier ? [`/api/issues/${task.identifier}`] : [])]; + const pending = ctx.input.page.waitForResponse(row => row.request().method() === "PATCH" && websitePaths.includes(new URL(row.url()).pathname)); await chooseStatus(ctx.input.page, "Todo"); state.beforeApprovalStatus = (await pending).status(); state.attemptedBeforeApproval = true; // Reload and independent reads observe the real admission gate, rather // than setting a run's blocked status or pretending the worker executed. await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); - state.noWorkerBeforeApproval = (await ctx.allRuns()).every(row => row.agentId !== (state.worker as Entity).id); + state.noWorkerBeforeApproval = (await ctx.allRuns()).every(row => row.agentId !== workerId || (state.preApprovalInteractionRunIds as string[]).includes(row.id)); + state.noWebsiteResultBeforeApproval = (await ctx.input.api.get(`${websiteRoot}/documents`)).length === 0 + && !["in_progress", "in_review", "done"].includes((await ctx.input.api.get(websiteRoot)).status); await ctx.input.capture("actual-design-dependency-wait", "Website retains its actual Design blocker before human approval", "runtime-design-blocker.png"); await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/issues/${design.identifier ?? design.id}`, { waitUntil: "domcontentloaded" }); await chooseStatus(ctx.input.page, "Done"); @@ -103,11 +119,12 @@ export const runtimeDependencyCases = [runtimeDefinition("task-coordination.bloc await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/issues/${task.identifier ?? task.id}`, { waitUntil: "domcontentloaded" }); await chooseStatus(ctx.input.page, "Todo"); } + const interactionCount = (state.preApprovalInteractionRunIds as string[]).length; const result = await pollUntil({ label: "One ordinary Website admission after Design approval", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 240_000), load: async () => ({ runs: await ctx.allRuns(), task: await ctx.input.api.get(websiteRoot) }), - accept: v => chatCatalogNativePhaseIsTimely(ctx) && v.runs.length === 2 && v.runs.every(row => row.status === "succeeded") && v.task.status === "done", - reject: v => v.runs.length > 2 || v.runs.some(row => ["failed", "cancelled", "timed_out"].includes(row.status)) ? "Eligible Website admission failed or duplicated native work" : undefined }); - const worker = result.runs.find(row => row.agentId === (state.worker as Entity).id)!; + accept: v => chatCatalogNativePhaseIsTimely(ctx) && v.runs.length === 2 + interactionCount && v.runs.every(row => row.status === "succeeded") && v.task.status === "done", + reject: v => v.runs.length > 2 + interactionCount || v.runs.some(row => ["failed", "cancelled", "timed_out"].includes(row.status)) ? "Eligible Website admission failed or duplicated native work" : undefined }); + const worker = result.runs.find(row => row.agentId === workerId && !(state.preApprovalInteractionRunIds as string[]).includes(row.id))!; const designActivity = await ctx.input.api.get(`/api/issues/${design.id}/activity`); const approval = designActivity.find(row => row.action === "issue.updated" && row.actorType === "user" && row.details?.status === "done"); const workerDetail = await ctx.input.api.get(`/api/heartbeat-runs/${worker.id}`); @@ -117,7 +134,7 @@ export const runtimeDependencyCases = [runtimeDefinition("task-coordination.bloc await sendChatMessage(ctx.input.page, followup, chatCatalogNativePhaseRemainingBudget(ctx)); const final = await pollUntil({ label: "Own native reply reporting actual post-approval Website outcome", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000), load: async () => { await ctx.refreshIssue(); return { source: ctx.issue(), runs: await ctx.allRuns() }; }, - accept: v => chatCatalogNativePhaseIsTimely(ctx) && v.source?.conversationState === "waiting" && v.runs.length === 3 && v.runs.every(row => row.status === "succeeded") }); + accept: v => chatCatalogNativePhaseIsTimely(ctx) && v.source?.conversationState === "waiting" && v.runs.length === 3 + interactionCount && v.runs.every(row => row.status === "succeeded") }); const run = final.runs.find(row => !result.runs.some(prior => prior.id === row.id))!, comments = await ctx.input.api.get(`/api/issues/${ctx.issue()!.id}/comments?order=asc`); const human = comments.filter(row => !row.authorAgentId && row.body === followup), replies = comments.filter(row => row.authorAgentId === ctx.input.fixtures.agent.id && row.createdByRunId === run.id); const detail = await ctx.input.api.get<{ runnerProfileJson?: { nativeExecutionInput?: { task?: { prompt?: string } } } }>(`/api/heartbeat-runs/${run.id}`);