From 781dcd075393ba0edfead96f4969228f8d28542b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 2 Oct 2026 23:56:00 -0700 Subject: [PATCH 1/3] test(web): add provider-change redeploy detection and input tests (CL-9781) --- apps/web/src/agent-model-redeploy.test.ts | 111 ++++++++++++++++++++++ apps/web/src/agent-model-redeploy.ts | 107 +++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 apps/web/src/agent-model-redeploy.test.ts create mode 100644 apps/web/src/agent-model-redeploy.ts diff --git a/apps/web/src/agent-model-redeploy.test.ts b/apps/web/src/agent-model-redeploy.test.ts new file mode 100644 index 000000000..ca75b2cd0 --- /dev/null +++ b/apps/web/src/agent-model-redeploy.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildAgentRedeployInput, + needsAgentRedeployForProviderChange, +} from "./agent-model-redeploy"; +import type { Grant } from "./settings/tenancy-api"; + +function grant(id: string, resource: string, effect: Grant["effect"]): Grant { + return { + id, + tenantId: "bench", + principalId: "principal", + resource, + action: "invoke", + effect, + origin: "invoker", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("needsAgentRedeployForProviderChange", () => { + test("stays quiet when the agent already declares the current offering", () => { + expect( + needsAgentRedeployForProviderChange( + [{ provider: "anthropic", model: "claude-sonnet-4-5" }], + [{ provider: "anthropic", model: "claude-sonnet-4-5" }], + ), + ).toBe(false); + }); + + test("flags a provider change", () => { + expect( + needsAgentRedeployForProviderChange( + [{ provider: "anthropic", model: "claude-sonnet-4-5" }], + [{ provider: "openai", model: "gpt-5" }], + ), + ).toBe(true); + }); + + test("flags a model change on the same provider", () => { + expect( + needsAgentRedeployForProviderChange( + [{ provider: "openai", model: "gpt-5" }], + [{ provider: "openai", model: "gpt-5.5" }], + ), + ).toBe(true); + }); + + test("flags a changed fallback chain", () => { + expect( + needsAgentRedeployForProviderChange( + [ + { provider: "anthropic", model: "claude-sonnet-4-5" }, + { provider: "openai", model: "gpt-5" }, + ], + [{ provider: "anthropic", model: "claude-sonnet-4-5" }], + ), + ).toBe(true); + }); + + test("stays quiet when no provider is connected yet", () => { + expect( + needsAgentRedeployForProviderChange( + [{ provider: "anthropic", model: "claude-sonnet-4-5" }], + null, + ), + ).toBe(false); + }); +}); + +describe("buildAgentRedeployInput", () => { + test("preserves the agent's config and marks a redeploy", () => { + expect( + buildAgentRedeployInput({ + name: "Research Buddy", + slug: "research-buddy", + systemPrompt: "You are helpful.", + mcpHandles: ["exa"], + toolGrants: [], + }), + ).toEqual({ + name: "Research Buddy", + slug: "research-buddy", + systemPrompt: "You are helpful.", + mcpHandles: ["exa"], + toolEffects: [], + redeploy: true, + }); + }); + + test("carries allow and deny while dropping ask-gated permissions", () => { + const input = buildAgentRedeployInput({ + name: "Research Buddy", + slug: "research-buddy", + systemPrompt: "You are helpful.", + mcpHandles: [], + toolGrants: [ + grant("allow-row", "tool:web_search", "allow"), + grant("ask-row", "tool:send_mail", "ask"), + grant("deny-row", "tool:delete_all", "deny"), + ], + }); + expect(input.toolEffects).toEqual([ + { resource: "tool:web_search", effect: "allow" }, + { resource: "tool:delete_all", effect: "deny" }, + ]); + expect(input.redeploy).toBe(true); + }); +}); diff --git a/apps/web/src/agent-model-redeploy.ts b/apps/web/src/agent-model-redeploy.ts new file mode 100644 index 000000000..9ed356356 --- /dev/null +++ b/apps/web/src/agent-model-redeploy.ts @@ -0,0 +1,107 @@ +// Re-deploys a hand-authored agent onto the workspace's current inference +// offering after the provider changed since its last deploy — the same +// `deployAgentSource` pipeline that created it, with its config carried +// over, so nothing the person set is lost in the move. +import { + agentSlugFromSourceAssetName, + deployAgentSource, + type DeployedAgent, + type NewAgentInput, +} from "./agent-deploy"; +import { readAgentMcpHandles, readAgentSource } from "./agent-source-read"; +import type { ChatAgent } from "./chat/threads-api"; +import { resolveExistingOffering, type DeclaredSource } from "./onboarding/provider-connect-step"; +import type { Grant } from "./settings/tenancy-api"; +import { readWorkerToolGrants, toolEffectsToCarry } from "./worker-tool-grants"; + +export class AgentRedeployError extends Error {} + +/** An inference source as either side of the comparison carries it: the + * agent's deployed declaration (plain strings off the source tree) and the + * workspace's current offering (plugin-typed) alike. */ +export type InferenceSource = { + readonly provider: string; + readonly model: string; +}; + +/** True when the agent's declared inference sources no longer match the + * workspace's current offering — its runs still pin the old provider, so a + * redeploy is the only way onto the new one. Quiet when no provider is + * connected yet, since there is nothing to move onto. */ +export function needsAgentRedeployForProviderChange( + declared: readonly InferenceSource[], + current: readonly InferenceSource[] | null, +): boolean { + if (current === null) return false; + return ( + declared.length !== current.length || + declared.some( + (source, index) => + source.provider !== current[index]?.provider || source.model !== current[index]?.model, + ) + ); +} + +/** The redeploy input that keeps the agent's config: its name and address + * slug, its instructions, its bound servers, and its non-ask permissions — + * ask-gated tools stay ask-gated by falling back to the deploy default. */ +export function buildAgentRedeployInput(args: { + readonly name: string; + readonly slug: string; + readonly systemPrompt: string; + readonly mcpHandles: readonly string[]; + readonly toolGrants: readonly Grant[]; +}): NewAgentInput { + return { + name: args.name, + slug: args.slug, + systemPrompt: args.systemPrompt, + mcpHandles: args.mcpHandles, + toolEffects: toolEffectsToCarry(args.toolGrants), + redeploy: true, + }; +} + +/** The workspace's current offering, or null when no provider is connected. */ +export function readCurrentOfferingSources( + tenantId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + return resolveExistingOffering(tenantId, fetchImpl).then( + (offering) => offering?.declaredSources ?? null, + ); +} + +/** + * Re-deploys an existing agent onto the current offering, preserving its + * config. Fails closed for the default worker and anything this pipeline + * didn't deploy (no slug to keep the address stable under), and when no + * provider is connected yet (`deployAgentSource` throws there). + */ +export async function redeployAgentForProviderChange( + args: { readonly tenantId: string; readonly agent: ChatAgent }, + fetchImpl: typeof fetch = fetch, +): Promise { + const slug = agentSlugFromSourceAssetName(args.agent.assetName); + if (slug === null) { + throw new AgentRedeployError(`${args.agent.assetName} was not deployed by this workbench`); + } + const [source, mcpHandles, grants] = await Promise.all([ + readAgentSource(args.tenantId, args.agent.id, args.agent.assetName, fetchImpl), + readAgentMcpHandles(args.tenantId, args.agent.id, args.agent.assetName, fetchImpl), + readWorkerToolGrants(args.tenantId, args.agent), + ]); + return deployAgentSource( + { + tenantId: args.tenantId, + input: buildAgentRedeployInput({ + name: args.agent.name, + slug, + systemPrompt: source.systemPrompt, + mcpHandles, + toolGrants: grants?.tools ?? [], + }), + }, + fetchImpl, + ); +} From 3f698a4a9124a9d783bf9513a3303f2e4fb65bcd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 2 Oct 2026 23:56:02 -0700 Subject: [PATCH 2/3] feat(web): offer re-deploy on the worker page when the inference provider changes (CL-9781) --- apps/web/src/pages/worker-page.css | 6 +++ apps/web/src/pages/worker-page.tsx | 74 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/apps/web/src/pages/worker-page.css b/apps/web/src/pages/worker-page.css index eba4b18b6..a3d1e00ac 100644 --- a/apps/web/src/pages/worker-page.css +++ b/apps/web/src/pages/worker-page.css @@ -243,6 +243,12 @@ color: var(--danger-ink); } +.wp-redeploy { + margin-top: 0.75rem; + border-top: 1px solid var(--line); + padding-top: 0.25rem; +} + .wp-hint { font-size: 12.5px; color: var(--ink-3); diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx index 3db6c944f..4e2e04d55 100644 --- a/apps/web/src/pages/worker-page.tsx +++ b/apps/web/src/pages/worker-page.tsx @@ -20,6 +20,11 @@ import { PageLayout } from "../shell/page-layout"; import { useWorkerRole } from "../worker-role-query"; import { updateGrant, type Grant } from "../settings/tenancy-api"; import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy"; +import { + needsAgentRedeployForProviderChange, + readCurrentOfferingSources, + redeployAgentForProviderChange, +} from "../agent-model-redeploy"; import { readAgentMcpHandles, readAgentSource } from "../agent-source-read"; import { TOOL_PREFIX, @@ -263,6 +268,12 @@ function approvalsLabel(tally: ReturnType): string { return `${once + always + denied + waiting} asked · ${once + always} allowed`; } +function formatSources( + sources: readonly { readonly provider: string; readonly model: string }[], +): string { + return sources.map((source) => `${source.provider} · ${source.model}`).join(", "); +} + function DetailsCard({ tenantId, agent, @@ -280,6 +291,39 @@ function DetailsCard({ const thisWeek = (own ?? []).filter((run) => Date.parse(run.createdAt) >= weekStart); const tally = useApprovalTally(tenantId, thisWeek.slice(0, 50)); const created = (own ?? []).map((run) => Date.parse(run.createdAt)).sort((a, b) => a - b)[0]; + + // Only agents this pipeline deployed can be re-deployed from here — only + // they carry a slug to keep the address stable under. The default worker's + // provider moves are handled where the provider is edited (Settings). + const queryClient = useQueryClient(); + const redeployable = agentSlugFromSourceAssetName(agent.assetName) !== null; + const offering = useQuery({ + queryKey: [...tenantKeys.agents(tenantId), "offering"], + queryFn: () => readCurrentOfferingSources(tenantId), + enabled: redeployable, + }); + const declared = source.data?.declaredSources; + const current = offering.data; + const providerChanged = + redeployable && + declared !== undefined && + current !== undefined && + needsAgentRedeployForProviderChange(declared, current); + + // Re-deploying preserves the agent's config (instructions, servers, + // permissions) — `redeployAgentForProviderChange` carries it over, so the + // move onto the current provider loses nothing the person set. + const redeploy = useMutation({ + mutationFn: () => redeployAgentForProviderChange({ tenantId, agent }), + onSuccess: async () => { + toast("Worker re-deployed onto the current provider"); + await queryClient.invalidateQueries({ queryKey: tenantKeys.agents(tenantId) }); + }, + onError: (cause) => { + reportError(cause, { operation: "worker_provider_redeploy", tenantId }); + }, + }); + return (

Details

@@ -303,6 +347,36 @@ function DetailsCard({ )} + {!providerChanged ? null : ( +
+

+ The inference provider changed since this worker was deployed — it still runs on{" "} + {formatSources(declared ?? [])}, while the workspace now offers{" "} + {formatSources(current ?? [])}. Re-deploying moves it over and keeps its instructions, + servers, and permissions. +

+ {redeploy.isError ? ( +

+ {describeApiError(redeploy.error, "re-deploy this worker")} +

+ ) : null} +
+ redeploy.mutate()} + > + {redeploy.isPending ? "Re-deploying…" : "Re-deploy"} + + + {redeploy.isPending + ? "Moving onto the current provider" + : "Re-deploys and restarts this worker"} + +
+
+ )}
); } From f7ad417500153f2924c0beafb9441eb7d6db1b90 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 3 Oct 2026 09:47:53 -0700 Subject: [PATCH 3/3] test(e2e): prove edit-and-redeploy preserves agent config (CL-9781) Deploy an agent via deployAgentSource, grow the workspace offering chain, then redeployAgentForProviderChange: the redeploy mints a new deployment on the same source asset (slug stable), keeps the definition mail trigger, system prompt, and MCP bindings, and adopts the current offering. Roles intentionally unasserted: role assignment lives on PR 1093 (CL-9780), still open; a TODO in the test names the post-merge carry assertion. The e2e project gains the web client's jsx/paths/DOM settings plus ambient CSS/?raw declarations so it can typecheck the client sources it exercises. --- e2e/install/agent-redeploy.test.ts | 346 +++++++++++++++++++++++++++++ e2e/test-env.d.ts | 9 + e2e/tsconfig.json | 7 +- 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 e2e/install/agent-redeploy.test.ts create mode 100644 e2e/test-env.d.ts diff --git a/e2e/install/agent-redeploy.test.ts b/e2e/install/agent-redeploy.test.ts new file mode 100644 index 000000000..07d415dc5 --- /dev/null +++ b/e2e/install/agent-redeploy.test.ts @@ -0,0 +1,346 @@ +// Proves the edit-and-redeploy flow over stock routes: an agent deployed via +// `deployAgentSource` is re-deployed via `redeployAgentForProviderChange` +// after the workspace offering chain changes. The redeploy mints a new +// deployment but keeps the slug (same source asset), the trigger address, the +// system prompt, and the MCP bindings, while adopting the workspace's current +// inference offering. Skips without DATABASE_URL. +// +// TODO(CL-9780): once PR 1093 lands and this branch rebases onto main, extend +// this test to cover the role carry: assign a stock role to the agent's +// workflow principal before the redeploy (POST +// `/api/tenants/:id/principals/:principalId/roles/:roleId`), then assert the +// redeploy re-assigns it to the new deployment's principal. Until then there +// are no roles on this branch to carry, so this test asserts no roles. +import "fake-indexeddb/auto"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { type } from "arktype"; +import { agentSlugFromSourceAssetName, deployAgentSource } from "../../apps/web/src/agent-deploy"; +import { + needsAgentRedeployForProviderChange, + readCurrentOfferingSources, + redeployAgentForProviderChange, +} from "../../apps/web/src/agent-model-redeploy"; +import { readAgentMcpHandles, readAgentSource } from "../../apps/web/src/agent-source-read"; +import { listChatAgents, type ChatAgent } from "../../apps/web/src/chat/threads-api"; +import { fetchSourceFile } from "../../apps/web/src/git-fetch"; +import { withGitToken } from "../../apps/web/src/git-token"; +import { slugify } from "../../apps/web/src/lib/slug/slug"; +import { + parseWorkflowSourceDefinition, + WORKFLOW_SOURCE_DEFINITION_PATH, +} from "../../packages/workflows/src/client"; +import { dbGate } from "../lib/db-gate"; +import { bootHub } from "../lib/hub"; + +const describeIfDb = dbGate(process.env["DATABASE_URL"] ?? "", import.meta.path); + +const Id = type({ id: "string" }); +const DefinitionTriggersShape = type({ + triggers: type({ type: "string", to: "string" }).array(), +}); +const DeploymentShape = type({ id: "string", status: "string", definitionAssetId: "string" }); + +// The client deploy/reads below run on ambient `fetch` plus +// `globalThis.location` (a browser surface). In this bun test both are +// shimmed onto the authed hub caller for the duration of the redeploy test, +// then restored — the same dressing `install-package.test.ts` builds per +// call, lifted to ambient so the unmodified product path is exercised. +function installAmbientClientShims(origin: string, cookie: string): () => void { + const realFetch = globalThis.fetch; + const hadLocation = "location" in globalThis; + const previousLocation = (globalThis as { location?: unknown }).location; + const authed = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" && input.startsWith("/") ? `${origin}${input}` : input; + return realFetch(url as string, { + ...init, + headers: { + origin, + cookie, + ...((init?.headers as Record | undefined) ?? {}), + }, + }); + }) as typeof fetch; + globalThis.fetch = authed; + (globalThis as { location?: unknown }).location = { origin }; + return () => { + globalThis.fetch = realFetch; + if (hadLocation) { + (globalThis as { location?: unknown }).location = previousLocation; + } else { + delete (globalThis as { location?: unknown }).location; + } + }; +} + +describeIfDb("agent redeploy on provider change", () => { + let server: ReturnType | undefined; + beforeAll(() => { + // Bound before the hub boots so BASE_URL names the real port. + server = Bun.serve({ port: 0, fetch: () => new Response("booting", { status: 503 }) }); + }); + // The in-process sidecar dials the hub's own port back. + const booted = bootHub({ + baseUrl: () => `http://localhost:${String(server?.port)}`, + port: () => server?.port ?? 0, + }); + + afterAll(async () => { + await server?.stop(true); + }); + + test("redeploy keeps slug, address, prompt, and MCP bindings on a new offering", async () => { + const hub = booted(); + if (server === undefined) throw new Error("server not bound"); + server.reload({ + websocket: hub.websocket as Bun.WebSocketHandler, + fetch: (req, srv) => hub.fetch(req, srv), + }); + const origin = `http://localhost:${String(server.port)}`; + const suffix = crypto.randomUUID().slice(0, 8); + + const signUp = await fetch(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ + email: `alice-${suffix}@example.com`, + password: "password123", + name: "alice", + }), + }); + expect(signUp.status).toBe(200); + const cookie = signUp.headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + const call = (input: string | URL | Request, init: RequestInit = {}) => + fetch(input, { + ...init, + headers: { origin, cookie, ...(init.headers as Record | undefined) }, + }); + const post = (route: string, body: unknown) => + call(`${origin}${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + async function created(res: Response): Promise { + expect(res.status).toBe(201); + return Id.assert(await res.json()).id; + } + + const tenantSlug = `ws-redeploy-${suffix}`; + const tenantId = await created( + await post("/api/tenants", { name: "Workspace", slug: tenantSlug }), + ); + const domain = `${tenantSlug}.localhost`; + + // A model offering is the one thing a deployment needs from the tenant. + const t = `/api/tenants/${tenantId}`; + const provider = await created( + await post(`${t}/providers`, { + name: "anthropic", + plugin: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + }), + ); + const credential = await created( + await post(`${t}/credentials`, { + providerId: provider, + name: "anthropic-key", + type: "api_key", + secret: "sk-ant-placeholder", + }), + ); + const modelProvider = await created( + await post(`${t}/catalog/providers`, { + name: "anthropic", + plugin: "anthropic", + baseURL: "https://api.anthropic.com", + credentialId: credential, + }), + ); + const model = await created( + await post(`${t}/catalog/models`, { canonicalName: "claude-sonnet-4-5" }), + ); + await created( + await post(`${t}/catalog/offerings`, { + modelId: model, + providerId: modelProvider, + priority: 0, + }), + ); + + // One workspace-catalog MCP server, stored exactly the way the Tools page + // stores one (provider row plus a credential carrying the `mcp` catalog), + // so the agent has a binding worth preserving. No discovery runs: the + // deploy reads the stored catalog, never the network. + const mcpProvider = await created( + await post(`${t}/providers`, { + name: `mcp-notes-${suffix}`, + plugin: "mcp-streamable-http", + apiBaseUrl: "https://notes.example.com", + }), + ); + await created( + await post(`${t}/credentials`, { + providerId: mcpProvider, + name: `mcp-notes-cred-${suffix}`, + type: "api_key", + secret: "no-token", + metadata: { + mcp: { + handle: "notes", + name: "Notes", + url: "https://notes.example.com", + auth: "none", + tools: [], + }, + }, + }), + ); + + const restoreAmbient = installAmbientClientShims(origin, cookie); + try { + const name = "Note Taker"; + const slug = slugify(name); + const assetName = `agent-${slug}-source`; + const systemPrompt = "You are a note-taking assistant. Be concise."; + const triggerAddress = `${slug}@${domain}`; + + // The mail trigger recipient the deployed definition carries — the + // address half of "same slug/trigger address" (the run address itself + // is per-deployment by design: the hub mints it at deploy time). + async function readTriggerRecipients(assetId: string): Promise { + const gitUrl = `${origin}/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`; + return withGitToken({ + tenantId, + assetId, + actions: ["can_read"], + lifetimeMs: 10 * 60 * 1000, + fetchImpl: call as typeof fetch, + use: async (token) => { + const file = await fetchSourceFile({ + url: gitUrl, + token, + filepath: WORKFLOW_SOURCE_DEFINITION_PATH, + }); + const parsed = DefinitionTriggersShape( + JSON.parse(parseWorkflowSourceDefinition(file, assetId)), + ); + if (parsed instanceof type.errors) { + throw new Error(`unexpected definition shape: ${parsed.summary}`); + } + return parsed.triggers.map((trigger) => trigger.to); + }, + }); + } + + const first = await deployAgentSource( + { tenantId, input: { name, systemPrompt, mcpHandles: ["notes"] } }, + call as typeof fetch, + ); + expect(agentSlugFromSourceAssetName(assetName)).toBe(slug); + + // The roster joins deployments against runs for addresses; right after + // an install the new deployment row may not be listed yet, so wait for + // the row itself the way `install-package.test.ts` polls for a run's + // first events. Status is deliberately unchecked: the run can fail + // asynchronously (this test's provider key is a placeholder, so the + // harness cannot reach inference) while the deploy — the asset, the + // definition, the address it declares — is what this test proves. + // Waiting on the deployment id (not the roster) also keeps the + // post-redeploy read from resolving to the previous deployment. + async function waitForDeployment(deploymentId: string): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + const listed = await call(`${origin}${t}/workflows/deployments`); + const rows = DeploymentShape.array().assert(await listed.json()); + if (rows.some((candidate) => candidate.id === deploymentId)) return; + await Bun.sleep(500); + } + throw new Error(`deployment ${deploymentId} never listed after deploy`); + } + + async function readLiveAgent(): Promise { + const found = (await listChatAgents(tenantId)).find( + (candidate) => candidate.assetName === assetName, + ); + if (found === undefined) throw new Error("agent missing from the roster after deploy"); + return found; + } + + const readBack = await readAgentSource( + tenantId, + first.assetId, + assetName, + call as typeof fetch, + ); + expect(readBack.systemPrompt).toBe(systemPrompt); + expect( + await readAgentMcpHandles(tenantId, first.assetId, assetName, call as typeof fetch), + ).toEqual(["notes"]); + + await waitForDeployment(first.deploymentId); + const agent = await readLiveAgent(); + // Run addresses are per-deployment (the hub mints them); the roster + // slot under the same asset name keeps serving this tenant's domain. + expect(agent.addresses.some((address) => address.endsWith(`@${domain}`))).toBe(true); + expect(await readTriggerRecipients(first.assetId)).toContain(triggerAddress); + + // The workspace grows a fallback offering: the agent's declared chain + // no longer matches, so the worker page would offer a re-deploy. + expect( + needsAgentRedeployForProviderChange(readBack.declaredSources, readBack.declaredSources), + ).toBe(false); + const modelProvider2 = await created( + await post(`${t}/catalog/providers`, { + name: "anthropic-2", + plugin: "anthropic", + baseURL: "https://api.anthropic.com", + credentialId: credential, + }), + ); + await created( + await post(`${t}/catalog/offerings`, { + modelId: model, + providerId: modelProvider2, + priority: 1, + }), + ); + + const redeployed = await redeployAgentForProviderChange( + { tenantId, agent }, + call as typeof fetch, + ); + + // A new deployment on the same source asset: the slug, and therefore + // the asset name, trigger address, and cron definition name, survive. + expect(redeployed.deploymentId).not.toBe(first.deploymentId); + expect(redeployed.assetId).toBe(first.assetId); + + await waitForDeployment(redeployed.deploymentId); + const after = await readLiveAgent(); + expect(after.addresses.some((address) => address.endsWith(`@${domain}`))).toBe(true); + expect(await readTriggerRecipients(redeployed.assetId)).toContain(triggerAddress); + + // Config carries over while inference moves onto the new chain. + const reread = await readAgentSource( + tenantId, + redeployed.assetId, + assetName, + call as typeof fetch, + ); + expect(reread.systemPrompt).toBe(systemPrompt); + // The redeploy adopts the workspace's current chain: what it declares + // is exactly what the offering resolves to now. + const current = await readCurrentOfferingSources(tenantId, call as typeof fetch); + if (current === null) throw new Error("workspace offering missing after redeploy"); + expect(reread.declaredSources).toEqual([...current]); + expect(needsAgentRedeployForProviderChange(reread.declaredSources, current)).toBe(false); + expect( + await readAgentMcpHandles(tenantId, redeployed.assetId, assetName, call as typeof fetch), + ).toEqual(["notes"]); + } finally { + restoreAmbient(); + } + }, 180_000); +}); diff --git a/e2e/test-env.d.ts b/e2e/test-env.d.ts new file mode 100644 index 000000000..cffc6d2fa --- /dev/null +++ b/e2e/test-env.d.ts @@ -0,0 +1,9 @@ +// Lets the e2e project typecheck the web client sources it exercises: the +// app covers side-effect CSS imports via `vite/client` and worker `?raw` +// bundle-text imports via `packages/worker/src/raw-module.d.ts`, neither of +// which is in this project's include. +declare module "*.css"; +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index b3a3ce178..9208580bb 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -3,7 +3,12 @@ "compilerOptions": { "types": ["bun"], "customConditions": ["bun"], - "noEmit": true + "noEmit": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "paths": { + "@/*": ["../apps/web/src/*"] + } }, "include": ["**/*.ts"] }