fix: refresh available tools when the session model changes (#54184)

This commit is contained in:
Tak Hoffman
2026-03-24 22:07:06 -05:00
committed by GitHub
parent 0c35ac4423
commit f3eb620824
8 changed files with 191 additions and 19 deletions

View File

@@ -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");
});
});

View File

@@ -318,6 +318,7 @@ async function dispatchSlashCommand(
...host.chatModelOverrides,
[targetSessionKey]: result.sessionPatch.modelOverride ?? null,
};
host.onSlashAction?.("refresh-tools-effective");
}
if (result.action === "refresh") {

View File

@@ -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.

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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",