mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix: defer slash model cache ownership
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user