The negotiated MCP-Apps capability lived only in the tool server instance, set from the client at initialize. A session whose Durable Object was evicted (deploy, idle) and cold-restored on the same mcp-session-id was rebuilt with no initialize to replay, so apps support read back as false and create-artifact/show-artifact answered with the deep-link fallback instead of the inline widget, mid-conversation. Persist the negotiated bit in SessionMeta and seed the rebuilt server from it. A real initialize still overrules the restored value in both directions, and init carries the stored bit through its re-resolve so restoring a session no longer erases it.
Unified · Split
| @@ -280,6 +280,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 279 unmodified lines | ||
| 280 | 280 | // flag existed carry no value; absent now means off, same as a fresh |
| 281 | 281 | // connection that did not ask for `?artifacts=true`. |
| 282 | 282 | artifactsEnabled: sessionMeta.artifactsEnabled ?? false, |
| 283 | + // Cold restores rebuild this server with no `initialize` to replay, so | |
| 284 | + // the negotiated apps support comes back from storage instead. | |
| 285 | + restoredAppsEnabled: sessionMeta.appsEnabled ?? false, | |
| 286 | + onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), | |
| 283 | 287 | loadAppShellHtml, |
| 284 | 288 | smokeRenderArtifact, |
| 285 | 289 | artifactUrl: artifactUrlFor( |
| @@ -149,6 +149,10 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 148 unmodified lines | ||
| 149 | 149 | // flag existed carry no value; absent now means off, same as a fresh |
| 150 | 150 | // connection that did not ask for `?artifacts=true`. |
| 151 | 151 | artifactsEnabled: sessionMeta.artifactsEnabled ?? false, |
| 152 | + // Cold restores rebuild this server with no `initialize` to replay, so | |
| 153 | + // the negotiated apps support comes back from storage instead. | |
| 154 | + restoredAppsEnabled: sessionMeta.appsEnabled ?? false, | |
| 155 | + onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), | |
| 152 | 156 | loadAppShellHtml: self.loadAppShellHtml, |
| 153 | 157 | smokeRenderArtifact, |
| 154 | 158 | ...(artifactOrigin |
| @@ -216,6 +216,96 @@ const makeHarnessSession = async (): Promise<HarnessSession> => { · 215 unmodified lines | ||
| 216 | 216 | return session; |
| 217 | 217 | }; |
| 218 | 218 | |
| 219 | +// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives | |
| 220 | +// in the rebuilt server's memory. These pin the storage round-trip that lets a | |
| 221 | +// cold-restored session rebuild with it instead of silently downgrading every | |
| 222 | +// artifact to a deep link. | |
| 223 | +describe("McpAgentSessionDOBase apps capability persistence", () => { | |
| 224 | + type CapabilitySession = HarnessSession & { | |
| 225 | + persistAppsEnabled: (appsEnabled: boolean) => Effect.Effect<void>; | |
| 226 | + loadSessionMeta: () => Effect.Effect<SessionMeta | null>; | |
| 227 | + resolveSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>; | |
| 228 | + resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>; | |
| 229 | + }; | |
| 230 | + | |
| 231 | + const baseMeta: SessionMeta = { | |
| 232 | + organizationId: "org-1", | |
| 233 | + organizationName: "Org 1", | |
| 234 | + userId: "user-1", | |
| 235 | + resource: defaultMcpResource, | |
| 236 | + }; | |
| 237 | + | |
| 238 | + const makeCapabilitySession = async ( | |
| 239 | + stored: SessionMeta = baseMeta, | |
| 240 | + ): Promise<{ session: CapabilitySession; storage: MemoryStorage }> => { | |
| 241 | + const storage = new MemoryStorage(); | |
| 242 | + await storage.put("session-meta", stored); | |
| 243 | + const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession; | |
| 244 | + session.ctx = storage; | |
| 245 | + session.getSessionId = () => "session-caps"; | |
| 246 | + return { session, storage }; | |
| 247 | + }; | |
| 248 | + | |
| 249 | + it("persists the negotiated capability so a later restore can read it back", async () => { | |
| 250 | + const { session, storage } = await makeCapabilitySession(); | |
| 251 | + | |
| 252 | + await Effect.runPromise(session.persistAppsEnabled(true)); | |
| 253 | + | |
| 254 | + expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ | |
| 255 | + organizationId: "org-1", | |
| 256 | + appsEnabled: true, | |
| 257 | + }); | |
| 258 | + }); | |
| 259 | + | |
| 260 | + it("records a client that loses apps support just as durably", async () => { | |
| 261 | + const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true }); | |
| 262 | + | |
| 263 | + await Effect.runPromise(session.persistAppsEnabled(false)); | |
| 264 | + | |
| 265 | + expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ appsEnabled: false }); | |
| 266 | + }); | |
| 267 | + | |
| 268 | + // `init` runs again on every cold restore and rebuilds meta from the bearer | |
| 269 | + // token, which carries no capabilities. If that overwrite won, restoring the | |
| 270 | + // session would erase the very bit meant to survive it. | |
| 271 | + it("carries the stored capability through the re-resolve on cold restore", async () => { | |
| 272 | + const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true }); | |
| 273 | + // What the token resolves to: no `appsEnabled` anywhere in sight. | |
| 274 | + session.resolveSessionMeta = () => Effect.succeed(baseMeta); | |
| 275 | + | |
| 276 | + const resolved = await Effect.runPromise( | |
| 277 | + session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }), | |
| 278 | + ); | |
| 279 | + | |
| 280 | + expect(resolved.appsEnabled).toBe(true); | |
| 281 | + expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ appsEnabled: true }); | |
| 282 | + }); | |
| 283 | + | |
| 284 | + it("leaves a session with no negotiated capability untouched", async () => { | |
| 285 | + const { session, storage } = await makeCapabilitySession(); | |
| 286 | + session.resolveSessionMeta = () => Effect.succeed(baseMeta); | |
| 287 | + | |
| 288 | + const resolved = await Effect.runPromise( | |
| 289 | + session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }), | |
| 290 | + ); | |
| 291 | + | |
| 292 | + expect(resolved.appsEnabled).toBeUndefined(); | |
| 293 | + expect(await storage.get<SessionMeta>("session-meta")).not.toHaveProperty("appsEnabled"); | |
| 294 | + }); | |
| 295 | + | |
| 296 | + // Persistence is best-effort observation of a capability, never a reason to | |
| 297 | + // fail the session that was merely trying to render something. | |
| 298 | + it("stays silent when there is no stored meta to merge into", async () => { | |
| 299 | + const storage = new MemoryStorage(); | |
| 300 | + const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession; | |
| 301 | + session.ctx = storage; | |
| 302 | + session.getSessionId = () => "session-caps"; | |
| 303 | + | |
| 304 | + await expect(Effect.runPromise(session.persistAppsEnabled(true))).resolves.toBeUndefined(); | |
| 305 | + expect(await storage.get<SessionMeta>("session-meta")).toBeUndefined(); | |
| 306 | + }); | |
| 307 | +}); | |
| 308 | + | |
| 219 | 309 | describe("McpAgentSessionDOBase transport restore", () => { |
| 220 | 310 | it("restores a same-session request after idle disposal leaves a stale server transport", async () => { |
| 221 | 311 | const session = await makeHarnessSession(); |
| @@ -109,6 +109,17 @@ export interface SessionMeta { · 108 unmodified lines | ||
| 109 | 109 | * `buildMcpServer` scopes the tool catalog to it. */ |
| 110 | 110 | readonly resource: McpResource; |
| 111 | 111 | readonly webOrigin?: string; |
| 112 | + /** | |
| 113 | + * Whether this session's client advertised MCP-Apps support at `initialize`. | |
| 114 | + * | |
| 115 | + * Capabilities are negotiated once, into the server instance's memory. When | |
| 116 | + * the DO is evicted (deploy, idle) and a later request cold-restores it, the | |
| 117 | + * rebuilt server never sees an `initialize` — so without persisting this, an | |
| 118 | + * apps-capable client silently drops to artifact deep links mid-conversation. | |
| 119 | + * Absent — including for sessions persisted before this field existed — means | |
| 120 | + * unknown, which behaves as disabled until the next `initialize`. | |
| 121 | + */ | |
| 122 | + readonly appsEnabled?: boolean; | |
| 112 | 123 | } |
| 113 | 124 | |
| 114 | 125 | export interface BuiltMcpServer { |
| @@ -355,6 +366,28 @@ export abstract class McpAgentSessionDOBase< · 240 unmodified lines | ||
| 355 | 366 | await this.ctx.storage.put(SESSION_META_KEY, sessionMeta); |
| 356 | 367 | } |
| 357 | 368 | |
| 369 | + /** | |
| 370 | + * Persist the MCP-Apps support negotiated at `initialize`, so a later cold | |
| 371 | + * restore can rebuild the server with it. Subclasses hand this to | |
| 372 | + * `createExecutorMcpServer` as `onAppsEnabledChange`. | |
| 373 | + * | |
| 374 | + * A no-op before meta exists: `initialize` always follows `init`, so there is | |
| 375 | + * nothing to merge into and nothing worth failing the session over. | |
| 376 | + */ | |
| 377 | + protected persistAppsEnabled(appsEnabled: boolean): Effect.Effect<void> { | |
| 378 | + const self = this; | |
| 379 | + return Effect.gen(function* () { | |
| 380 | + const stored = yield* self.loadSessionMeta(); | |
| 381 | + if (!stored || stored.appsEnabled === appsEnabled) return; | |
| 382 | + yield* Effect.promise(() => self.saveSessionMeta({ ...stored, appsEnabled })); | |
| 383 | + }).pipe( | |
| 384 | + Effect.withSpan("mcp.session.persist_apps_enabled", { | |
| 385 | + attributes: { "mcp.artifact.apps_enabled": appsEnabled }, | |
| 386 | + }), | |
| 387 | + Effect.ignoreCause({ log: false }), | |
| 388 | + ); | |
| 389 | + } | |
| 390 | + | |
| 358 | 391 | private async markActivity(now = Date.now()): Promise<void> { |
| 359 | 392 | this.lastActivityMs = now; |
| 360 | 393 | await Promise.all([ |
| @@ -466,9 +499,15 @@ export abstract class McpAgentSessionDOBase< · 105 unmodified lines | ||
| 466 | 499 | const self = this; |
| 467 | 500 | return Effect.gen(function* () { |
| 468 | 501 | const resolved = yield* self.resolveSessionMeta(token); |
| 502 | + // `init` runs again on every cold restore, and `resolveSessionMeta` | |
| 503 | + // rebuilds meta from the bearer token — which carries no negotiated | |
| 504 | + // capabilities. Carry the stored value forward, or restoring the session | |
| 505 | + // would erase the very bit that survives the restore. | |
| 506 | + const stored = yield* self.loadSessionMeta(); | |
| 469 | 507 | const sessionMeta: SessionMeta = { |
| 470 | 508 | ...resolved, |
| 471 | 509 | ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), |
| 510 | + ...(stored?.appsEnabled === undefined ? {} : { appsEnabled: stored.appsEnabled }), | |
| 472 | 511 | }; |
| 473 | 512 | yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( |
| 474 | 513 | Effect.withSpan("mcp.session.save_meta"), |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { describe, expect, it, vi } from "@effect/vitest"; | |
| 2 | 2 | import { Data, Effect } from "effect"; |
| 3 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| @@ -236,6 +236,147 @@ describe("MCP host — artifact tool visibility", () => { · 231 unmodified lines | ||
| 236 | 236 | ); |
| 237 | 237 | }); |
| 238 | 238 | |
| 239 | + // A cold restore (deploy, DO eviction) rebuilds the server mid-conversation | |
| 240 | + // on the same session id, and the client — already past `initialize` — never | |
| 241 | + // sends another one. Everything the rebuilt server knows about the client's | |
| 242 | + // capabilities has to come from what the host persisted. | |
| 243 | + it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => { | |
| 244 | + const store = makeArtifactStore(); | |
| 245 | + const mcpServer = await Effect.runPromise( | |
| 246 | + createExecutorMcpServer({ | |
| 247 | + engine: makeStubEngine({}), | |
| 248 | + artifacts: store.port, | |
| 249 | + artifactsEnabled: true, | |
| 250 | + loadAppShellHtml: () => Promise.resolve(SHELL_HTML), | |
| 251 | + // What the host read back out of DO storage for this session. | |
| 252 | + restoredAppsEnabled: true, | |
| 253 | + }), | |
| 254 | + ); | |
| 255 | + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | |
| 256 | + await mcpServer.connect(serverTransport); | |
| 257 | + // Deliberately NOT an SDK `Client`: `client.connect()` always sends | |
| 258 | + // `initialize`, which is the one thing a cold restore never replays. The | |
| 259 | + // client is mid-conversation and just keeps issuing calls against the same | |
| 260 | + // mcp-session-id, so this drives raw JSON-RPC over the transport. | |
| 261 | + const responses = new Map<number, Record<string, unknown>>(); | |
| 262 | + clientTransport.onmessage = (message) => { | |
| 263 | + const envelope = message as { id?: number; result?: Record<string, unknown> }; | |
| 264 | + if (typeof envelope.id === "number" && envelope.result) { | |
| 265 | + responses.set(envelope.id, envelope.result); | |
| 266 | + } | |
| 267 | + }; | |
| 268 | + await clientTransport.start(); | |
| 269 | + const call = async (id: number, method: string, params: Record<string, unknown>) => { | |
| 270 | + await clientTransport.send({ jsonrpc: "2.0", id, method, params }); | |
| 271 | + await vi.waitFor(() => expect(responses.has(id)).toBe(true)); | |
| 272 | + return responses.get(id) ?? {}; | |
| 273 | + }; | |
| 274 | + | |
| 275 | + const listed = (await call(1, "tools/list", {})) as { | |
| 276 | + tools: Array<{ name: string }>; | |
| 277 | + }; | |
| 278 | + const names = listed.tools.map((tool) => tool.name); | |
| 279 | + expect(names).toContain("execute-action"); | |
| 280 | + expect(names).toContain("execute-action-resume"); | |
| 281 | + | |
| 282 | + // The render path is the symptom that took this to production: the same | |
| 283 | + // session rendered inline before the restore and a deep link after. | |
| 284 | + const created = (await call(2, "tools/call", { | |
| 285 | + name: "create-artifact", | |
| 286 | + arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, | |
| 287 | + })) as { structuredContent?: Record<string, unknown> }; | |
| 288 | + expect(created.structuredContent).toEqual({ code: COUNTER_CODE, artifactId: "art_1" }); | |
| 289 | + expect(created.structuredContent).not.toHaveProperty("status", "fallback_url"); | |
| 290 | + | |
| 291 | + await clientTransport.close(); | |
| 292 | + await serverTransport.close(); | |
| 293 | + }); | |
| 294 | + | |
| 295 | + it("still falls back to a deep link when the restored session had no apps support", async () => { | |
| 296 | + const store = makeArtifactStore(); | |
| 297 | + await withClient( | |
| 298 | + makeStubEngine({}), | |
| 299 | + NO_APPS_CAPS, | |
| 300 | + async (client) => { | |
| 301 | + const result = await client.callTool({ | |
| 302 | + name: "create-artifact", | |
| 303 | + arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, | |
| 304 | + }); | |
| 305 | + expect(structuredOf(result)).toEqual({ | |
| 306 | + status: "fallback_url", | |
| 307 | + url: "https://executor.test/artifacts/art_1", | |
| 308 | + artifactId: "art_1", | |
| 309 | + }); | |
| 310 | + }, | |
| 311 | + { | |
| 312 | + artifacts: store.port, | |
| 313 | + restoredAppsEnabled: false, | |
| 314 | + artifactUrl: artifactUrlFor("https://executor.test"), | |
| 315 | + }, | |
| 316 | + ); | |
| 317 | + }); | |
| 318 | + | |
| 319 | + // The restored value seeds the session; it must never outrank the client in | |
| 320 | + // front of you. A client that reconnects without apps support gets the | |
| 321 | + // fallback, restored bit or not. | |
| 322 | + it("lets a real initialize overrule the restored value in both directions", async () => { | |
| 323 | + const store = makeArtifactStore(); | |
| 324 | + await withClient( | |
| 325 | + makeStubEngine({}), | |
| 326 | + NO_APPS_CAPS, | |
| 327 | + async (client) => { | |
| 328 | + const names = await toolNames(client); | |
| 329 | + expect(names).not.toContain("execute-action"); | |
| 330 | + const result = await client.callTool({ | |
| 331 | + name: "create-artifact", | |
| 332 | + arguments: { code: COUNTER_CODE, title: "Active users dashboard" }, | |
| 333 | + }); | |
| 334 | + expect(structuredOf(result)).toMatchObject({ status: "fallback_url" }); | |
| 335 | + }, | |
| 336 | + { | |
| 337 | + artifacts: store.port, | |
| 338 | + restoredAppsEnabled: true, | |
| 339 | + artifactUrl: artifactUrlFor("https://executor.test"), | |
| 340 | + }, | |
| 341 | + ); | |
| 342 | + }); | |
| 343 | + | |
| 344 | + it("reports the negotiated value to the host so the next restore can seed itself", async () => { | |
| 345 | + const store = makeArtifactStore(); | |
| 346 | + const seen: boolean[] = []; | |
| 347 | + await withClient( | |
| 348 | + makeStubEngine({}), | |
| 349 | + APPS_CAPS, | |
| 350 | + async (client) => { | |
| 351 | + await toolNames(client); | |
| 352 | + }, | |
| 353 | + { | |
| 354 | + artifacts: store.port, | |
| 355 | + restoredAppsEnabled: false, | |
| 356 | + onAppsEnabledChange: (appsEnabled) => Effect.sync(() => void seen.push(appsEnabled)), | |
| 357 | + }, | |
| 358 | + ); | |
| 359 | + expect(seen).toEqual([true]); | |
| 360 | + }); | |
| 361 | + | |
| 362 | + it("does not re-persist when initialize confirms the restored value", async () => { | |
| 363 | + const store = makeArtifactStore(); | |
| 364 | + const seen: boolean[] = []; | |
| 365 | + await withClient( | |
| 366 | + makeStubEngine({}), | |
| 367 | + APPS_CAPS, | |
| 368 | + async (client) => { | |
| 369 | + await toolNames(client); | |
| 370 | + }, | |
| 371 | + { | |
| 372 | + artifacts: store.port, | |
| 373 | + restoredAppsEnabled: true, | |
| 374 | + onAppsEnabledChange: (appsEnabled) => Effect.sync(() => void seen.push(appsEnabled)), | |
| 375 | + }, | |
| 376 | + ); | |
| 377 | + expect(seen).toEqual([]); | |
| 378 | + }); | |
| 379 | + | |
| 239 | 380 | it("registers no ui tools at all when no shell loader is configured", async () => { |
| 240 | 381 | const store = makeArtifactStore(); |
| 241 | 382 | const mcpServer = await Effect.runPromise( |
| @@ -211,6 +211,25 @@ type SharedMcpServerConfig = { · 210 unmodified lines | ||
| 211 | 211 | * result. Hosts recording product analytics supply it; core stays agnostic. |
| 212 | 212 | */ |
| 213 | 213 | readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect<void>; |
| 214 | + /** | |
| 215 | + * Whether the client this session belongs to can render MCP Apps, as | |
| 216 | + * negotiated at a previous `initialize`. | |
| 217 | + * | |
| 218 | + * Capabilities normally arrive from the client at `initialize` and live only | |
| 219 | + * in the server instance. A session whose host evicted and cold-restored it | |
| 220 | + * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay, | |
| 221 | + * so without this the rebuilt server assumes no apps support and silently | |
| 222 | + * downgrades every artifact to a deep link. Hosts that persist the | |
| 223 | + * negotiated value pass it back here; the next `initialize`, if one comes, | |
| 224 | + * overwrites it. | |
| 225 | + */ | |
| 226 | + readonly restoredAppsEnabled?: boolean; | |
| 227 | + /** | |
| 228 | + * Called when `initialize` negotiates the client's MCP-Apps support, so the | |
| 229 | + * host can persist it for {@link restoredAppsEnabled} on a later cold | |
| 230 | + * restore. Best-effort: failures are swallowed and never affect the session. | |
| 231 | + */ | |
| 232 | + readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>; | |
| 214 | 233 | }; |
| 215 | 234 | |
| 216 | 235 | /** |
| @@ -1505,8 +1524,11 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1288 unmodified lines | ||
| 1505 | 1524 | const artifacts = config.artifacts; |
| 1506 | 1525 | |
| 1507 | 1526 | // Set from the client's advertised capabilities at `initialize`. Read by |
| 1508 | − // the render handlers to choose inline widget vs. deep link. | |
| 1509 | − let appsEnabled = false; | |
| 1527 | + // the render handlers to choose inline widget vs. deep link. Seeded from | |
| 1528 | + // the host's persisted value so a cold-restored session — rebuilt with no | |
| 1529 | + // `initialize` to replay — keeps rendering inline for a client that had | |
| 1530 | + // already negotiated apps support. | |
| 1531 | + let appsEnabled = config.restoredAppsEnabled ?? false; | |
| 1510 | 1532 | let executeActionTool: { enable: () => void; disable: () => void } | undefined; |
| 1511 | 1533 | let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined; |
| 1512 | 1534 | |
| @@ -1921,7 +1943,28 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 408 unmodified lines | ||
| 1921 | 1943 | | (ClientCapabilities & { extensions?: Record<string, unknown> }) |
| 1922 | 1944 | | null, |
| 1923 | 1945 | ); |
| 1924 | − appsEnabled = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); | |
| 1946 | + // Absent capabilities (the SDK returns `undefined`) mean `initialize` | |
| 1947 | + // hasn't happened on THIS server instance — the construction-time call | |
| 1948 | + // below, or a cold restore that resumed mid-conversation. Neither is | |
| 1949 | + // evidence the client lost apps support, so the restored value stands | |
| 1950 | + // until a real `initialize` replaces it. Reading `false` off an absent | |
| 1951 | + // value here is exactly what made a cold-restored session fall back to | |
| 1952 | + // deep links. | |
| 1953 | + const negotiated = clientCapabilities | |
| 1954 | + ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) | |
| 1955 | + : appsEnabled; | |
| 1956 | + const changed = negotiated !== appsEnabled; | |
| 1957 | + appsEnabled = negotiated; | |
| 1958 | + | |
| 1959 | + // Persist only a real negotiation that moved the value, so the next cold | |
| 1960 | + // restore seeds itself. Best-effort: the session must not fail on it. | |
| 1961 | + const onAppsEnabledChange = config.onAppsEnabledChange; | |
| 1962 | + if (clientCapabilities && changed && onAppsEnabledChange) { | |
| 1963 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session | |
| 1964 | + void Effect.runPromiseWith(context)( | |
| 1965 | + onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })), | |
| 1966 | + ); | |
| 1967 | + } | |
| 1925 | 1968 | |
| 1926 | 1969 | // `execute-action` is only callable from inside a rendered app, so a |
| 1927 | 1970 | // client that can't render one should never see it. `create-artifact`, |