mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
fix: refresh available tools when the session model changes (#54184)
This commit is contained in:
@@ -136,10 +136,12 @@ describe("handleSendChat", () => {
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const onSlashAction = vi.fn();
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
sessionKey: "main",
|
||||
chatMessage: "/model gpt-5-mini",
|
||||
onSlashAction,
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
@@ -152,6 +154,7 @@ describe("handleSendChat", () => {
|
||||
kind: "qualified",
|
||||
value: "openai/gpt-5-mini",
|
||||
});
|
||||
expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ async function dispatchSlashCommand(
|
||||
...host.chatModelOverrides,
|
||||
[targetSessionKey]: result.sessionPatch.modelOverride ?? null,
|
||||
};
|
||||
host.onSlashAction?.("refresh-tools-effective");
|
||||
}
|
||||
|
||||
if (result.action === "refresh") {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveChatModelOverrideValue,
|
||||
resolveChatModelSelectState,
|
||||
} from "./chat-model-select-state.ts";
|
||||
import { refreshVisibleToolsEffectiveForCurrentSession } from "./controllers/agents.ts";
|
||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
@@ -571,6 +572,7 @@ async function switchChatModel(state: AppViewState, nextModel: string) {
|
||||
key: targetSessionKey,
|
||||
model: nextModel || null,
|
||||
});
|
||||
void refreshVisibleToolsEffectiveForCurrentSession(state);
|
||||
await refreshSessionOptions(state);
|
||||
} catch (err) {
|
||||
// Roll back so the picker reflects the actual server model.
|
||||
|
||||
@@ -22,9 +22,11 @@ import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controlle
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
||||
import {
|
||||
buildToolsEffectiveRequestKey,
|
||||
loadAgents,
|
||||
loadToolsCatalog,
|
||||
loadToolsEffective,
|
||||
refreshVisibleToolsEffectiveForCurrentSession,
|
||||
saveAgentsConfig,
|
||||
} from "./controllers/agents.ts";
|
||||
import { loadChannels } from "./controllers/channels.ts";
|
||||
@@ -1081,7 +1083,10 @@ export function renderApp(state: AppViewState) {
|
||||
void loadToolsCatalog(state, resolvedAgentId);
|
||||
}
|
||||
if (resolvedAgentId === resolveAgentIdFromSessionKey(state.sessionKey)) {
|
||||
const toolsRequestKey = `${resolvedAgentId}:${state.sessionKey}`;
|
||||
const toolsRequestKey = buildToolsEffectiveRequestKey(state, {
|
||||
agentId: resolvedAgentId,
|
||||
sessionKey: state.sessionKey,
|
||||
});
|
||||
if (
|
||||
state.toolsEffectiveResultKey !== toolsRequestKey ||
|
||||
state.toolsEffectiveError
|
||||
@@ -1237,22 +1242,23 @@ export function renderApp(state: AppViewState) {
|
||||
const basePath = ["agents", "list", index, "model"];
|
||||
if (!modelId) {
|
||||
removeConfigFormValue(state, basePath);
|
||||
return;
|
||||
}
|
||||
const entry = Array.isArray(list)
|
||||
? (list[index] as { model?: unknown })
|
||||
: undefined;
|
||||
const existing = entry?.model;
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||
const next = {
|
||||
primary: modelId,
|
||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||
};
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
} else {
|
||||
updateConfigFormValue(state, basePath, modelId);
|
||||
const entry = Array.isArray(list)
|
||||
? (list[index] as { model?: unknown })
|
||||
: undefined;
|
||||
const existing = entry?.model;
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||
const next = {
|
||||
primary: modelId,
|
||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||
};
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
} else {
|
||||
updateConfigFormValue(state, basePath, modelId);
|
||||
}
|
||||
}
|
||||
void refreshVisibleToolsEffectiveForCurrentSession(state);
|
||||
},
|
||||
onModelFallbacksChange: (agentId, fallbacks) => {
|
||||
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
||||
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
||||
import { exportChatMarkdown } from "./chat/export.ts";
|
||||
import { loadToolsEffective as loadToolsEffectiveInternal } from "./controllers/agents.ts";
|
||||
import {
|
||||
loadToolsEffective as loadToolsEffectiveInternal,
|
||||
refreshVisibleToolsEffectiveForCurrentSession as refreshVisibleToolsEffectiveForCurrentSessionInternal,
|
||||
} from "./controllers/agents.ts";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
@@ -486,6 +489,10 @@ export class OpenClawApp extends LitElement {
|
||||
case "export":
|
||||
exportChatMarkdown(this.chatMessages, this.assistantName);
|
||||
break;
|
||||
case "refresh-tools-effective": {
|
||||
void refreshVisibleToolsEffectiveForCurrentSessionInternal(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", this.globalKeydownHandler);
|
||||
|
||||
@@ -21,6 +21,25 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
|
||||
toolsEffectiveResultKey: null,
|
||||
toolsEffectiveError: null,
|
||||
toolsEffectiveResult: null,
|
||||
sessionKey: "main",
|
||||
sessionsResult: {
|
||||
ts: 0,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
model: "gpt-5-mini",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
],
|
||||
},
|
||||
chatModelOverrides: {},
|
||||
chatModelCatalog: [{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }],
|
||||
agentsPanel: "overview",
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
@@ -188,7 +207,7 @@ describe("loadToolsEffective", () => {
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(state.toolsEffectiveResult).toEqual(payload);
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main");
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
expect(state.toolsEffectiveError).toBeNull();
|
||||
expect(state.toolsEffectiveLoading).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { resolveAgentIdFromSessionKey } from "../../../../src/routing/session-key.js";
|
||||
import {
|
||||
resolveChatModelOverride,
|
||||
resolvePreferredServerChatModelValue,
|
||||
} from "../chat-model-ref.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { AgentsListResult, ToolsCatalogResult, ToolsEffectiveResult } from "../types.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ChatModelOverride,
|
||||
ModelCatalogEntry,
|
||||
SessionsListResult,
|
||||
ToolsCatalogResult,
|
||||
ToolsEffectiveResult,
|
||||
} from "../types.ts";
|
||||
import { saveConfig } from "./config.ts";
|
||||
import type { ConfigState } from "./config.ts";
|
||||
import {
|
||||
@@ -23,6 +35,11 @@ export type AgentsState = {
|
||||
toolsEffectiveResultKey?: string | null;
|
||||
toolsEffectiveError: string | null;
|
||||
toolsEffectiveResult: ToolsEffectiveResult | null;
|
||||
sessionKey?: string;
|
||||
sessionsResult?: SessionsListResult | null;
|
||||
chatModelOverrides?: Record<string, ChatModelOverride | null>;
|
||||
chatModelCatalog?: ModelCatalogEntry[];
|
||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
};
|
||||
|
||||
export type AgentsConfigSaveState = AgentsState & ConfigState;
|
||||
@@ -107,7 +124,10 @@ export async function loadToolsEffective(
|
||||
) {
|
||||
const resolvedAgentId = params.agentId.trim();
|
||||
const resolvedSessionKey = params.sessionKey.trim();
|
||||
const requestKey = `${resolvedAgentId}:${resolvedSessionKey}`;
|
||||
const requestKey = buildToolsEffectiveRequestKey(state, {
|
||||
agentId: resolvedAgentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
});
|
||||
if (!state.client || !state.connected || !resolvedAgentId || !resolvedSessionKey) {
|
||||
return;
|
||||
}
|
||||
@@ -152,6 +172,70 @@ export async function loadToolsEffective(
|
||||
}
|
||||
}
|
||||
|
||||
export function resetToolsEffectiveState(state: AgentsState) {
|
||||
state.toolsEffectiveResult = null;
|
||||
state.toolsEffectiveResultKey = null;
|
||||
state.toolsEffectiveError = null;
|
||||
state.toolsEffectiveLoading = false;
|
||||
state.toolsEffectiveLoadingKey = null;
|
||||
}
|
||||
|
||||
export function buildToolsEffectiveRequestKey(
|
||||
state: Pick<AgentsState, "sessionsResult" | "chatModelOverrides" | "chatModelCatalog">,
|
||||
params: { agentId: string; sessionKey: string },
|
||||
): string {
|
||||
const resolvedAgentId = params.agentId.trim();
|
||||
const resolvedSessionKey = params.sessionKey.trim();
|
||||
const modelKey = resolveEffectiveToolsModelKey(state, resolvedSessionKey);
|
||||
return `${resolvedAgentId}:${resolvedSessionKey}:model=${modelKey || "(default)"}`;
|
||||
}
|
||||
|
||||
export function refreshVisibleToolsEffectiveForCurrentSession(
|
||||
state: AgentsState,
|
||||
): Promise<void> | undefined {
|
||||
const resolvedSessionKey = state.sessionKey?.trim();
|
||||
if (!resolvedSessionKey || state.agentsPanel !== "tools" || !state.agentsSelectedId) {
|
||||
return;
|
||||
}
|
||||
const sessionAgentId = resolveAgentIdFromSessionKey(resolvedSessionKey);
|
||||
if (!sessionAgentId || state.agentsSelectedId !== sessionAgentId) {
|
||||
return;
|
||||
}
|
||||
return loadToolsEffective(state, {
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEffectiveToolsModelKey(
|
||||
state: Pick<AgentsState, "sessionsResult" | "chatModelOverrides" | "chatModelCatalog">,
|
||||
sessionKey: string,
|
||||
): string {
|
||||
const resolvedSessionKey = sessionKey.trim();
|
||||
if (!resolvedSessionKey) {
|
||||
return "";
|
||||
}
|
||||
const catalog = state.chatModelCatalog ?? [];
|
||||
const cachedOverride = state.chatModelOverrides?.[resolvedSessionKey];
|
||||
const defaults = state.sessionsResult?.defaults;
|
||||
const defaultModel = resolvePreferredServerChatModelValue(
|
||||
defaults?.model,
|
||||
defaults?.modelProvider,
|
||||
catalog,
|
||||
);
|
||||
if (cachedOverride === null) {
|
||||
return defaultModel;
|
||||
}
|
||||
if (cachedOverride) {
|
||||
return resolveChatModelOverride(cachedOverride, catalog).value;
|
||||
}
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === resolvedSessionKey);
|
||||
if (activeRow?.model) {
|
||||
return resolvePreferredServerChatModelValue(activeRow.model, activeRow.modelProvider, catalog);
|
||||
}
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
export async function saveAgentsConfig(state: AgentsConfigSaveState) {
|
||||
const selectedBefore = state.agentsSelectedId;
|
||||
await saveConfig(state);
|
||||
|
||||
@@ -77,6 +77,13 @@ function createChatHeaderState(
|
||||
if (method === "models.list") {
|
||||
return { models: catalog };
|
||||
}
|
||||
if (method === "tools.effective") {
|
||||
return {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const state = {
|
||||
@@ -120,6 +127,13 @@ function createChatHeaderState(
|
||||
basePath: "",
|
||||
hello: null,
|
||||
agentsList: null,
|
||||
agentsPanel: "overview",
|
||||
agentsSelectedId: null,
|
||||
toolsEffectiveLoading: false,
|
||||
toolsEffectiveLoadingKey: null,
|
||||
toolsEffectiveResultKey: null,
|
||||
toolsEffectiveError: null,
|
||||
toolsEffectiveResult: null,
|
||||
applySettings(next: AppViewState["settings"]) {
|
||||
state.settings = next;
|
||||
},
|
||||
@@ -805,6 +819,42 @@ describe("chat view", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
state.agentsPanel = "tools";
|
||||
state.agentsSelectedId = "main";
|
||||
state.toolsEffectiveResultKey = "main:main";
|
||||
state.toolsEffectiveResult = {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("tools.effective", {
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("clears the session model override back to the default model", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
Reference in New Issue
Block a user