diff --git a/tests/runner-e2e/chat-catalog-contract.ts b/tests/runner-e2e/chat-catalog-contract.ts index 571d6761833..52b7b14faa8 100644 --- a/tests/runner-e2e/chat-catalog-contract.ts +++ b/tests/runner-e2e/chat-catalog-contract.ts @@ -41,6 +41,13 @@ const originalClockExecutionIds = new Set([ "chat-features.runner-opencode.local.tasks.edit-properties", "chat-features.runner-opencode.local.steering.edit-queued-message", "chat-features.runner-opencode.local.steering.discard-queued-message", + "chat-features.runner-opencode.local.instance-operations.backup-and-restore", + "chat-features.runner-opencode.local.instance-operations.restart-service", + "chat-features.runner-opencode.local.routines.webhook-trigger", + "chat-features.runner-opencode.local.status-cards.explain-card", + "chat-features.runner-opencode.local.secrets.configure-company-secret", + "chat-features.runner-opencode.local.teams.configure-team", + "chat-features.runner-opencode.local.steering.interrupt-with-direction", ]); function usesOriginalNativePhaseClock(ctx: ChatCatalogContext): boolean { return originalClockExecutionIds.has(ctx.input?.execution?.id); @@ -60,6 +67,10 @@ export function chatCatalogNativePhaseRemainingBudget(ctx: ChatCatalogContext): return deadline! - now; }; } +/** Only explicitly selected lanes opt identity helpers into the original clock. */ +export function chatCatalogOriginalPhaseDeadline(ctx: ChatCatalogContext): number | undefined { + return usesOriginalNativePhaseClock(ctx) ? chatCatalogNativePhaseDeadline(ctx, 0) : undefined; +} export function chatCatalogNativePhaseIsTimely(ctx: ChatCatalogContext): boolean { return !usesOriginalNativePhaseClock(ctx) || Number.isFinite(ctx.observationDeadlineAt) && Date.now() < ctx.observationDeadlineAt!; } diff --git a/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts b/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts index 08f2debe9f4..f1ba411f862 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-company-secret.ts @@ -1,3 +1,4 @@ +import { chatCatalogNativePhaseRemainingBudget, chatCatalogOriginalPhaseDeadline } from "../chat-catalog-contract.js"; import { randomBytes } from "node:crypto"; import { expect } from "@playwright/test"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; @@ -37,7 +38,7 @@ export const companySecretExternalCases=[definition("secrets.configure-company-s authentication:"human-session",expectedRunCount:2,expectedHumanMessageCount:2,attemptTimeoutMs:480000, sourceFiles:["chat-catalog-domains/external-company-secret.ts","chat-catalog-domains/external-private-browser.ts","chat-catalog-domains/external-private-browser-worker.ts","chat-catalog-domains/external-credential-language.ts","chat-catalog-domains/external-secret-scan.ts","chat-catalog-domains/external-support.ts","chat-catalog-domains/internal-support.ts","chat-catalog-domains/identity-support.ts","run-observations.ts","chat-flow.ts","chat-scenarios/secrets.yaml"], async act(ctx,state){ - await waitForReply(ctx);const initial=await observeExternal(ctx,state),reply=initial.phaseReplies.at(-1);if(!reply||!deploymentSecretGuidance(reply.body))throw new Error("Actual reply must direct secure deployment-key entry and Morgan-only reference binding before human action");state.initialRunId=reply.runId; + await waitForReply(ctx, chatCatalogOriginalPhaseDeadline(ctx));const initial=await observeExternal(ctx,state),reply=initial.phaseReplies.at(-1);if(!reply||!deploymentSecretGuidance(reply.body))throw new Error("Actual reply must direct secure deployment-key entry and Morgan-only reference binding before human action");state.initialRunId=reply.runId; state.secretCountBeforeHuman=(await ctx.input.api.get(`${root(ctx)}/secrets`)).length;if(state.secretCountBeforeHuman!==0)throw new Error("Secret was created before authorized human entry"); const path=`/${ctx.input.fixtures.company.issuePrefix}/company/settings/secrets`,href=initial.links.find(link=>{try{const url=new URL(link,initial.origin);return url.origin===initial.origin&&url.pathname===path;}catch{return false;}});if(!href)throw new Error("Actual reply must link the supported secret management surface"); const receipt=await privateBrowserEntry({mode:"company-secret-create",companyId:ctx.input.fixtures.company.id,baseURL:initial.origin,setupURL:new URL(href,initial.origin).href,providerURL:"",credential:values.get(ctx)!,cookies:await ctx.input.page.context().cookies(initial.origin)}); @@ -50,7 +51,7 @@ export const companySecretExternalCases=[definition("secrets.configure-company-s const saved=ctx.input.page.waitForResponse(response=>new URL(response.url()).pathname===`/api/agents/${state.morganId}`&&response.request().method()==="PATCH"&&response.ok()); await ctx.input.page.getByRole("button",{name:"Save changes",exact:true}).click();await saved;await ctx.input.page.reload({waitUntil:"domcontentloaded"}); await expect(editor.getByRole("textbox",{name:"Variable name",exact:true}).last()).toHaveValue("DEPLOYMENT_API_KEY");await expect(editor).toContainText("Deployment API key");state.morganBindingVisible=true; - await ctx.input.page.goto(source,{waitUntil:"domcontentloaded"});await sendChatMessage(ctx.input.page,ctx.scenario.query); + await ctx.input.page.goto(source,{waitUntil:"domcontentloaded"});await sendChatMessage(ctx.input.page,ctx.scenario.query, chatCatalogNativePhaseRemainingBudget(ctx)); }, async observe(ctx,state){ const o=await observeExternal(ctx,state),value=values.get(ctx);if(!value||!ctx.issue())throw new Error("Private secret output scan fixture missing"); diff --git a/tests/runner-e2e/chat-catalog-domains/external-team-configure.ts b/tests/runner-e2e/chat-catalog-domains/external-team-configure.ts index 89af0eb3cf9..b5e8f4ba98a 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-team-configure.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-team-configure.ts @@ -1,3 +1,4 @@ +import { chatCatalogNativePhaseRemainingBudget, chatCatalogOriginalPhaseDeadline } from "../chat-catalog-contract.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -79,7 +80,7 @@ export const teamConfigureExternalCases = [definition("teams.configure-team", as async verifySetup(_ctx, state) { const c = checks(), rows = state.initialProbes as Probe[]; c.add("missing-account-fixture", rows.some(row => row.agentId === state.missingId && row.status === "fail"), "Actual saved-runtime probe reports the required missing member account."); c.add("working-account-fixture", rows.some(row => row.agentId === state.workingId && row.status === "pass"), "A distinct installed member already passes its runtime probe."); return c.result(); }, sourceFiles: ["chat-catalog-domains/external-team-configure.ts", "chat-catalog-domains/external-team-install.ts", "codex-subscription-fixture.ts", "chat-catalog-domains/external-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/identity-support.ts", "chat-flow.ts", "chat-scenarios/teams.yaml"], async act(ctx, state) { - await waitForReply(ctx); const initial = await observeExternal(ctx, state); state.initialRunId = initial.runs[0]?.id; + await waitForReply(ctx, chatCatalogOriginalPhaseDeadline(ctx)); const initial = await observeExternal(ctx, state); state.initialRunId = initial.runs[0]?.id; state.initialMemberNamed = initial.reply.toLowerCase().includes(String(state.missingName).toLowerCase()); const members = catalogTeamMembers(await observeTeamResources(ctx), String(state.teamId)), missing = members.find(row => row.id === state.missingId)!; const current = await probe(ctx, missing); const alreadyRepaired = current.status === "pass"; @@ -94,7 +95,7 @@ export const teamConfigureExternalCases = [definition("teams.configure-team", as await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); } else state.repairChannel = "agent-configuration"; state.repairVerifiedAfter = Date.now(); state.repairConfirmed = (await probe(ctx, await ctx.input.api.get(`/api/agents/${missing.id}`))).status === "pass"; - await sendChatMessage(ctx.input.page, "Please verify which installed team members are ready now, including QA, and preserve the already working member's configuration."); + await sendChatMessage(ctx.input.page, "Please verify which installed team members are ready now, including QA, and preserve the already working member's configuration.", chatCatalogNativePhaseRemainingBudget(ctx)); }, async observe(ctx, state) { const members = catalogTeamMembers(await observeTeamResources(ctx), String(state.teamId)); state.afterProbes = await Promise.all(members.map(member => probe(ctx, member))); diff --git a/tests/runner-e2e/chat-catalog-domains/integrator-routine-webhook.ts b/tests/runner-e2e/chat-catalog-domains/integrator-routine-webhook.ts index 9da1d032cd5..d2c645783b2 100644 --- a/tests/runner-e2e/chat-catalog-domains/integrator-routine-webhook.ts +++ b/tests/runner-e2e/chat-catalog-domains/integrator-routine-webhook.ts @@ -1,3 +1,4 @@ +import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "../chat-catalog-contract.js"; import { uploadFactualQueryAttachment, factualContextQueryAttachment } from "../chat-query-attachments.js"; import type { RoutineDetail, RoutineRunSummary } from "../../../packages/shared/src/types/routine.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; @@ -59,16 +60,17 @@ export const integratorRoutineWebhookCases = [integratorDefinition("routines", " expectedRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 720_000, sourceFiles: ["chat-catalog-domains/integrator-routine-webhook.ts", "chat-catalog-domains/integrator-webhook-private.ts", "chat-catalog-domains/integrator-webhook-private-worker.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-scenarios/routines.yaml"], async act(ctx, state) { - const first = await pollUntil({ label: "actual webhook creation before any test-service event", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeWebhook(ctx, state), accept: o => o.runs.length === 1 && o.runs[0]?.status === "succeeded" && ctx.issue()?.conversationState === "waiting" }); + const first = await pollUntil({ label: "actual webhook creation before any test-service event", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 180_000), intervalMs: 1000, load: () => observeWebhook(ctx, state), accept: o => chatCatalogNativePhaseIsTimely(ctx) && (o.runs.length === 1 && o.runs[0]?.status === "succeeded" && ctx.issue()?.conversationState === "waiting") }); state.triggerCheckpoint = { ...first, state: structuredClone(state) }; await ctx.input.evidence("chat-webhook-created-before-event.json", state.triggerCheckpoint); const triggers = first.routine.triggers.filter(trigger => trigger.kind === "webhook" && !trigger.archived); if (triggers.length !== 1 || first.invocations.length || first.detectedCredentialLeak || !triggers[0]!.signingMode || triggers[0]!.signingMode === "none") throw new Error("Canonical webhook creation did not establish one safe authenticated trigger"); if (!ctx.input.privateSecrets) throw new Error("Secure webhook human setup requires private redaction"); const trigger = triggers[0]!; const cookies = await ctx.input.page.context().cookies(); for (const cookie of cookies) ctx.input.privateSecrets.push(cookie.value); + chatCatalogNativePhaseRemainingBudget(ctx)?.(); const humanSetup = await finishPrivateWebhook({ baseURL: ctx.input.api.baseURL, route: `/${ctx.input.fixtures.company.issuePrefix}/routines/${first.routine.id}`, routineId: first.routine.id, triggerId: trigger.id, setupPending: trigger.setupPending ?? false, signingMode: trigger.signingMode!, nonce: String(state.deliveryNonce), cookies }, value => ctx.input.privateSecrets!.push(value)); state.humanSetup = humanSetup; await ctx.input.evidence("chat-webhook-private-human-receipt.json", humanSetup); - await pollUntil({ label: "actual authorized test-service native execution", deadlineAt: Date.now() + 240_000, intervalMs: 1000, load: () => observeWebhook(ctx, state), accept: o => o.invocations.length === 1 && o.invocations[0]?.status === "completed" && o.runs.length === 2 && o.runs.every(run => run.status === "succeeded") }); - const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, "Did the synthetic test-service event actually arrive, and what execution did it create?"); + await pollUntil({ label: "actual authorized test-service native execution", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 240_000), intervalMs: 1000, load: () => observeWebhook(ctx, state), accept: o => chatCatalogNativePhaseIsTimely(ctx) && (o.invocations.length === 1 && o.invocations[0]?.status === "completed" && o.runs.length === 2 && o.runs.every(run => run.status === "succeeded")) }); + const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, "Did the synthetic test-service event actually arrive, and what execution did it create?", chatCatalogNativePhaseRemainingBudget(ctx)); }, })]; diff --git a/tests/runner-e2e/chat-catalog-domains/integrator-status-cards.ts b/tests/runner-e2e/chat-catalog-domains/integrator-status-cards.ts index c7c3c781551..d7609a83476 100644 --- a/tests/runner-e2e/chat-catalog-domains/integrator-status-cards.ts +++ b/tests/runner-e2e/chat-catalog-domains/integrator-status-cards.ts @@ -1,3 +1,4 @@ +import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "../chat-catalog-contract.js"; import { uploadFactualQueryAttachment, factualContextQueryAttachment } from "../chat-query-attachments.js"; import type { StatusCard, StatusCardUpdate, StatusCardSummaryRevision } from "../../../packages/shared/src/validators/status-card.js"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; @@ -180,8 +181,8 @@ export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [ define("status-cards.explain-card", "ceo", { authentication: "human-session", queryAttachments: factualContextQueryAttachment("selected-status-card.md"), expectedRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 720_000, sourceFiles: ["chat-catalog-domains/integrator-status-cards.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/integrator-support.ts", "chat-scenarios/status-cards.yaml"], async act(ctx, state) { - const first = await pollUntil({ label: "accessible source explanation before actual access change", deadlineAt: Date.now() + 180_000, intervalMs: 1000, - load: () => observeCards(ctx, state), accept: o => o.runs.length === 2 && o.runs.every(run => run.status === "succeeded") && ctx.issue()?.conversationState === "waiting" }); + const first = await pollUntil({ label: "accessible source explanation before actual access change", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 180_000), intervalMs: 1000, + load: () => observeCards(ctx, state), accept: o => chatCatalogNativePhaseIsTimely(ctx) && (o.runs.length === 2 && o.runs.every(run => run.status === "succeeded") && ctx.issue()?.conversationState === "waiting") }); if (!explainsSavedStaleness(first.reply)) throw new Error("Initial saved-versus-live explanation was incorrect; later unavailability cannot repair it"); state.accessCheckpoint = { ...first, state: structuredClone(state) }; await ctx.input.evidence("chat-card-explanation-before-access-loss.json", state.accessCheckpoint); const source = (state.sourceTasks as Entity[])[0]!; @@ -193,7 +194,7 @@ export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [ await ctx.input.evidence("chat-card-current-source-unavailable-probe.json", { principalId: state.accessPrincipalId, sourceId: source.id, status: denial.status, kind: "source-deleted-unavailable", permissionDenialClaimed: false, savedSummaryRetained: retained.summaryBody === (state.card as StatusCard).summaryBody, savedHistoryRetained: JSON.stringify(retainedSummaries) === JSON.stringify(state.originalSummaries) }); if (denial.status !== 404 || retained.summaryBody !== (state.card as StatusCard).summaryBody || JSON.stringify(retainedSummaries) !== JSON.stringify(state.originalSummaries)) throw new Error("Deleted source prerequisite did not retain card/history and actual principal404"); - const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, "That disposable source task was removed after your earlier answer. Can you still read and verify its current blocker details? Distinguish the older saved summary from what you can check now."); + const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, "That disposable source task was removed after your earlier answer. Can you still read and verify its current blocker details? Distinguish the older saved summary from what you can check now.", chatCatalogNativePhaseRemainingBudget(ctx)); }, }), define("status-cards.refresh-card", "ceo", { expectedRunCount: 7, expectedHumanMessageCount: 4, attemptTimeoutMs: 1_200_000, @@ -209,7 +210,7 @@ export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [ await pollUntil({ label: "actual refreshed card and native worker completion", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeCards(ctx, state), accept: o => { const card = o.cards.find(row => row.id === before.id); return !!card && card.lastGeneratedAt !== before.lastGeneratedAt && freshSummary(o, card) && o.runs.length === 3 && o.runs.every(run => run.status === "succeeded"); } }); const { sendChatMessage } = await import("../chat-flow.js"); - await sendChatMessage(ctx.input.page, "What did the refresh actually produce?"); + await sendChatMessage(ctx.input.page, "What did the refresh actually produce?", chatCatalogNativePhaseRemainingBudget(ctx)); const succeeded = await pollUntil({ label: "successful generation actually reported before separate failure condition", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeCards(ctx, state), accept: o => o.runs.length === 4 && o.runs.every(run => run.status === "succeeded") && ctx.issue()?.conversationState === "waiting" }); state.successfulRefreshResult = { ...succeeded, state: structuredClone(state) }; await ctx.input.evidence("chat-card-refresh-last-successful-result.json", state.successfulRefreshResult); const worker = await ctx.input.api.get(`/api/agents/${(state.worker as Entity).id}`); @@ -219,7 +220,7 @@ export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [ // genuine CEO source turns; nothing is hidden from the declared bound. const workerContext = { ...ctx, allRuns: async () => (await ctx.allRuns()).filter(run => run.agentId === worker.id) }; const failure = await unavailableNativeModelFailure(workerContext, worker, async () => { - await sendChatMessage(ctx.input.page, "Run another full refresh of that card now."); + await sendChatMessage(ctx.input.page, "Run another full refresh of that card now.", chatCatalogNativePhaseRemainingBudget(ctx)); const update = await pollUntil({ label: "explicit human full refresh creates actual generation attempt", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => ctx.input.api.get(`/api/status-cards/${before.id}/updates`), accept: updates => updates.some(row => !beforeFailureIds.has(row.id) && row.kind !== "compile" && !!row.generationIssueId) }); @@ -234,7 +235,7 @@ export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [ generationIssueId: failure.failedIssueId, cardId: before.id, observedCardState: actual?.state, observedGeneratingIssueId: actual?.generatingIssueId, failedUpdateIds: failedUpdates.map(update => update.id), cardErrorObserved: established, expectedCardFailureOutcome: established ? "observed" : "not-observed-failing-product-outcome", taskOrCardStatusPatched: false, cancelledAsFailure: false }); await pollUntil({ label: "actual refresh source turn settled after failed generation", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeCards(ctx, state), accept: o => o.runs.length === 6 && o.runs.filter(run => run.status === "succeeded").length === 5 && o.runs.filter(run => run.status === "failed").length === 1 && ctx.issue()?.conversationState === "waiting" }); - await sendChatMessage(ctx.input.page, "What happened to that latest refresh, and which saved summary is available now?"); + await sendChatMessage(ctx.input.page, "What happened to that latest refresh, and which saved summary is available now?", chatCatalogNativePhaseRemainingBudget(ctx)); }, }), ]; diff --git a/tests/runner-e2e/chat-catalog-domains/privileged-backup.ts b/tests/runner-e2e/chat-catalog-domains/privileged-backup.ts index 8035d5472b5..c4618fdd611 100644 --- a/tests/runner-e2e/chat-catalog-domains/privileged-backup.ts +++ b/tests/runner-e2e/chat-catalog-domains/privileged-backup.ts @@ -1,3 +1,4 @@ +import { chatCatalogNativePhaseRemainingBudget } from "../chat-catalog-contract.js"; import { createHash } from "node:crypto"; import { readFile, realpath } from "node:fs/promises"; import path from "node:path"; @@ -125,7 +126,7 @@ const backup = privilegedDefinition("instance-operations.backup-and-restore", "p archiveRetention: "private owned attempt only; secret-bearing archive excluded from ordinary chat artifacts" }; await ctx.input.evidence("chat-catalog-private-backup-restore.json", receipt); const message = `The operator backup and restore run produced this factual receipt. Please report what it establishes and any limits:\n\n${JSON.stringify(receipt)}`; - await sendChatMessage(ctx.input.page, message); + await sendChatMessage(ctx.input.page, message, chatCatalogNativePhaseRemainingBudget(ctx)); state.restoreReport = await companionPhase(ctx, initial.runs.map(run => run.id), message); await ctx.input.evidence("chat-catalog-native-restore-report.json", state.restoreReport); }, grade: gradeBackupRestore, diff --git a/tests/runner-e2e/chat-catalog-domains/runtime-interruption.ts b/tests/runner-e2e/chat-catalog-domains/runtime-interruption.ts index 868768e2dff..e33ed9bc239 100644 --- a/tests/runner-e2e/chat-catalog-domains/runtime-interruption.ts +++ b/tests/runner-e2e/chat-catalog-domains/runtime-interruption.ts @@ -1,3 +1,4 @@ +import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "../chat-catalog-contract.js"; import { expect } from "@playwright/test"; import { writeFile } from "node:fs/promises"; import type { HeartbeatRun } from "../../../packages/shared/src/types/heartbeat.js"; @@ -64,13 +65,14 @@ export const runtimeInterruptionCases = [runtimeDefinition("steering.interrupt-w runPolicy: { kind: "terminal-counts", terminalStatusCounts: { cancelled: 1, succeeded: 2 } }, sourceFiles: ["chat-catalog-domains/runtime-interruption.ts", "chat-catalog-domains/runtime-rendered-artifact.ts", "chat-catalog-domains/runtime-queue.ts", "chat-catalog-domains/runtime-busy-worker.ts", "chat-catalog-domains/runtime-support.ts", "registered-artifact.ts", "chat-scenarios/steering.yaml"], async act(ctx, state) { - await pollUntil({ label: "Canonical interruption guidance before the actual human action", deadlineAt: Date.now() + 150_000, + await pollUntil({ label: "Canonical interruption guidance before the actual human action", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000), load: async () => { await ctx.refreshIssue(); return { source: ctx.issue(), runs: await ctx.allRuns() }; }, - accept: v => v.source?.conversationState === "waiting" && v.runs.length === 2 && v.runs.some(row => row.id === state.workerRunId && row.status === "running") && v.runs.some(row => row.agentId === ctx.input.fixtures.agent.id && row.status === "succeeded") }); + accept: v => chatCatalogNativePhaseIsTimely(ctx) && (v.source?.conversationState === "waiting" && v.runs.length === 2 && v.runs.some(row => row.id === state.workerRunId && row.status === "running") && v.runs.some(row => row.agentId === ctx.input.fixtures.agent.id && row.status === "succeeded")) }); state.queryReply = (await observeRuntime(ctx, state)).reply; const source = ctx.input.page.url(), task = state.task as Entity, confirmation = state.approvedDirection as Entity, endpoint = `/api/issues/${task.id}/queued-comments`; await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/issues/${task.identifier ?? task.id}`, { waitUntil: "domcontentloaded" }); const interrupted = ctx.input.page.waitForResponse(r => r.request().method() === "POST" && new URL(r.url()).pathname === `${endpoint}/interrupt`); + chatCatalogNativePhaseRemainingBudget(ctx)?.(); await ctx.input.page.getByTestId(`task-chat-queued-interrupt-${confirmation.id}`).click(); state.humanInterrupted = true; state.interruptStatus = (await interrupted).status(); const stopped = await pollUntil({ label: "Matching native cancellation ACK after actual supported Interrupt", deadlineAt: Date.now() + 90_000, load: () => ctx.input.api.get(`/api/heartbeat-runs/${state.workerRunId}`), @@ -82,10 +84,10 @@ export const runtimeInterruptionCases = [runtimeDefinition("steering.interrupt-w // independently approved persisted plan and its actual queued response. await writeFile((state.gate as { gate: string }).gate, "The pending fixture tool has been released.", "utf8"); let neverConcurrent = true; - const delivered = await pollUntil({ label: "One fresh native continuation after old-owner cleanup delivers simpler Website", deadlineAt: Date.now() + 240_000, + const delivered = await pollUntil({ label: "One fresh native continuation after old-owner cleanup delivers simpler Website", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 240_000), load: async () => { const runs = await ctx.allRuns(); neverConcurrent &&= runs.filter(row => row.agentId === (state.worker as Entity).id && ["running", "queued"].includes(row.status)).length <= 1; return { runs, task: await ctx.input.api.get(`/api/issues/${task.id}`), queue: await ctx.input.api.get(endpoint) }; }, - accept: v => v.runs.length === 3 && v.task.status === "done" && v.queue.entries.length === 0 && v.runs.filter(row => row.id !== state.workerRunId).every(row => row.status === "succeeded"), + accept: v => chatCatalogNativePhaseIsTimely(ctx) && (v.runs.length === 3 && v.task.status === "done" && v.queue.entries.length === 0 && v.runs.filter(row => row.id !== state.workerRunId).every(row => row.status === "succeeded")), reject: v => v.runs.length > 3 || v.runs.some(row => row.id !== state.workerRunId && ["failed", "cancelled", "timed_out"].includes(row.status)) ? "Interruption failed or delivered more than one successor" : undefined }); const successors = delivered.runs.filter(row => row.agentId === (state.worker as Entity).id && row.id !== state.workerRunId); state.successorCount = successors.length; state.neverConcurrentOwners = neverConcurrent; const next = successors[0] && await ctx.input.api.get(`/api/heartbeat-runs/${successors[0].id}`); state.successor = next; state.successorRunId = next?.id; diff --git a/tests/runner-e2e/chat-catalog-original-clock.test.ts b/tests/runner-e2e/chat-catalog-original-clock.test.ts index af33963207b..dcf620f8561 100644 --- a/tests/runner-e2e/chat-catalog-original-clock.test.ts +++ b/tests/runner-e2e/chat-catalog-original-clock.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChatCatalogContext } from "./chat-catalog-contract.js"; -import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "./chat-catalog-contract.js"; +import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget, chatCatalogOriginalPhaseDeadline } from "./chat-catalog-contract.js"; const observed = vi.hoisted(() => vi.fn()); vi.mock("./chat-catalog-domains/internal-support.js", async importOriginal => ({ ...await importOriginal(), observeInternal: observed })); +import { waitForReply } from "./chat-catalog-domains/identity-support.js"; import { settlePrivilegedChat } from "./chat-catalog-domains/privileged-support.js"; function context(scenarioId = "cli-operations.owned-dev-instance", deadline = 870_000): ChatCatalogContext { return { scenario: { id: scenarioId }, input: { execution: { id: `chat-features.runner-opencode.local.${scenarioId}` } }, observationDeadlineAt: deadline, @@ -16,6 +17,27 @@ describe("selected native phases share the once-created original flow clock", () vi.setSystemTime(850_000); expect(chatCatalogNativePhaseDeadline(ctx, Date.now() + 180_000)).toBe(870_000); expect(ctx.observationDeadlineAt).toBe(870_000); }); + it.each(["instance-operations.backup-and-restore", "instance-operations.restart-service", "routines.webhook-trigger", "status-cards.explain-card", "secrets.configure-company-secret", "teams.configure-team", "steering.interrupt-with-direction"])("future13 %s shares the setup-inclusive original clock", scenario => { + vi.useFakeTimers(); vi.setSystemTime(800_000); const ctx = context(scenario); + expect(chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000)).toBe(870_000); + expect(chatCatalogOriginalPhaseDeadline(ctx)).toBe(870_000); + vi.setSystemTime(869_999); expect(chatCatalogNativePhaseRemainingBudget(ctx)!()).toBe(1); + vi.setSystemTime(870_000); expect(() => chatCatalogOriginalPhaseDeadline(ctx)).toThrow(/exhausted/); + }); + it("actual identity helper settles after its old150s cap using only the original remaining clock", async () => { + vi.useFakeTimers(); vi.setSystemTime(50_000); const ctx = context("secrets.configure-company-secret", 450_000); + Object.assign(ctx, { refreshIssue: async () => {}, allRuns: async () => [{ status: Date.now() >= 230_000 ? "succeeded" : "running" }] }); + let completed = false; const pending = waitForReply(ctx, chatCatalogOriginalPhaseDeadline(ctx)).then(() => { completed = true; }); + await vi.advanceTimersByTimeAsync(152_000); expect(completed).toBe(false); + await vi.advanceTimersByTimeAsync(28_000); await pending; expect(completed).toBe(true); + expect(ctx.observationDeadlineAt).toBe(450_000); + }); + it.each(["onboarding.first-work", "routines.plugin-managed-routine", "status-cards.archive-card", "status-cards.other-authors-card"])("future13 %s does not receive new phase or UI behavior", scenario => { + vi.useFakeTimers(); vi.setSystemTime(800_000); const ctx = context(scenario, 1); + expect(chatCatalogNativePhaseDeadline(ctx, 900_000)).toBe(900_000); + expect(chatCatalogOriginalPhaseDeadline(ctx)).toBeUndefined(); + expect(chatCatalogNativePhaseRemainingBudget(ctx)).toBeUndefined(); + }); it("actual existing privileged phase can settle beyond150s without changing native/reply proof", async () => { vi.useFakeTimers(); vi.setSystemTime(0); const ctx = context(); observed.mockImplementation(async () => ({ reply: Date.now() >= 180_000 ? "Use the operator path." : "", runs: [{ status: Date.now() >= 180_000 ? "succeeded" : "running" }] }));