fix: defer slash model cache ownership

This commit is contained in:
Shakker
2026-08-02 20:48:27 +01:00
parent 3a5e57f505
commit cbf8066a0d
6 changed files with 88 additions and 9 deletions
+33
View File
@@ -658,6 +658,39 @@ describe("createSessionCapability", () => {
sessions.dispose();
});
it("defers model override publication when the caller owns lifecycle validation", async () => {
const pendingPatch = deferred<unknown>();
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<void>();
const request = vi.fn(async (method: string) => {
+2
View File
@@ -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<unknown>;
/**
+6 -2
View File
@@ -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);
}
@@ -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<SessionsListResult>((resolve) => {
+10 -4
View File
@@ -102,6 +102,7 @@ async function patchSession(
context: SlashCommandContext,
sessionKey: string,
patch: Parameters<typeof patchChatCommandSessionSettings>[2],
options?: Parameters<typeof patchChatCommandSessionSettings>[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 }),
+4 -1
View File
@@ -115,6 +115,7 @@ export function patchChatSessionSettings(
patch: Pick<SessionPatch, "model" | "thinkingLevel" | "fastMode" | "toolOverrides">,
options: {
agentId?: string;
deferModelOverride?: boolean;
reconcile?: (result: SessionsPatchResult) => Promise<void> | void;
} = {},
): Promise<SessionsPatchResult | null> {
@@ -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<NonNullable<Awaited<ReturnType<SessionCapability["patch"]>>>> {
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");