diff --git a/ui/src/lib/sessions/index.test.ts b/ui/src/lib/sessions/index.test.ts index 92e0d7644cb3..3fdc77970444 100644 --- a/ui/src/lib/sessions/index.test.ts +++ b/ui/src/lib/sessions/index.test.ts @@ -658,6 +658,39 @@ describe("createSessionCapability", () => { sessions.dispose(); }); + it("defers model override publication when the caller owns lifecycle validation", async () => { + const pendingPatch = deferred(); + const request = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + return await pendingPatch.promise; + } + if (method === "sessions.subscribe") { + return { subscribed: true }; + } + if (method === "sessions.list") { + return sessionsResult([], 2); + } + throw new Error(`Unexpected request: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const { gateway } = createGatewayHarness(client); + const sessions = createSessionCapability(gateway); + const key = "global"; + sessions.setModelOverride(key, "openai/gpt-old"); + + const operation = sessions.patch( + key, + { model: "openai/gpt-new" }, + { deferListRefresh: true, deferModelOverride: true }, + ); + + expect(sessions.state.modelOverrides[key]).toBe("openai/gpt-old"); + pendingPatch.resolve({ ok: true, path: "", key, entry: {} }); + await expect(operation).resolves.toMatchObject({ ok: true, key }); + expect(sessions.state.modelOverrides[key]).toBe("openai/gpt-old"); + sessions.dispose(); + }); + it("does not dispatch a queued patch on a replacement connection", async () => { const priorPatch = deferred(); const request = vi.fn(async (method: string) => { diff --git a/ui/src/lib/sessions/patch.ts b/ui/src/lib/sessions/patch.ts index b7496a79b0c3..6dc15355c048 100644 --- a/ui/src/lib/sessions/patch.ts +++ b/ui/src/lib/sessions/patch.ts @@ -25,6 +25,8 @@ export type SessionPatch = { export type SessionPatchOptions = { agentId?: string; + /** Let a caller with stricter lifecycle ownership publish the resolved model value. */ + deferModelOverride?: boolean; /** Capture the current connection now, but dispatch only after this tail settles. */ waitFor?: Promise; /** diff --git a/ui/src/lib/sessions/session-mutations.ts b/ui/src/lib/sessions/session-mutations.ts index c6c002fbb519..80806fd58fb4 100644 --- a/ui/src/lib/sessions/session-mutations.ts +++ b/ui/src/lib/sessions/session-mutations.ts @@ -161,13 +161,14 @@ export function createSessionMutations(host: SessionMutationsHost) { return null; } const hasModelPatch = Object.hasOwn(patchParams, "model"); + const managesModelOverride = hasModelPatch && options.deferModelOverride !== true; const normalizedKey = key.trim(); const pendingModelPatch = pendingModelPatches.get(normalizedKey); const previousModelOverride = pendingModelPatch ? pendingModelPatch.previous : host.readState().modelOverrides[normalizedKey]; const modelPatchToken = Symbol(); - if (hasModelPatch) { + if (managesModelOverride) { pendingModelPatches.set(normalizedKey, { token: modelPatchToken, previous: previousModelOverride, @@ -200,7 +201,10 @@ export function createSessionMutations(host: SessionMutationsHost) { return null; } } - if (pendingModelPatches.get(normalizedKey)?.token === modelPatchToken) { + if ( + managesModelOverride && + pendingModelPatches.get(normalizedKey)?.token === modelPatchToken + ) { pendingModelPatches.delete(normalizedKey); setModelOverride(key, patchParams.model); } diff --git a/ui/src/pages/chat/chat-command-executor.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts index 5b8f249a2b36..8081f82655ff 100644 --- a/ui/src/pages/chat/chat-command-executor.test.ts +++ b/ui/src/pages/chat/chat-command-executor.test.ts @@ -5,6 +5,7 @@ import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import { t } from "../../i18n/index.ts"; import type { SessionCapability, SessionPatch } from "../../lib/sessions/index.ts"; +import type { SessionPatchOptions } from "../../lib/sessions/patch.ts"; import { createResolvedModelPatch, createModelCatalog, @@ -25,8 +26,8 @@ function createSessionCapability(client: GatewayBrowserClient): SessionCapabilit list: (options = {}) => request("sessions.list", options), refresh: async () => undefined, create: async () => null, - patch: (key: string, patch: SessionPatch, options: { agentId?: string | null } = {}) => - request("sessions.patch", { key, ...options, ...patch }), + patch: (key: string, patch: SessionPatch, options: SessionPatchOptions = {}) => + request("sessions.patch", { key, agentId: options.agentId, ...patch }), delete: async () => false, deleteMany: async () => ({ deleted: [], errors: [], preservedWorktrees: [] }), reset: async () => true, @@ -143,6 +144,36 @@ describe("executeSlashCommand directives", () => { expectNoRequestCall(request, "sessions.patch"); }); + it("defers slash-command model cache publication to the captured chat owner", async () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const patch = vi.fn().mockResolvedValue(createResolvedModelPatch(OPENAI_GPT5_MINI_MODEL)); + const sessions = { + ...createSessionCapability(client), + patch, + } as SessionCapability; + + const result = await executeSlashCommandImpl(client, "global", "model", "gpt-5-mini", { + sessions, + sessionAccessSnapshot: { + client, + hello: null, + phase: "connected", + }, + agentId: "work", + chatModelCatalog: createModelCatalog([OPENAI_GPT5_MINI_MODEL]), + }); + + expect(result.failed).not.toBe(true); + expect(patch).toHaveBeenCalledWith( + "global", + { model: "gpt-5-mini" }, + expect.objectContaining({ + agentId: "work", + deferModelOverride: true, + }), + ); + }); + it("does not patch through a replacement connection after loading session state", async () => { let resolveList: ((value: SessionsListResult) => void) | undefined; const listResult = new Promise((resolve) => { diff --git a/ui/src/pages/chat/chat-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts index 6da35423240b..a8db5c392470 100644 --- a/ui/src/pages/chat/chat-command-executor.ts +++ b/ui/src/pages/chat/chat-command-executor.ts @@ -102,6 +102,7 @@ async function patchSession( context: SlashCommandContext, sessionKey: string, patch: Parameters[2], + options?: Parameters[3], ) { const params = { key: sessionKey, @@ -112,7 +113,7 @@ async function patchSession( method: "sessions.patch", params, }); - return await patchChatCommandSessionSettings(context, sessionKey, patch); + return await patchChatCommandSessionSettings(context, sessionKey, patch, options); } function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | undefined { @@ -303,9 +304,14 @@ async function executeModel( try { const requestedModel = args.trim(); const [patched, resolvedModelCatalog] = await Promise.all([ - patchSession(context, sessionKey, { - model: requestedModel, - }), + patchSession( + context, + sessionKey, + { + model: requestedModel, + }, + { deferModelOverride: true }, + ), modelCatalog ? Promise.resolve(modelCatalog) : loadModelCatalog(client, { allowFailure: true }), diff --git a/ui/src/pages/chat/chat-settings-patches.ts b/ui/src/pages/chat/chat-settings-patches.ts index b3c609b8672c..c561cf8a8648 100644 --- a/ui/src/pages/chat/chat-settings-patches.ts +++ b/ui/src/pages/chat/chat-settings-patches.ts @@ -115,6 +115,7 @@ export function patchChatSessionSettings( patch: Pick, options: { agentId?: string; + deferModelOverride?: boolean; reconcile?: (result: SessionsPatchResult) => Promise | void; } = {}, ): Promise { @@ -125,6 +126,7 @@ export function patchChatSessionSettings( // redirect queued intent to a replacement Gateway. const result = await host.sessions.patch(sessionKey, patch, { agentId: options.agentId, + deferModelOverride: options.deferModelOverride, waitFor: previous, }); if (result) { @@ -164,6 +166,7 @@ export async function patchChatCommandSessionSettings( context: ChatCommandSettingsContext, sessionKey: string, patch: SessionPatch, + options: { deferModelOverride?: boolean } = {}, ): Promise>>> { const result = await patchChatSessionSettings( { @@ -174,7 +177,7 @@ export async function patchChatCommandSessionSettings( }, sessionKey, patch, - selectedGlobalScope(sessionKey, context), + { ...selectedGlobalScope(sessionKey, context), ...options }, ); if (!result) { throw new Error("Session capability is unavailable");