Download src/hooks/chat/record-model-switch-message.test.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 10.4 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/chat/record-model-switch-message.test.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/hooks/chat/record-model-switch-message.test.ts
-
curl -L -o record-model-switch-message.test.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/chat/record-model-switch-message.test.ts
10.4 kB
| import { describe, it, expect, beforeEach } from "vitest"; | |
| import { useModelStore } from "#/stores/model-store"; | |
| import { | |
| getStoredConversationMetadata, | |
| setStoredConversationMetadata, | |
| } from "#/api/conversation-metadata-store"; | |
| import { OpenHandsEvent } from "#/types/agent-server/core"; | |
| import { seedModelSwitchesFromHistory } from "./record-model-switch-message"; | |
| const userMessage = (id: string): OpenHandsEvent => | |
| ({ | |
| id, | |
| timestamp: "2024-01-01T00:00:00Z", | |
| source: "user", | |
| llm_message: { role: "user", content: [{ type: "text", text: "hi" }] }, | |
| }) as unknown as OpenHandsEvent; | |
| const switchObservation = ( | |
| id: string, | |
| profileName: string, | |
| isError = false, | |
| timestamp = "2024-01-01T00:00:00Z", | |
| ): OpenHandsEvent => | |
| ({ | |
| id, | |
| timestamp, | |
| source: "environment", | |
| action_id: `action-${id}`, | |
| observation: { | |
| kind: "SwitchLLMObservation", | |
| content: [], | |
| is_error: isError, | |
| profile_name: profileName, | |
| reason: null, | |
| active_model: null, | |
| }, | |
| }) as unknown as OpenHandsEvent; | |
| // An agent action event. `ThinkAction` is renderable (shown as a thinking | |
| // block); `PlanningFileEditorAction` is hidden by `shouldRenderEvent`. | |
| const agentAction = (id: string, kind: string): OpenHandsEvent => | |
| ({ | |
| id, | |
| timestamp: "2024-01-01T00:00:00Z", | |
| source: "agent", | |
| action: { kind, thought: "t" }, | |
| tool_name: "tool", | |
| tool_call_id: `call-${id}`, | |
| }) as unknown as OpenHandsEvent; | |
| const entriesFor = (conversationId: string) => | |
| useModelStore.getState().entriesByConversation[conversationId] ?? []; | |
| const activeProfileFor = (conversationId: string) => | |
| useModelStore.getState().activeProfileByConversation[conversationId]; | |
| const stampedProfileFor = (conversationId: string) => | |
| getStoredConversationMetadata(conversationId)?.active_profile; | |
| describe("seedModelSwitchesFromHistory", () => { | |
| beforeEach(() => { | |
| useModelStore.getState().clearAll(); | |
| window.localStorage.clear(); | |
| }); | |
| it("seeds a successful switch anchored to the prior renderable event", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| const entries = entriesFor("c1"); | |
| expect(entries).toHaveLength(1); | |
| expect(entries[0].switchedTo).toBe("fast"); | |
| expect(entries[0].anchorEventId).toBe("u1"); | |
| expect(entries[0].id).toBe("history-switch:o1"); | |
| }); | |
| it("is idempotent across re-seeds (e.g. reloads)", () => { | |
| const events = [userMessage("u1"), switchObservation("o1", "fast")]; | |
| seedModelSwitchesFromHistory("c1", events); | |
| seedModelSwitchesFromHistory("c1", events); | |
| expect(entriesFor("c1")).toHaveLength(1); | |
| }); | |
| it("ignores failed switches (they still render as error cards)", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("e1", "fast", true), | |
| ]); | |
| expect(entriesFor("c1")).toHaveLength(0); | |
| }); | |
| it("never anchors to a non-rendered event (must land on a rendered id)", () => { | |
| // PlanningFileEditorAction is hidden by shouldRenderEvent, so the renderer | |
| // never mounts it; anchoring there would orphan the message. The anchor | |
| // must fall back to the prior rendered event (the user message). | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| agentAction("p1", "PlanningFileEditorAction"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| const entries = entriesFor("c1"); | |
| expect(entries).toHaveLength(1); | |
| expect(entries[0].anchorEventId).toBe("u1"); | |
| }); | |
| it("anchors to a renderable ThinkAction that precedes the switch", () => { | |
| // In uiEvents the ThinkObservation is dropped and the ThinkAction is kept | |
| // (and rendered as a thinking block), so it is a valid anchor. | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| agentAction("t1", "ThinkAction"), | |
| switchObservation("o1", "architect"), | |
| ]); | |
| const entries = entriesFor("c1"); | |
| expect(entries).toHaveLength(1); | |
| expect(entries[0].anchorEventId).toBe("t1"); | |
| }); | |
| it("anchors to null when no renderable event precedes the switch", () => { | |
| seedModelSwitchesFromHistory("c1", [switchObservation("o1", "architect")]); | |
| const entries = entriesFor("c1"); | |
| expect(entries).toHaveLength(1); | |
| expect(entries[0].anchorEventId).toBeNull(); | |
| }); | |
| it("preserves order and anchors for multiple switches", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| userMessage("u2"), | |
| switchObservation("o2", "architect"), | |
| ]); | |
| const entries = entriesFor("c1"); | |
| expect(entries).toHaveLength(2); | |
| expect(entries[0]).toMatchObject({ | |
| switchedTo: "fast", | |
| anchorEventId: "u1", | |
| }); | |
| expect(entries[1]).toMatchObject({ | |
| switchedTo: "architect", | |
| anchorEventId: "u2", | |
| }); | |
| }); | |
| it("re-stamps the active profile from a switch in the loaded history", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(activeProfileFor("c1")).toBe("fast"); | |
| expect(stampedProfileFor("c1")).toBe("fast"); | |
| }); | |
| it("stamps the latest switch when history holds several", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| userMessage("u2"), | |
| switchObservation("o2", "architect"), | |
| ]); | |
| expect(activeProfileFor("c1")).toBe("architect"); | |
| expect(stampedProfileFor("c1")).toBe("architect"); | |
| }); | |
| it("leaves the stamp untouched when history has no switch", () => { | |
| setStoredConversationMetadata("c1", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| selected_workspace: null, | |
| active_profile: "original", | |
| plugins: null, | |
| }); | |
| useModelStore.getState().setActiveProfile("c1", "original"); | |
| seedModelSwitchesFromHistory("c1", [userMessage("u1")]); | |
| expect(activeProfileFor("c1")).toBe("original"); | |
| expect(stampedProfileFor("c1")).toBe("original"); | |
| }); | |
| it("does not stamp from a failed switch", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("e1", "fast", true), | |
| ]); | |
| expect(activeProfileFor("c1")).toBeUndefined(); | |
| expect(stampedProfileFor("c1")).toBeUndefined(); | |
| }); | |
| it("keys the stamp per conversation", () => { | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(activeProfileFor("c2")).toBeUndefined(); | |
| expect(stampedProfileFor("c2")).toBeUndefined(); | |
| }); | |
| it("preserves the other stored metadata fields when re-stamping", () => { | |
| setStoredConversationMetadata("c1", { | |
| selected_repository: "org/repo", | |
| selected_branch: "main", | |
| git_provider: "github", | |
| selected_workspace: "/ws", | |
| active_profile: "stale", | |
| plugins: [{ source: "s", ref: "r", repo_path: null }], | |
| }); | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(getStoredConversationMetadata("c1")).toMatchObject({ | |
| selected_repository: "org/repo", | |
| selected_branch: "main", | |
| git_provider: "github", | |
| selected_workspace: "/ws", | |
| active_profile: "fast", | |
| plugins: [{ source: "s", ref: "r", repo_path: null }], | |
| }); | |
| }); | |
| it("preserves workspace_mode across a stamp (#15520)", () => { | |
| setStoredConversationMetadata("c1", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| selected_workspace: "/ws", | |
| workspace_mode: "local_repo", | |
| }); | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(getStoredConversationMetadata("c1")?.workspace_mode).toBe( | |
| "local_repo", | |
| ); | |
| }); | |
| it("leaves a newer manual stamp alone (manual switch after a tool switch survives reload)", () => { | |
| // t1: agent tool-switches to "fast" (observation in history). t2: user | |
| // manually switches back to "architect" via /model — a REST call that | |
| // leaves no observation. The reload seed must not roll the stamp back. | |
| setStoredConversationMetadata("c1", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| active_profile: "architect", | |
| stamped_at: "2024-06-01T00:00:00Z", | |
| }); | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(getStoredConversationMetadata("c1")?.active_profile).toBe( | |
| "architect", | |
| ); | |
| expect(getStoredConversationMetadata("c1")?.stamped_at).toBe( | |
| "2024-06-01T00:00:00Z", | |
| ); | |
| // The in-memory stamp takes priority over the persisted one in the pill, | |
| // so it must not be set to the stale profile either. | |
| expect(activeProfileFor("c1")).toBeUndefined(); | |
| }); | |
| it("repairs a legacy stamp written before stamped_at existed", () => { | |
| setStoredConversationMetadata("c1", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| active_profile: "architect", | |
| }); | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(stampedProfileFor("c1")).toBe("fast"); | |
| expect(getStoredConversationMetadata("c1")?.stamped_at).toBe( | |
| "2024-01-01T00:00:00Z", | |
| ); | |
| }); | |
| it("re-stamps when the latest observation is newer than the stored stamp", () => { | |
| setStoredConversationMetadata("c1", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| active_profile: "architect", | |
| stamped_at: "2023-01-01T00:00:00Z", | |
| }); | |
| seedModelSwitchesFromHistory("c1", [ | |
| userMessage("u1"), | |
| switchObservation("o1", "fast"), | |
| ]); | |
| expect(stampedProfileFor("c1")).toBe("fast"); | |
| expect(getStoredConversationMetadata("c1")?.stamped_at).toBe( | |
| "2024-01-01T00:00:00Z", | |
| ); | |
| }); | |
| it("stamps with the observation timestamp so re-seeding the same history is a no-op", () => { | |
| const events = [userMessage("u1"), switchObservation("o1", "fast")]; | |
| seedModelSwitchesFromHistory("c1", events); | |
| seedModelSwitchesFromHistory("c1", events); | |
| expect(getStoredConversationMetadata("c1")?.stamped_at).toBe( | |
| "2024-01-01T00:00:00Z", | |
| ); | |
| }); | |
| }); | |