import { expect } from "vitest"; import { screenshot, validate } from "@openwork/test-evidence"; import { clickButton, denFetch, evalIn, go, visibleText, waitFor, waitForText, } from "@openwork/behaviors"; import type { DenSession } from "@openwork/behaviors"; import type { Surface } from "@openwork/cdp"; import { app, needs, server, test } from "@openwork/testkit"; /** * CORE JOURNEY: an active Automation keeps its instructions, cadence, and * durable history when its pinned model disappears. Den records the first due * occurrence as skipped, pauses future dispatches in `needs_attention`, and * the app points the owner directly to a supported-model picker. Choosing a * replacement creates a new immutable revision, clears the warning, and * resumes from the next scheduled occurrence without replaying the skipped * one. */ const REQUEST_TIMEOUT_MS = 10_000; const SCHEDULE_TIMEOUT_MS = 120_000; const LEGACY_PROVIDER_NAME = "Legacy Automation Models"; const LEGACY_MODEL_ID = "legacy-automation-model"; const REPLACEMENT_PROVIDER_NAME = "Replacement Automation Models"; const REPLACEMENT_MODEL_ID = "replacement-automation-model"; const REPLACEMENT_MODEL_NAME = "Replacement Automation Model"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function auth(session: DenSession): Record { return { authorization: `Bearer ${session.token}` }; } async function activeOrganizationId(session: DenSession): Promise { const result = await denFetch(session, "/v1/me/orgs", { headers: auth(session), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const organizations = isRecord(result.body) && Array.isArray(result.body.orgs) ? result.body.orgs.filter(isRecord) : []; const activeId = isRecord(result.body) && typeof result.body.activeOrgId === "string" ? result.body.activeOrgId : ""; const active = organizations.find((organization) => organization.isActive === true) ?? organizations[0]; const orgId = activeId || (active && typeof active.id === "string" ? active.id : ""); if (!result.response.ok || !orgId) { throw new Error(`Finding the active organization failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`); } return orgId; } async function createProvider(input: { admin: DenSession; orgId: string; providerKey: string; providerName: string; modelId: string; modelName: string; }): Promise { const result = await denFetch(input.admin, "/v1/llm-providers", { method: "POST", headers: { ...auth(input.admin), "x-openwork-org-id": input.orgId }, body: JSON.stringify({ name: input.providerName, source: "custom", customConfig: { id: input.providerKey, name: input.providerName, npm: "@ai-sdk/openai-compatible", env: [`${input.providerKey.toUpperCase().replaceAll("-", "_")}_API_KEY`], models: [{ id: input.modelId, name: input.modelName }], }, apiKey: "sk-openwork-automation-transition-eval-only", allMembers: true, }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const provider = isRecord(result.body) && isRecord(result.body.llmProvider) ? result.body.llmProvider : null; const providerId = provider && typeof provider.id === "string" ? provider.id : ""; if (result.response.status !== 201 || !providerId) { throw new Error(`Creating ${input.providerName} failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`); } return providerId; } async function createDueAutomation(input: { admin: DenSession; orgId: string; providerId: string; name: string; instructions: string; }): Promise { const nextMinute = new Date(Date.now() + 90_000); const result = await denFetch(input.admin, "/v1/automations", { method: "POST", headers: { ...auth(input.admin), "x-openwork-org-id": input.orgId }, body: JSON.stringify({ name: input.name, instructions: input.instructions, schedule: { kind: "daily", timezone: "UTC", hour: nextMinute.getUTCHours(), minute: nextMinute.getUTCMinutes(), }, model: { providerId: input.providerId, modelId: LEGACY_MODEL_ID, variant: null }, }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const automation = isRecord(result.body) && isRecord(result.body.automation) ? result.body.automation : null; const automationId = automation && typeof automation.id === "string" ? automation.id : ""; if (result.response.status !== 201 || !automationId) { throw new Error(`Creating the Automation failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`); } const nextDueAt = automation && typeof automation.nextDueAt === "number" ? automation.nextDueAt : 0; if (nextDueAt <= Date.now() || nextDueAt > Date.now() + SCHEDULE_TIMEOUT_MS) { throw new Error(`The Automation was not scheduled within the test window: ${result.text.slice(0, 1_000)}`); } return automationId; } async function deleteProvider(admin: DenSession, orgId: string, providerId: string): Promise { const result = await denFetch(admin, `/v1/llm-providers/${encodeURIComponent(providerId)}`, { method: "DELETE", headers: { ...auth(admin), "x-openwork-org-id": orgId }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); expect(result.response.status).toBe(204); } async function skippedModelReceipt(admin: DenSession, orgId: string, automationId: string): Promise> { const deadline = Date.now() + SCHEDULE_TIMEOUT_MS; let lastResponse = "not requested"; while (Date.now() < deadline) { const result = await denFetch(admin, `/v1/automations/${encodeURIComponent(automationId)}/runs`, { headers: { ...auth(admin), "x-openwork-org-id": orgId }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); lastResponse = `HTTP ${result.response.status} ${result.text.slice(0, 1_000)}`; const items = isRecord(result.body) && Array.isArray(result.body.items) ? result.body.items.filter(isRecord) : []; const skipped = items.find((item) => item.status === "skipped" && isRecord(item.error) && (item.error.code === "provider_unavailable" || item.error.code === "model_access_lost") ); if (skipped) return skipped; await new Promise((resolve) => setTimeout(resolve, 1_000)); } throw new Error(`The due occurrence was not recorded as skipped after its model disappeared. Last response: ${lastResponse}`); } async function clickAutomationCard(surface: Surface, name: string): Promise { const clicked = await evalIn(surface, `(() => { const card = [...document.querySelectorAll('button')] .find((candidate) => (candidate.textContent ?? '').includes(${JSON.stringify(name)})); if (!(card instanceof HTMLButtonElement)) return false; card.click(); return true; })()`); expect(clicked).toBe(true); } test("an unavailable Automation model needs attention until the owner selects a supported replacement", async ({ evidence, place }) => { needs({ optIn: ["OPENWORK_EVAL_E2E_TESTS", "OPENWORK_EVAL_AUTOMATIONS_E2E_TEST"] }); await using den = await server({ place }); const orgId = await activeOrganizationId(den.admin); const stamp = Date.now(); const automationName = `CRM transition check ${stamp}`; const instructions = `Keep the CRM transition marker ${stamp} in every summary.`; const legacyProviderId = await createProvider({ admin: den.admin, orgId, providerKey: `legacy-automation-${stamp}`, providerName: LEGACY_PROVIDER_NAME, modelId: LEGACY_MODEL_ID, modelName: "Legacy Automation Model", }); await createProvider({ admin: den.admin, orgId, providerKey: `replacement-automation-${stamp}`, providerName: REPLACEMENT_PROVIDER_NAME, modelId: REPLACEMENT_MODEL_ID, modelName: REPLACEMENT_MODEL_NAME, }); const automationId = await createDueAutomation({ admin: den.admin, orgId, providerId: legacyProviderId, name: automationName, instructions, }); // The model disappears after the revision is durable but before dispatch. await deleteProvider(den.admin, orgId, legacyProviderId); const skippedReceipt = await skippedModelReceipt(den.admin, orgId, automationId); expect(skippedReceipt.trigger).toBe("scheduled"); evidence.recordAssertionEvidence( "The first unavailable-model occurrence is retained once", "Den recorded one scheduled receipt as skipped before pausing the Automation.", true, ); await using desktop = await app({ den, as: "admin", place }); await go(desktop, "/automations"); await waitForText(desktop, automationName, { timeoutMs: 60_000 }); await waitFor(desktop, `(() => { const card = document.querySelector('[data-automation-needs-attention]'); return Boolean(card && (card.textContent ?? '').includes(${JSON.stringify(automationName)})); })()`, { timeoutMs: 60_000, label: "Automation card warning" }); expect(await evalIn(desktop, "Boolean(document.querySelector('[data-automations-attention-indicator]'))")).toBe(true); expect(await visibleText(desktop)).toContain("Needs attention"); evidence.recordAssertionEvidence( "The Automations list calls out the broken model", "The sidebar and affected card have warning icons, and the card reports Needs attention while healthy cards remain unchanged.", true, ); await clickAutomationCard(desktop, automationName); await waitForText(desktop, "Model needs attention", { timeoutMs: 30_000 }); const pausedDetail = await visibleText(desktop); expect(pausedDetail).toContain(instructions); expect(pausedDetail).toContain(`${legacyProviderId}/${LEGACY_MODEL_ID}`); expect(pausedDetail).toContain("No future run scheduled"); expect(pausedDetail).toContain("Skipped โ€” model unavailable"); const runNowDisabled = await evalIn(desktop, `(() => { const button = [...document.querySelectorAll('button')] .find((candidate) => (candidate.textContent ?? '').trim() === 'Run now'); return button instanceof HTMLButtonElement && button.disabled; })()`); expect(runNowDisabled).toBe(true); evidence.recordAssertionEvidence( "The detail page preserves context and pauses execution", "Instructions, schedule, old model identity, and skipped history remain visible; Run now and future scheduling are paused.", true, ); await clickButton(desktop, "Select a supported model"); await waitForText(desktop, "Current model is no longer available", { timeoutMs: 30_000 }); await waitFor(desktop, `(() => { const dialog = document.querySelector('[role=dialog]'); return Boolean(dialog && (dialog.textContent ?? '').includes('Replacement Automation Model')); })()`, { timeoutMs: 30_000, label: "supported replacement model picker" }); const pickerText = await evalIn(desktop, `document.querySelector('[role=dialog]')?.textContent ?? ''`); expect(String(pickerText)).toContain("Replacement Automation Model"); expect(String(pickerText)).not.toContain("Legacy Automation Model"); // Provider groups start collapsed unless they hold the current model, a // cloud-sourced group, or OpenWork models โ€” none apply here, so the model // rows are not in the DOM yet. The shipped interaction is: expand the // provider group via its header button, then click the model row (a // role="button" div whose label span is the model's display name). const expandedReplacementGroup = await evalIn(desktop, `(() => { const dialog = document.querySelector('[role=dialog]'); if (!dialog) return false; const header = [...dialog.querySelectorAll('button')] .find((candidate) => [...candidate.querySelectorAll('span')] .some((label) => (label.textContent ?? '').trim() === ${JSON.stringify(REPLACEMENT_PROVIDER_NAME)})); if (!(header instanceof HTMLElement)) return false; header.click(); return true; })()`); expect(expandedReplacementGroup).toBe(true); await waitFor(desktop, `(() => { const dialog = document.querySelector('[role=dialog]'); return Boolean(dialog && [...dialog.querySelectorAll('span')] .some((label) => (label.textContent ?? '').trim() === ${JSON.stringify(REPLACEMENT_MODEL_NAME)})); })()`, { timeoutMs: 15_000, label: "replacement model row rendered" }); // The expanded row is a native button whose label spans carry the model's // display name and raw id (the plural group header never does). Click the // candidate with an exact label match, and surface the visible spans when // nothing is clickable so a red run explains itself. const selectedReplacement = await evalIn(desktop, `(() => { const dialogs = [...document.querySelectorAll('[role=dialog]')]; if (dialogs.length === 0) return "no dialog"; for (const dialog of dialogs.slice().reverse()) { const items = [...dialog.querySelectorAll('button, [role=button], [role=option], [cmdk-item]')]; const item = items.find((candidate) => [...candidate.querySelectorAll('span')] .some((label) => { const text = (label.textContent ?? '').trim(); return text === ${JSON.stringify(REPLACEMENT_MODEL_NAME)} || text === ${JSON.stringify(REPLACEMENT_MODEL_ID)}; })); if (item instanceof HTMLElement) { item.click(); return "ok"; } } const spans = [...dialogs[dialogs.length - 1].querySelectorAll('span')] .map((label) => (label.textContent ?? '').trim()) .filter((text) => text.length > 0) .slice(0, 40); return "no clickable row; dialogs=" + dialogs.length + " spans=" + JSON.stringify(spans); })()`); expect(selectedReplacement).toBe("ok"); await clickButton(desktop, "Save changes"); await waitForText(desktop, "Active", { timeoutMs: 60_000 }); await waitForText(desktop, "Revision 2", { timeoutMs: 30_000 }); const repairedDetail = await visibleText(desktop); expect(repairedDetail).toContain(`${REPLACEMENT_PROVIDER_NAME} ยท Replacement Automation Model`); expect(repairedDetail).not.toContain("Model needs attention"); expect(repairedDetail).not.toContain("No future run scheduled"); expect(repairedDetail).toContain("Skipped โ€” model unavailable"); const finalRunNowDisabled = await evalIn(desktop, `(() => { const button = [...document.querySelectorAll('button')] .find((candidate) => (candidate.textContent ?? '').trim() === 'Run now'); return button instanceof HTMLButtonElement ? button.disabled : null; })()`); expect(finalRunNowDisabled).toBe(false); await waitFor(desktop, "!document.querySelector('[data-automations-attention-indicator]')", { timeoutMs: 30_000, label: "sidebar warning clears after model replacement", }); const runs = await denFetch(den.admin, `/v1/automations/${encodeURIComponent(automationId)}/runs`, { headers: { ...auth(den.admin), "x-openwork-org-id": orgId }, }); const runItems = isRecord(runs.body) && Array.isArray(runs.body.items) ? runs.body.items : []; expect(runItems).toHaveLength(1); evidence.recordAssertionEvidence( "Explicit replacement resumes without replay", "Saving the supported model created revision 2, restored Active with a next run, retained the skipped receipt, and did not replay it.", true, ); const shot = await screenshot(desktop); const seen = await validate(shot, [ "The repaired Automation is Active and shows revision 2", "The replacement model is visible in Desktop execution", "The earlier skipped run remains visible in Run history", "No model-needs-attention warning or error stack is visible", ]); expect(seen.ok, seen.why).toBe(true); });