From d7e2fdaf51de02147a06ac4f5b7ab62284456352 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 15 May 2026 00:49:16 -0400 Subject: [PATCH 1/4] Use SDK agent settings for local conversations --- __tests__/api/agent-server-adapter.test.ts | 32 ++--- src/api/agent-server-adapter.ts | 159 ++++++++++++--------- 2 files changed, 110 insertions(+), 81 deletions(-) diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index e61cf5b87..844dd08bd 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -54,7 +54,7 @@ beforeEach(() => { }); describe("buildStartConversationRequest", () => { - it("uses nested settings as the source of truth and keeps SDK tool names", () => { + it("uses nested settings as the source of truth and lets the SDK create the agent", () => { const payload = buildStartConversationRequest({ settings: { ...DEFAULT_SETTINGS, @@ -71,6 +71,7 @@ describe("buildStartConversationRequest", () => { enabled: true, max_size: 120, }, + enable_switch_llm_tool: true, }, conversation_settings: { ...DEFAULT_SETTINGS.conversation_settings, @@ -79,41 +80,39 @@ describe("buildStartConversationRequest", () => { }, query: "hello", }) as { - agent: Record & { + agent?: unknown; + agent_settings: Record & { llm: Record; tools: Array<{ name: string; params: Record }>; + agent_context: Record; }; workspace: { working_dir: string }; initial_message: { content: Array<{ text: string }> }; max_iterations: number; }; - expect(payload.agent.llm).toMatchObject({ + expect(payload.agent).toBeUndefined(); + expect(payload.agent_settings.llm).toMatchObject({ model: "nested-model", api_key: "nested-key", base_url: "https://nested.example.com", }); - expect(payload.agent.condenser).toEqual({ - kind: "LLMSummarizingCondenser", - llm: { - model: "nested-model", - api_key: "nested-key", - base_url: "https://nested.example.com", - usage_id: "condenser", - }, + expect(payload.agent_settings.condenser).toEqual({ + enabled: true, max_size: 120, }); - expect(payload.agent.tools).toEqual([ + expect(payload.agent_settings.tools).toEqual([ { name: "terminal", params: {} }, { name: "file_editor", params: {} }, { name: "task_tracker", params: {} }, { name: "browser_tool_set", params: {} }, ]); - expect(payload.agent.agent_context).toEqual({ + expect(payload.agent_settings.agent_context).toEqual({ load_public_skills: true, load_user_skills: true, }); - expect(payload.agent.agent).toBeUndefined(); + expect(payload.agent_settings.agent).toBe("CodeActAgent"); + expect(payload.agent_settings.enable_switch_llm_tool).toBe(true); expect(payload.workspace.working_dir).toBe( "/workspace/project/agent-canvas", ); @@ -121,7 +120,6 @@ describe("buildStartConversationRequest", () => { expect(payload.initial_message.content[0]?.text).toBe("hello"); }); - it("omits browser_tool_set when the server does not advertise browser support", () => { mockIsAgentServerToolAvailable.mockReturnValue(false); @@ -134,12 +132,12 @@ describe("buildStartConversationRequest", () => { }, }, }) as { - agent: { + agent_settings: { tools: Array<{ name: string; params: Record }>; }; }; - expect(payload.agent.tools).toEqual([ + expect(payload.agent_settings.tools).toEqual([ { name: "terminal", params: {} }, { name: "file_editor", params: {} }, { name: "task_tracker", params: {} }, diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index c596db088..37ce5d064 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -134,11 +134,31 @@ export function toConversationPage(data: { type SettingsRecord = Record; -const AGENT_SETTINGS_METADATA_KEYS = new Set([ - "schema_version", - "agent_kind", - "agent", -]); +interface AgentToolSpec { + name: string; + params: SettingsRecord; +} + +type AgentSettingsPayload = SettingsRecord & { + llm: SettingsRecord; + agent_context: SettingsRecord; + tools: AgentToolSpec[]; +}; + +interface LocalWorkspacePayload { + kind: "LocalWorkspace"; + working_dir: string; +} + +interface InitialMessagePayload { + role: "user"; + content: Array<{ type: "text"; text: string }>; +} + +type ConversationSettingsPayload = SettingsRecord & { + workspace: LocalWorkspacePayload; + initial_message?: InitialMessagePayload; +}; const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([ "schema_version", @@ -193,21 +213,59 @@ function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) { } } -function getAgentTools() { - const tools = DEFAULT_TOOL_NAMES.map((name) => ({ name, params: {} })); +function isToolRecord( + value: unknown, +): value is { name: string; params?: unknown } { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as { name?: unknown }).name === "string" + ); +} + +function shouldIncludeTool(name: string) { + return ( + name !== BROWSER_TOOL_SET_NAME || + (browserToolsEnabled() && isAgentServerToolAvailable(BROWSER_TOOL_SET_NAME)) + ); +} + +function getAgentTools(configuredTools: unknown): AgentToolSpec[] { + const tools = new Map(); + + for (const name of DEFAULT_TOOL_NAMES) { + tools.set(name, { name, params: {} }); + } + + if (shouldIncludeTool(BROWSER_TOOL_SET_NAME)) { + tools.set(BROWSER_TOOL_SET_NAME, { + name: BROWSER_TOOL_SET_NAME, + params: {}, + }); + } + if ( - browserToolsEnabled() && - isAgentServerToolAvailable(BROWSER_TOOL_SET_NAME) + Array.isArray(configuredTools) && + configuredTools.every((tool) => isToolRecord(tool)) ) { - tools.push({ name: BROWSER_TOOL_SET_NAME, params: {} }); + for (const tool of configuredTools) { + if (shouldIncludeTool(tool.name)) { + tools.set(tool.name, { + name: tool.name, + params: toRecord(tool.params), + }); + } + } } - return tools; + + return Array.from(tools.values()); } function buildInitialMessage( query?: string, conversationInstructions?: string, -) { +): InitialMessagePayload | null { const parts = [query?.trim(), conversationInstructions?.trim()].filter( Boolean, ); @@ -221,34 +279,7 @@ function buildInitialMessage( }; } -function buildCondenserConfig( - llm: SettingsRecord, - rawCondenser: unknown, -): SettingsRecord | undefined { - const condenser = toRecord(rawCondenser); - - if (condenser.enabled !== true) { - return undefined; - } - - const condenserLlm = { - ...llm, - usage_id: "condenser", - }; - - const config: SettingsRecord = { - kind: "LLMSummarizingCondenser", - llm: condenserLlm, - }; - - if (typeof condenser.max_size === "number") { - config.max_size = condenser.max_size; - } - - return config; -} - -function buildConfiguredAgentSettings(settings: Settings): SettingsRecord { +function buildConfiguredAgentSettings(settings: Settings): AgentSettingsPayload { const agentSettings = toRecord(settings.agent_settings); const llm = toRecord(agentSettings.llm); @@ -269,36 +300,20 @@ function buildConfiguredAgentSettings(settings: Settings): SettingsRecord { delete llm.base_url; } - const condenser = buildCondenserConfig(llm, agentSettings.condenser); - - AGENT_SETTINGS_METADATA_KEYS.forEach((key) => delete agentSettings[key]); - const mcpConfig = toRecord(agentSettings.mcp_config); if (Object.keys(mcpConfig).length === 0 || !("mcpServers" in mcpConfig)) { delete agentSettings.mcp_config; } - if (condenser) { - agentSettings.condenser = condenser; - } else { - delete agentSettings.condenser; - } - return { ...agentSettings, llm, - tools: getAgentTools(), - }; -} - -function createAgentFromSettings(agentSettings: SettingsRecord) { - return { - kind: "Agent", - ...agentSettings, agent_context: { + ...toRecord(agentSettings.agent_context), load_public_skills: shouldLoadPublicSkills(), load_user_skills: true, }, + tools: getAgentTools(agentSettings.tools), }; } @@ -308,7 +323,7 @@ function buildConfiguredConversationSettings(options: { conversationInstructions?: string; plugins?: PluginSpec[]; workingDir?: string; -}): SettingsRecord { +}): ConversationSettingsPayload { const { settings, query, conversationInstructions, plugins, workingDir } = options; const conversationSettings = toRecord(settings.conversation_settings); @@ -318,7 +333,7 @@ function buildConfiguredConversationSettings(options: { (key) => delete conversationSettings[key], ); - return { + const payload: ConversationSettingsPayload = { ...conversationSettings, workspace: { kind: "LocalWorkspace", @@ -335,6 +350,8 @@ function buildConfiguredConversationSettings(options: { } : {}), }; + + return payload; } /** @@ -349,6 +366,21 @@ interface LookupSecret { description?: string; } +type StartConversationPayload = Record & { + agent_settings: AgentSettingsPayload; + workspace: LocalWorkspacePayload; + confirmation_policy: SettingsRecord; + security_analyzer?: SettingsRecord; + initial_message?: InitialMessagePayload; + max_iterations: number; + stuck_detection: true; + autotitle: true; + worktree: true; + secrets_encrypted?: true; + conversation_id?: string; + secrets?: Record; +}; + export interface StartConversationOptions { settings: Settings; query?: string; @@ -381,14 +413,13 @@ export interface StartConversationOptions { export function buildStartConversationRequest( options: StartConversationOptions, -) { +): StartConversationPayload { // Use encrypted settings if provided, otherwise fall back to regular settings const sourceAgentSettings = options.encryptedAgentSettings ? { ...options.settings, agent_settings: options.encryptedAgentSettings } : options.settings; const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings); - const agent = createAgentFromSettings(agentSettings); // For conversation settings, merge encrypted settings if provided const sourceConversationOptions = options.encryptedConversationSettings @@ -405,8 +436,8 @@ export function buildStartConversationRequest( sourceConversationOptions, ); - const payload: Record = { - agent, + const payload: StartConversationPayload = { + agent_settings: agentSettings, workspace: conversationSettings.workspace, confirmation_policy: getConversationConfirmationPolicy(conversationSettings), From 6c5f9e5a860e04cff8295480be08b390a07a63ef Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 15 May 2026 05:24:22 +0000 Subject: [PATCH 2/4] chore: address PR review feedback (#457) Co-authored-by: openhands --- src/api/agent-server-adapter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index 37ce5d064..5fdc3104c 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -232,6 +232,8 @@ function shouldIncludeTool(name: string) { } function getAgentTools(configuredTools: unknown): AgentToolSpec[] { + // Defaults are always present; schema-provided tools may override them by + // name or add new tools, but they cannot remove local runtime defaults. const tools = new Map(); for (const name of DEFAULT_TOOL_NAMES) { @@ -279,7 +281,9 @@ function buildInitialMessage( }; } -function buildConfiguredAgentSettings(settings: Settings): AgentSettingsPayload { +function buildConfiguredAgentSettings( + settings: Settings, +): AgentSettingsPayload { const agentSettings = toRecord(settings.agent_settings); const llm = toRecord(agentSettings.llm); @@ -305,6 +309,8 @@ function buildConfiguredAgentSettings(settings: Settings): AgentSettingsPayload delete agentSettings.mcp_config; } + // Forward condenser settings verbatim so the server SDK's create_agent() + // path owns condenser construction and stays aligned with SDK defaults. return { ...agentSettings, llm, From de7800340a38d1bb0bc63a8199618e9175fde74a Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 15 May 2026 01:31:25 -0400 Subject: [PATCH 3/4] Show switch LLM tool results in model UI --- .../should-render-event.test.ts | 70 +++++++++++++++++++ .../get-event-content.tsx | 8 +++ .../get-observation-content.ts | 33 +++++++++ .../get-observation-result.ts | 3 + .../should-render-event.ts | 16 +++++ .../conversation-websocket-context.tsx | 34 +++++++++ src/hooks/chat/record-model-switch-message.ts | 12 ++++ .../conversation-mutation-utils.test.ts | 58 ++++++++++++++- .../mutation/conversation-mutation-utils.ts | 32 +++++++++ .../use-switch-llm-profile-and-log.ts | 7 +- src/hooks/mutation/use-switch-llm-profile.ts | 5 +- src/types/agent-server/core/base/action.ts | 14 +++- src/types/agent-server/core/base/base.ts | 3 +- .../agent-server/core/base/observation.ts | 26 ++++++- src/types/agent-server/type-guards.ts | 10 +++ 15 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 src/hooks/chat/record-model-switch-message.ts diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts index 72091f6eb..c2587ae15 100644 --- a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts +++ b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts @@ -7,6 +7,13 @@ import { createUserMessageEvent, } from "test-utils"; import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; +import { + ActionEvent, + ObservationEvent, + SecurityRisk, +} from "#/types/agent-server/core"; +import { SwitchLLMAction } from "#/types/agent-server/core/base/action"; +import { SwitchLLMObservation } from "#/types/agent-server/core/base/observation"; const makeACPEvent = ( overrides: Partial = {}, @@ -78,3 +85,66 @@ describe("shouldRenderEvent - ACPToolCallEvent", () => { expect(shouldRenderEvent(event)).toBe(true); }); }); + +describe("shouldRenderEvent - SwitchLLM", () => { + const switchAction: ActionEvent = { + id: "switch-action", + timestamp: "2024-01-01T00:00:00Z", + source: "agent", + thought: [], + thinking_blocks: [], + action: { + kind: "SwitchLLMAction", + profile_name: "haiku", + reason: "Use a cheaper model.", + }, + tool_name: "switch_llm", + tool_call_id: "tool-switch", + tool_call: { + id: "tool-switch", + type: "function", + function: { + name: "switch_llm", + arguments: JSON.stringify({ profile_name: "haiku" }), + }, + }, + llm_response_id: "response-switch", + security_risk: SecurityRisk.LOW, + }; + + const makeSwitchObservation = ( + overrides: Partial = {}, + ): ObservationEvent => ({ + id: "switch-observation", + timestamp: "2024-01-01T00:00:01Z", + source: "environment", + tool_name: "switch_llm", + tool_call_id: "tool-switch", + action_id: "switch-action", + observation: { + kind: "SwitchLLMObservation", + content: [{ type: "text", text: "Switched." }], + is_error: false, + profile_name: "haiku", + reason: "Use a cheaper model.", + active_model: "anthropic/claude-haiku-4-5", + ...overrides, + }, + }); + + it("hides switch actions and successful observations for the shared model UI", () => { + expect(shouldRenderEvent(switchAction)).toBe(false); + expect(shouldRenderEvent(makeSwitchObservation())).toBe(false); + }); + + it("keeps failed switch observations visible", () => { + expect( + shouldRenderEvent( + makeSwitchObservation({ + is_error: true, + content: [{ type: "text", text: "Profile was not found." }], + }), + ), + ).toBe(true); + }); +}); diff --git a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx index fd476decc..829506b1c 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx +++ b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx @@ -225,6 +225,14 @@ const getObservationEventTitle = ( name: event.observation.skill_name, }; break; + case "SwitchLLMObservation": + observationKey = event.observation.is_error + ? "MODEL$SWITCH_FAILED" + : "MODEL$SWITCHED_TO_PROFILE"; + observationValues = { + name: event.observation.profile_name, + }; + break; case "BrowserObservation": observationKey = "OBSERVATION_MESSAGE$BROWSE"; break; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts index 0658aa411..74c1162ae 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts +++ b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts @@ -15,6 +15,7 @@ import { GlobObservation, GrepObservation, InvokeSkillObservation, + SwitchLLMObservation, } from "#/types/agent-server/core/base/observation"; // File Editor Observations @@ -171,6 +172,33 @@ const getInvokeSkillObservationContent = ( return content; }; +const getSwitchLLMObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + if (observation.is_error) { + return textContent + ? `**Error:**\n${textContent}` + : `**Error:**\nFailed to switch LLM profile \`${observation.profile_name}\`.`; + } + + const parts = [`**Profile:** \`${observation.profile_name}\``]; + if (observation.active_model) { + parts.push(`**Active model:** \`${observation.active_model}\``); + } + if (observation.reason) { + parts.push(`**Reason:** ${observation.reason}`); + } + + return parts.join("\n"); +}; + // Complex Observations const getTaskTrackerObservationContent = ( event: ObservationEvent, @@ -373,6 +401,11 @@ export const getObservationContent = (event: ObservationEvent): string => { event as ObservationEvent, ); + case "SwitchLLMObservation": + return getSwitchLLMObservationContent( + event as ObservationEvent, + ); + default: return getDefaultEventContent(event); } diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts index 41f229d68..08eb27361 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts +++ b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts @@ -51,6 +51,9 @@ export const getObservationResult = ( case "MCPToolObservation": if (observation.is_error) return "error"; return "success"; + case "SwitchLLMObservation": + if (observation.is_error) return "error"; + return "success"; default: return "success"; } diff --git a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts index f9ad86591..3b46bc902 100644 --- a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts +++ b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts @@ -34,11 +34,27 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => { return false; } + // The model switch tool reuses the same inline model message UI as + // `/model ` once the observation arrives. + if (actionType === "SwitchLLMAction") { + return false; + } + return true; } // Render observation events if (isObservationEvent(event)) { + // Successful model switches are rendered through ModelMessages so they + // look identical to `/model ` confirmations. Failed switches + // still render as observations so the error remains visible in chat. + if ( + event.observation.kind === "SwitchLLMObservation" && + !event.observation.is_error + ) { + return false; + } + return true; } diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx index 0bac2745e..b417b2cba 100644 --- a/src/contexts/conversation-websocket-context.tsx +++ b/src/contexts/conversation-websocket-context.tsx @@ -33,6 +33,7 @@ import { isPlanningFileEditorObservationEvent, isBrowserObservationEvent, isBrowserNavigateActionEvent, + isSwitchLLMObservationEvent, } from "#/types/agent-server/type-guards"; import { ConversationStateUpdateEventStats } from "#/types/agent-server/core/events/conversation-state-event"; import type { @@ -53,6 +54,11 @@ import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation- import useMetricsStore from "#/stores/metrics-store"; import { useConversationHistory } from "#/hooks/query/use-conversation-history"; import { setConversationState } from "#/utils/conversation-local-storage"; +import { recordModelSwitchMessage } from "#/hooks/chat/record-model-switch-message"; +import { + invalidateConversationQueries, + updateConversationLlmModelInCache, +} from "#/hooks/mutation/conversation-mutation-utils"; export type WebSocketConnectionState = | "CONNECTING" @@ -386,6 +392,13 @@ export function ConversationWebSocketProvider({ // Use type guard to validate v1 event structure if (isAgentServerEvent(event)) { + const isDuplicateEvent = useEventStore + .getState() + .eventIds.has(event.id); + const switchLLMObservation = + !isDuplicateEvent && isSwitchLLMObservationEvent(event) + ? event + : null; addEvent(event); // Handle displayable error events - show error banner @@ -494,6 +507,27 @@ export function ConversationWebSocketProvider({ if (isBrowserNavigateActionEvent(event)) { useBrowserStore.getState().setUrl(event.action.url); } + + if ( + conversationId && + switchLLMObservation && + !switchLLMObservation.observation.is_error + ) { + recordModelSwitchMessage( + conversationId, + switchLLMObservation.observation.profile_name, + ); + + if (switchLLMObservation.observation.active_model) { + updateConversationLlmModelInCache( + queryClient, + conversationId, + switchLLMObservation.observation.active_model, + ); + } + + invalidateConversationQueries(queryClient, conversationId); + } } } catch (error) { console.warn("Failed to parse WebSocket message as JSON:", error); diff --git a/src/hooks/chat/record-model-switch-message.ts b/src/hooks/chat/record-model-switch-message.ts new file mode 100644 index 000000000..bd7e0a200 --- /dev/null +++ b/src/hooks/chat/record-model-switch-message.ts @@ -0,0 +1,12 @@ +import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor"; +import { useModelStore } from "#/stores/model-store"; + +export function recordModelSwitchMessage( + conversationId: string, + profileName: string, + anchorEventId: string | null = getLastRenderableEventId(), +) { + useModelStore + .getState() + .recordSwitch(conversationId, anchorEventId, profileName); +} diff --git a/src/hooks/mutation/conversation-mutation-utils.test.ts b/src/hooks/mutation/conversation-mutation-utils.test.ts index 894842acf..bf34c1b0a 100644 --- a/src/hooks/mutation/conversation-mutation-utils.test.ts +++ b/src/hooks/mutation/conversation-mutation-utils.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { QueryClient } from "@tanstack/react-query"; -import { updateConversationExecutionStatusInCache } from "./conversation-mutation-utils"; +import { + updateConversationExecutionStatusInCache, + updateConversationLlmModelInCache, +} from "./conversation-mutation-utils"; import { ExecutionStatus } from "#/types/agent-server/core/base/common"; import { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; @@ -51,3 +54,56 @@ describe("updateConversationExecutionStatusInCache", () => { }); }); }); + +describe("updateConversationLlmModelInCache", () => { + it("updates active conversation and list cache entries", () => { + const queryClient = new QueryClient(); + const conversation = createConversation(); + const otherConversation = { ...createConversation(), id: "conversation-2" }; + + queryClient.setQueryData( + ["user", "conversation", conversation.id, "backend-1", null], + conversation, + ); + queryClient.setQueryData(["user", "conversations"], { + pages: [ + { + items: [conversation, otherConversation], + }, + ], + }); + + updateConversationLlmModelInCache( + queryClient, + conversation.id, + "anthropic/claude-haiku-4-5", + ); + + expect( + queryClient.getQueryData([ + "user", + "conversation", + conversation.id, + "backend-1", + null, + ]), + ).toMatchObject({ + llm_model: "anthropic/claude-haiku-4-5", + }); + + expect( + queryClient.getQueryData<{ + pages: Array<{ items: AppConversation[] }>; + }>(["user", "conversations"])?.pages[0].items, + ).toEqual([ + expect.objectContaining({ + id: conversation.id, + llm_model: "anthropic/claude-haiku-4-5", + }), + expect.objectContaining({ + id: otherConversation.id, + llm_model: null, + }), + ]); + }); +}); diff --git a/src/hooks/mutation/conversation-mutation-utils.ts b/src/hooks/mutation/conversation-mutation-utils.ts index 5a28e12f3..aeab0564b 100644 --- a/src/hooks/mutation/conversation-mutation-utils.ts +++ b/src/hooks/mutation/conversation-mutation-utils.ts @@ -105,6 +105,38 @@ export const updateConversationExecutionStatusInCache = ( }); }; +export const updateConversationLlmModelInCache = ( + queryClient: QueryClient, + conversationId: string, + llm_model: string, +): void => { + queryClient.setQueriesData( + { queryKey: ["user", "conversation", conversationId] }, + (oldData) => { + if (!oldData) return oldData; + return { ...oldData, llm_model }; + }, + ); + + queryClient.setQueriesData<{ + pages: Array<{ + items: Array<{ id: string; llm_model: string | null }>; + }>; + }>({ queryKey: ["user", "conversations"] }, (oldData) => { + if (!oldData) return oldData; + + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + items: page.items.map((conv) => + conv.id === conversationId ? { ...conv, llm_model } : conv, + ), + })), + }; + }); +}; + export const invalidateConversationQueries = ( queryClient: QueryClient, conversationId: string, diff --git a/src/hooks/mutation/use-switch-llm-profile-and-log.ts b/src/hooks/mutation/use-switch-llm-profile-and-log.ts index 502899bb9..f6d01e868 100644 --- a/src/hooks/mutation/use-switch-llm-profile-and-log.ts +++ b/src/hooks/mutation/use-switch-llm-profile-and-log.ts @@ -1,8 +1,8 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor"; +import { recordModelSwitchMessage } from "#/hooks/chat/record-model-switch-message"; import { useSwitchLlmProfile } from "#/hooks/mutation/use-switch-llm-profile"; -import { useModelStore } from "#/stores/model-store"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; @@ -14,7 +14,6 @@ import { I18nKey } from "#/i18n/declaration"; */ export function useSwitchLlmProfileAndLog() { const { mutate } = useSwitchLlmProfile(); - const recordSwitch = useModelStore((s) => s.recordSwitch); const { t } = useTranslation(); return useCallback( @@ -25,7 +24,7 @@ export function useSwitchLlmProfileAndLog() { { conversationId, profileName }, { onSuccess: () => - recordSwitch(conversationId, anchorEventId, profileName), + recordModelSwitchMessage(conversationId, profileName, anchorEventId), onError: (err: unknown) => { const fallback = t(I18nKey.MODEL$SWITCH_FAILED, { name: profileName, @@ -37,6 +36,6 @@ export function useSwitchLlmProfileAndLog() { }, ); }, - [mutate, recordSwitch, t], + [mutate, t], ); } diff --git a/src/hooks/mutation/use-switch-llm-profile.ts b/src/hooks/mutation/use-switch-llm-profile.ts index cf422be5a..6fd2e1c0a 100644 --- a/src/hooks/mutation/use-switch-llm-profile.ts +++ b/src/hooks/mutation/use-switch-llm-profile.ts @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import { invalidateConversationQueries } from "./conversation-mutation-utils"; interface SwitchLlmProfileVars { conversationId: string; @@ -18,9 +19,7 @@ export const useSwitchLlmProfile = () => { mutationFn: ({ conversationId, profileName }: SwitchLlmProfileVars) => AgentServerConversationService.switchProfile(conversationId, profileName), onSuccess: (_data, { conversationId }) => { - queryClient.invalidateQueries({ - queryKey: ["user", "conversation", conversationId], - }); + invalidateConversationQueries(queryClient, conversationId); }, // Caller renders an inline message + handles error toast manually. meta: { disableToast: true }, diff --git a/src/types/agent-server/core/base/action.ts b/src/types/agent-server/core/base/action.ts index 3b41b100f..03a9a01b2 100644 --- a/src/types/agent-server/core/base/action.ts +++ b/src/types/agent-server/core/base/action.ts @@ -277,6 +277,17 @@ export interface InvokeSkillAction extends ActionBase<"InvokeSkillAction"> { name: string; } +export interface SwitchLLMAction extends ActionBase<"SwitchLLMAction"> { + /** + * Name of the saved LLM profile to use for future agent steps. + */ + profile_name: string; + /** + * Brief reason why this profile is a better fit for the next step. + */ + reason: string; +} + export type Action = | MCPToolAction | FinishAction @@ -299,4 +310,5 @@ export type Action = | BrowserCloseTabAction | GlobAction | GrepAction - | InvokeSkillAction; + | InvokeSkillAction + | SwitchLLMAction; diff --git a/src/types/agent-server/core/base/base.ts b/src/types/agent-server/core/base/base.ts index ea24c5bbb..d51194519 100644 --- a/src/types/agent-server/core/base/base.ts +++ b/src/types/agent-server/core/base/base.ts @@ -8,7 +8,8 @@ type EventType = | "StrReplaceEditor" | "TaskTracker" | "PlanningFileEditor" - | "InvokeSkill"; + | "InvokeSkill" + | "SwitchLLM"; type ActionOnlyType = | "BrowserNavigate" diff --git a/src/types/agent-server/core/base/observation.ts b/src/types/agent-server/core/base/observation.ts index e41f541ac..38461811a 100644 --- a/src/types/agent-server/core/base/observation.ts +++ b/src/types/agent-server/core/base/observation.ts @@ -290,6 +290,29 @@ export interface InvokeSkillObservation extends ObservationBase<"InvokeSkillObse is_error?: boolean; } +export interface SwitchLLMObservation extends ObservationBase<"SwitchLLMObservation"> { + /** + * Content returned from the switch LLM tool. + */ + content: Array; + /** + * Whether the profile switch resulted in an error. + */ + is_error: boolean; + /** + * Name of the profile the agent attempted to activate. + */ + profile_name: string; + /** + * Reason the agent gave for the switch. + */ + reason: string | null; + /** + * Model configured by the activated profile, when available. + */ + active_model: string | null; +} + export type Observation = | MCPToolObservation | FinishObservation @@ -303,4 +326,5 @@ export type Observation = | PlanningFileEditorObservation | GlobObservation | GrepObservation - | InvokeSkillObservation; + | InvokeSkillObservation + | SwitchLLMObservation; diff --git a/src/types/agent-server/type-guards.ts b/src/types/agent-server/type-guards.ts index 5a9cbdcfc..e179f60a8 100644 --- a/src/types/agent-server/type-guards.ts +++ b/src/types/agent-server/type-guards.ts @@ -9,6 +9,7 @@ import { TerminalObservation, BrowserObservation, BrowserNavigateAction, + SwitchLLMObservation, } from "./core"; import { AgentErrorEvent } from "./core/events/observation-event"; import { MessageEvent } from "./core/events/message-event"; @@ -145,6 +146,15 @@ export const isBrowserObservationEvent = ( ): event is ObservationEvent => isObservationEvent(event) && event.observation.kind === "BrowserObservation"; +/** + * Type guard function to check if an observation event is a SwitchLLMObservation + */ +export const isSwitchLLMObservationEvent = ( + event: OpenHandsEvent, +): event is ObservationEvent => + isObservationEvent(event) && + event.observation.kind === "SwitchLLMObservation"; + /** * Type guard function to check if an action event is a BrowserNavigateAction */ From c0203ccf00ca1b380d268be6b6674c36b55fa21a Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 15 May 2026 15:36:24 +0000 Subject: [PATCH 4/4] chore: fix model switch formatting Co-authored-by: openhands --- src/hooks/mutation/use-switch-llm-profile-and-log.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hooks/mutation/use-switch-llm-profile-and-log.ts b/src/hooks/mutation/use-switch-llm-profile-and-log.ts index f6d01e868..e67425630 100644 --- a/src/hooks/mutation/use-switch-llm-profile-and-log.ts +++ b/src/hooks/mutation/use-switch-llm-profile-and-log.ts @@ -24,7 +24,11 @@ export function useSwitchLlmProfileAndLog() { { conversationId, profileName }, { onSuccess: () => - recordModelSwitchMessage(conversationId, profileName, anchorEventId), + recordModelSwitchMessage( + conversationId, + profileName, + anchorEventId, + ), onError: (err: unknown) => { const fallback = t(I18nKey.MODEL$SWITCH_FAILED, { name: profileName,