From f3eb6208240d3fed76be8e049a841257fc3fec13 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:07:06 -0500 Subject: [PATCH] fix: refresh available tools when the session model changes (#54184) --- ui/src/ui/app-chat.test.ts | 3 + ui/src/ui/app-chat.ts | 1 + ui/src/ui/app-render.helpers.ts | 2 + ui/src/ui/app-render.ts | 36 +++++++----- ui/src/ui/app.ts | 9 ++- ui/src/ui/controllers/agents.test.ts | 21 ++++++- ui/src/ui/controllers/agents.ts | 88 +++++++++++++++++++++++++++- ui/src/ui/views/chat.test.ts | 50 ++++++++++++++++ 8 files changed, 191 insertions(+), 19 deletions(-) diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9356aa8f883..49aff34a6e7 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -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"); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 8f03bc822e8..465504f0c1d 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -318,6 +318,7 @@ async function dispatchSlashCommand( ...host.chatModelOverrides, [targetSessionKey]: result.sessionPatch.modelOverride ?? null, }; + host.onSlashAction?.("refresh-tools-effective"); } if (result.action === "refresh") { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 889b6972cde..c46d10903f7 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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. diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f4383e3e94d..486500f39b6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 3ea81a40097..b96167783bd 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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); diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts index 497609dc43e..bdece513b36 100644 --- a/ui/src/ui/controllers/agents.test.ts +++ b/ui/src/ui/controllers/agents.test.ts @@ -21,6 +21,25 @@ function createState(): { state: AgentsState; request: ReturnType 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); }); diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts index 363b0a5cc20..625ccaa47f6 100644 --- a/ui/src/ui/controllers/agents.ts +++ b/ui/src/ui/controllers/agents.ts @@ -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; + 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, + 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 | 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, + 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); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 513d0c0b384..59f7760d385 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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), + ); + 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( + '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",