Download src/hooks/chat/record-model-switch-message.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 6.01 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/chat/record-model-switch-message.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/hooks/chat/record-model-switch-message.ts
-
curl -L -o record-model-switch-message.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/chat/record-model-switch-message.ts
6.01 kB
| import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor"; | |
| import { useModelStore, SeededSwitch } from "#/stores/model-store"; | |
| import { | |
| getStoredConversationMetadata, | |
| mergeStoredConversationMetadata, | |
| } from "#/api/conversation-metadata-store"; | |
| import { OpenHandsEvent } from "#/types/agent-server/core"; | |
| import { isSwitchLLMObservationEvent } from "#/types/agent-server/type-guards"; | |
| import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event"; | |
| export function recordModelSwitchMessage( | |
| conversationId: string, | |
| profileName: string, | |
| anchorEventId: string | null = getLastRenderableEventId(), | |
| ) { | |
| useModelStore | |
| .getState() | |
| .recordSwitch(conversationId, anchorEventId, profileName); | |
| } | |
| /** | |
| * The active-profile stamp for a conversation: the optimistic in-memory entry | |
| * (read first by the chat-input pill) plus the persisted per-conversation | |
| * `active_profile` metadata (survives reloads, round-tripped onto the | |
| * conversation by the agent-server adapter — issue #1082). Every path that | |
| * learns of a successful switch — the user `/model` mutation, the live | |
| * WebSocket handler, and the history seed below — must write through this one | |
| * function so the live and reload paths can't drift. | |
| * | |
| * `stampedAt` is persisted alongside the profile so the history seed can tell | |
| * a stale observation apart from a newer manual switch (see below). The live | |
| * WebSocket handler passes the observation's own (server) timestamp; the user | |
| * `/model` mutation has no event and defaults to the client clock. | |
| */ | |
| export function stampActiveLlmProfile( | |
| conversationId: string, | |
| profileName: string, | |
| stampedAt: string = new Date().toISOString(), | |
| ) { | |
| useModelStore.getState().setActiveProfile(conversationId, profileName); | |
| // Merge rather than replace: the stamp only owns these two fields, and the | |
| // record carries others it must not drop — the attached workspace mode | |
| // (#15520), the plugins snapshot behind the in-conversation plugins view, | |
| // and the local planner conversation id. Enumerating them here meant every | |
| // new field had to remember to opt in. | |
| mergeStoredConversationMetadata(conversationId, { | |
| active_profile: profileName, | |
| stamped_at: stampedAt, | |
| }); | |
| } | |
| /** | |
| * Rebuilds the inline "Switched to" messages for a conversation from its loaded | |
| * history. | |
| * | |
| * The live messages live in an in-memory store written only by the WebSocket | |
| * handler and the user `/model` action. Existing conversations load via REST | |
| * history, which bypasses that handler, so without this replay no past agent | |
| * switches would render after a reload (the SwitchLLMObservation events are | |
| * also hidden as cards by `shouldRenderEvent`). | |
| * | |
| * `uiEvents` MUST be the event store's `uiEvents` (the same list the renderer | |
| * and the live `getLastRenderableEventId()` use) — NOT the raw history. The | |
| * renderer anchors a message after an event only if that event's id is in | |
| * `uiEvents.filter(shouldRenderEvent)`, and `uiEvents` differs from raw history: | |
| * actions are replaced by their observations and `ThinkObservation` / | |
| * `FinishObservation` are dropped. Anchoring off raw history would point at ids | |
| * that never mount (e.g. a dropped `ThinkObservation`), orphaning the message. | |
| * | |
| * Each successful switch is anchored to the last renderable event before it, | |
| * matching where the live handler would have placed it. Idempotent: entries are | |
| * keyed by the observation event id, so re-seeding on every reload is a no-op. | |
| * | |
| * Also re-derives the active-profile stamp from the latest successful | |
| * SwitchLLM observation. The stamp is otherwise written only by the live | |
| * WebSocket handler and the user `/model` mutation, so a switch that fired | |
| * while the socket was down would never be stamped — after a reload the pill | |
| * showed the stale profile while the panel showed the server's true model. | |
| * | |
| * The re-stamp is gated on the stored `stamped_at`: manual `/model` switches | |
| * go through REST and leave NO observation in history, so an unconditional | |
| * re-stamp would roll a newer manual switch back to an older tool switch. | |
| * The seed therefore only stamps when there is no existing stamp, the stamp | |
| * predates this field (legacy — treated as older than any observation so it | |
| * still gets repaired), or the latest observation is strictly newer. | |
| * Observation timestamps are server-generated while `stamped_at` from the | |
| * mutation path is client-generated, so the cross-clock comparison is a | |
| * heuristic — acceptable here since the alternative is a silent rollback. | |
| */ | |
| export function seedModelSwitchesFromHistory( | |
| conversationId: string, | |
| uiEvents: OpenHandsEvent[], | |
| ) { | |
| const switches: SeededSwitch[] = []; | |
| let lastRenderableId: string | null = null; | |
| let latestSwitch: { profileName: string; timestamp: string } | null = null; | |
| for (const event of uiEvents) { | |
| if (isSwitchLLMObservationEvent(event) && !event.observation.is_error) { | |
| switches.push({ | |
| id: `history-switch:${event.id}`, | |
| anchorEventId: lastRenderableId, | |
| profileName: event.observation.profile_name, | |
| }); | |
| latestSwitch = { | |
| profileName: event.observation.profile_name, | |
| timestamp: event.timestamp, | |
| }; | |
| } | |
| if (shouldRenderEvent(event)) { | |
| lastRenderableId = String(event.id); | |
| } | |
| } | |
| if (switches.length > 0) { | |
| useModelStore.getState().seedSwitches(conversationId, switches); | |
| } | |
| if (latestSwitch) { | |
| const { profileName, timestamp } = latestSwitch; | |
| const stored = getStoredConversationMetadata(conversationId); | |
| const stampedAt = stored?.active_profile ? stored.stamped_at : null; | |
| if (!stampedAt || Date.parse(timestamp) > Date.parse(stampedAt)) { | |
| // Stamp with the observation's own timestamp so re-seeding the same | |
| // history on the next reload is a no-op (strictly-newer never matches). | |
| stampActiveLlmProfile(conversationId, profileName, timestamp); | |
| } | |
| } | |
| } | |