fix: order slash model cache updates

This commit is contained in:
Shakker
2026-08-02 21:31:45 +01:00
parent 9c021a6bb2
commit ab312ec22f
5 changed files with 106 additions and 39 deletions
@@ -151,9 +151,11 @@ describe("executeSlashCommand directives", () => {
.mockResolvedValue(
createResolvedModelPatch(OPENAI_GPT5_MINI_MODEL.id, OPENAI_GPT5_MINI_MODEL.provider),
);
const setModelOverride = vi.fn();
const sessions = {
...createSessionCapability(client),
patch,
setModelOverride,
} as SessionCapability;
const result = await executeSlashCommandImpl(client, "global", "model", "gpt-5-mini", {
@@ -176,6 +178,36 @@ describe("executeSlashCommand directives", () => {
deferModelOverride: true,
}),
);
expect(setModelOverride).toHaveBeenCalledWith("global", "openai/gpt-5-mini");
});
it("does not publish a slash-command model cache value after its owner retires", async () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const setModelOverride = vi.fn();
const sessions = {
...createSessionCapability(client),
patch: vi
.fn()
.mockResolvedValue(
createResolvedModelPatch(OPENAI_GPT5_MINI_MODEL.id, OPENAI_GPT5_MINI_MODEL.provider),
),
setModelOverride,
} as SessionCapability;
const result = await executeSlashCommandImpl(client, "global", "model", "gpt-5-mini", {
sessions,
sessionAccessSnapshot: {
client,
hello: null,
phase: "connected",
},
agentId: "work",
ownsModelOverride: () => false,
chatModelCatalog: createModelCatalog(OPENAI_GPT5_MINI_MODEL),
});
expect(result.failed).not.toBe(true);
expect(setModelOverride).not.toHaveBeenCalled();
});
it("does not patch through a replacement connection after loading session state", async () => {
+37 -29
View File
@@ -76,6 +76,7 @@ type SlashCommandContext = {
sessionsResultAgentId?: string | null;
defaultAgentId?: string;
agentId?: string;
ownsModelOverride?: () => boolean;
};
function assertCurrentSlashCommand(context: SlashCommandContext): void {
@@ -303,40 +304,47 @@ async function executeModel(
try {
const requestedModel = args.trim();
const [patched, resolvedModelCatalog] = await Promise.all([
patchSession(
context,
sessionKey,
{
model: requestedModel,
const resolvedModelCatalog = modelCatalog
? Promise.resolve(modelCatalog)
: loadModelCatalog(client, { allowFailure: true });
let resolvedOverride: ChatModelOverride | null = null;
await patchSession(
context,
sessionKey,
{
model: requestedModel,
},
{
deferModelOverride: true,
reconcile: async (result) => {
const resolvedModel = result.resolved?.model ?? requestedModel;
let resolvedValue = resolvePreferredServerChatModelValue(
resolvedModel,
result.resolved?.modelProvider,
await resolvedModelCatalog,
);
const requestedOverride = createChatModelOverride(requestedModel);
const resolvedProvider = result.resolved?.modelProvider?.trim();
if (
requestedOverride?.kind === "qualified" &&
resolvedProvider &&
resolvedValue &&
!resolvedValue.toLowerCase().startsWith(`${resolvedProvider.toLowerCase()}/`) &&
requestedOverride.value.toLowerCase().endsWith(`/${resolvedModel.trim().toLowerCase()}`)
) {
resolvedValue = requestedOverride.value;
}
resolvedOverride = createChatModelOverride(resolvedValue);
if (context.ownsModelOverride?.() !== false) {
context.sessions.setModelOverride(sessionKey, resolvedOverride?.value ?? null);
}
},
{ deferModelOverride: true },
),
modelCatalog
? Promise.resolve(modelCatalog)
: loadModelCatalog(client, { allowFailure: true }),
]);
const resolvedModel = patched.resolved?.model ?? requestedModel;
let resolvedValue = resolvePreferredServerChatModelValue(
resolvedModel,
patched.resolved?.modelProvider,
resolvedModelCatalog,
},
);
const requestedOverride = createChatModelOverride(requestedModel);
const resolvedProvider = patched.resolved?.modelProvider?.trim();
if (
requestedOverride?.kind === "qualified" &&
resolvedProvider &&
resolvedValue &&
!resolvedValue.toLowerCase().startsWith(`${resolvedProvider.toLowerCase()}/`) &&
requestedOverride.value.toLowerCase().endsWith(`/${resolvedModel.trim().toLowerCase()}`)
) {
resolvedValue = requestedOverride.value;
}
return {
content: t("chat.commandResults.model.set", { model: `\`${requestedModel}\`` }),
action: "refresh",
sessionPatch: { modelOverride: createChatModelOverride(resolvedValue) },
sessionPatch: { modelOverride: resolvedOverride },
};
} catch (err) {
return {
+1 -8
View File
@@ -423,6 +423,7 @@ export async function dispatchChatSlashCommand(
sessionsResultAgentId: host.sessionsResultAgentId,
defaultAgentId: resolveUiDefaultAgentId(host),
agentId: target.agentId,
ownsModelOverride: () => isChatCommandModelCacheOwnerCurrent(host, target),
});
} catch (err) {
if (targetIsCurrent()) {
@@ -475,14 +476,6 @@ export async function dispatchChatSlashCommand(
}
if (result.sessionPatch && "modelOverride" in result.sessionPatch) {
// A route switch on the same Gateway still owns the originating session's
// cache. A replacement connection must not consume this late command result.
if (isChatCommandModelCacheOwnerCurrent(host, target)) {
host.sessions.setModelOverride(
target.sessionKey,
result.sessionPatch.modelOverride?.value ?? null,
);
}
if (targetIsCurrent()) {
await host.refreshCurrentSessionTools?.();
}
+32 -1
View File
@@ -8,6 +8,7 @@ import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import type { UiSettings } from "../../app/settings.ts";
import { SLASH_COMMANDS } from "../../lib/chat/commands.ts";
import { createSessionCapability } from "../../lib/sessions/index.ts";
import { createResolvedModelPatch } from "../../test-helpers/chat-model.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import {
@@ -1804,6 +1805,36 @@ describe("handleSendChat", () => {
expect(host.chatQueue).toStrictEqual([]);
});
it("keeps a resolved model reconciliation inside the canonical settings queue", async () => {
const reconcile = createDeferred<void>();
let patchCount = 0;
const host = makeHost({
requestHandlers: {
"sessions.patch": () => {
patchCount += 1;
return createResolvedModelPatch(patchCount === 1 ? "gpt-5-mini" : "gpt-5", "openai");
},
},
});
const first = patchChatSessionSettings(
host,
host.sessionKey,
{ model: "openai/gpt-5-mini" },
{ reconcile: async () => await reconcile.promise },
);
await waitForFast(() => expect(patchCount).toBe(1));
const second = patchChatSessionSettings(host, host.sessionKey, {
model: "openai/gpt-5",
});
await Promise.resolve();
expect(patchCount).toBe(1);
reconcile.resolve();
await Promise.all([first, second]);
expect(patchCount).toBe(2);
});
it("keeps waiting when a late picker barrier cannot be persisted", async () => {
const queuedText = "do not bypass the late picker";
const history = createDeferred<unknown>();
@@ -4449,7 +4480,7 @@ describe("handleSendChat", () => {
expect(host.chatRunId).toBeNull();
expect(host.lastError).toBe("Second session error");
expect(host.chatError).toBe("Second session error");
expect(host.sessions.state.modelOverrides[item.sessionKey]).toBe("openai/gpt-5-mini");
expect(host.sessions.state.modelOverrides[item.sessionKey]).toBeUndefined();
expect(refreshCurrentSessionTools).not.toHaveBeenCalled();
expect(refreshCurrentChat).not.toHaveBeenCalled();
});
+4 -1
View File
@@ -166,7 +166,10 @@ export async function patchChatCommandSessionSettings(
context: ChatCommandSettingsContext,
sessionKey: string,
patch: SessionPatch,
options: { deferModelOverride?: boolean } = {},
options: {
deferModelOverride?: boolean;
reconcile?: (result: SessionsPatchResult) => Promise<void> | void;
} = {},
): Promise<NonNullable<Awaited<ReturnType<SessionCapability["patch"]>>>> {
const result = await patchChatSessionSettings(
{