diff options
| author | Adam Malczewski <[email protected]> | 2026-08-20 19:36:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-08-20 19:36:12 +0900 |
| commit | fb7eb3105e1fbbdefdad9348f9132815b5bbe52d (patch) | |
| tree | deb95e97a917424ea67470b168b91349a41d1c08 /src/app | |
| parent | 63588c3c69b32d180dc78a2cc187cc8aa22ad7e0 (diff) | |
| download | dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.tar.gz dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.zip | |
feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics
Implement the frontend of the Named Agents feature (plan §5-§9):
- New features/named-agents module: pure logic (types re-exported from
@dispatch/wire + transport-contract, validation, provider/model options,
snapshot helpers, network normalizers, auto-save status model) + an injected
auto-save controller (name debounce, immediate provider/model save, monotonic
stale-response guard) + the collapsible sidebar card view.
- Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists
the raw model WITH the namedAgent snapshot; draft promotion carries it;
refreshModel restores ModelResponse.namedAgent.
- Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot +
setSnapshot (retained across rename/remove, persisted with tabs).
- Model selector: synthetic Named Agents provider ALWAYS first; selecting one
resolves to the raw model + records a snapshot; a retained snapshot of a
renamed/removed agent still displays as unavailable (plan §7).
- Composition root wires the view (viewKinds + loaded-modules manifest) + feeds
the selector.
Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0,
biome clean (only pre-existing config infos pending a future migrate), build OK.
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 35 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 213 | ||||
| -rw-r--r-- | src/app/store.test.ts | 174 |
3 files changed, 417 insertions, 5 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index e50648a..9436648 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -45,6 +45,12 @@ import { manifest as tabsManifest, TabList } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; import { + manifest as namedAgentsManifest, + NamedAgentsView, + type NamedAgentInput, + type NamedAgentSelectionSnapshot, + } from "../features/named-agents"; + import { CwdField, type CwdSaveResult, LspStatusView, @@ -119,6 +125,7 @@ const viewKinds = [ { id: "tabs", label: "Tabs" }, { id: "model", label: "Model" }, + { id: "named-agents", label: "Named Agents" }, { id: "lsp", label: "Language Servers" }, { id: "mcp", label: "MCP Servers" }, { id: "extensions", label: "Extensions" }, @@ -167,6 +174,7 @@ heartbeatManifest, concurrencyManifest, visionManifest, + namedAgentsManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -310,6 +318,21 @@ store.selectModel(model); } + // A Named Agent selected in the model selector: resolve it to its raw model + // AND record the snapshot on the active conversation (plan §6/§7). The store + // persists the raw model to `/conversations/:id/model` + retains the snapshot + // on the tab. + function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot) { + store.selectModel(snapshot.model, snapshot); + } + + // Adapt the store's Named Agent CRUD to the named-agents feature's ports. + const namedAgentPorts = { + create: (input: NamedAgentInput) => store.createNamedAgent(input), + update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input), + remove: (id: string) => store.deleteNamedAgent(id), + }; + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. async function warmNow(): Promise<WarmFeedback | null> { const result = await store.warmNow(); @@ -754,6 +777,9 @@ selected={store.activeModel} onSelect={handleSelectModel} modelInfo={store.modelInfo} + namedAgents={store.namedAgents} + namedAgentSnapshot={store.namedAgentSnapshot} + onSelectNamedAgent={handleSelectNamedAgent} /> <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs re-mount per conversation — incl. switching between drafts — and can't @@ -775,6 +801,15 @@ /> {/key} </div> + {:else if kind === "named-agents"} + <!-- Named Agents: a global, collapsible sidebar view of curated presets. + The store owns the list (authoritative for the model selector + agent + tooling); the view drives CRUD via injected ports (auto-save). --> + <NamedAgentsView + agents={store.namedAgents} + models={store.models} + ports={namedAgentPorts} + /> {:else if kind === "lsp"} <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> {#key store.currentConversationId} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 9d0e605..08a7cd8 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -80,6 +80,15 @@ import type { HeartbeatStopResult, } from "../features/heartbeat"; import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; +import type { + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResult, +} from "../features/named-agents"; +import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; import { @@ -224,7 +233,8 @@ export interface AppStore { * message is a silent server no-op (nothing to roll back). */ cancelQueuedMessage(messageId: string): void; - selectModel(model: string): void; + /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */ + selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void; newDraft(): void; /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ setActiveWorkspace(workspaceId: string): void; @@ -518,6 +528,39 @@ export interface AppStore { readonly fatalError: string | null; /** Dismiss the fatal error (called by the error modal's X button). */ clearFatalError(): void; + // ── Named Agents (global config; plan §9) ────────────────────────────────── + /** + * The persisted Named Agents (from `GET /named-agents`). Global user config + * (not per-workspace); seeded on boot. Drives the sidebar editor AND the + * synthetic "Named Agents" provider in the model selector (plan §6). + */ + readonly namedAgents: readonly NamedAgent[]; + /** + * The active conversation's Named Agent selection snapshot (plan §7), or null + * when it was selected via a raw model. Survives the Named Agent being renamed + * / removed (the snapshot retains the friendly label + captured raw model). + */ + readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null; + /** Refetch the Named Agents (`GET /named-agents`). */ + refreshNamedAgents(): Promise<NamedAgentsResult>; + /** + * Create a Named Agent (`POST /named-agents`). On success the store's + * `namedAgents` list is updated (append/replace) so every consumer — the + * sidebar editor AND the model selector — reflects it without a reload. + */ + createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>; + /** + * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is + * updated in place. Returns the canonical saved record. + */ + updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>; + /** + * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the + * store list immediately (removing it from the sidebar + model selector), and + * does NOT alter conversations that previously selected it (those keep their + * snapshot — plan §7). + */ + deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>; dispose(): void; } @@ -557,6 +600,16 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy }; } +/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */ +function upsertNamedAgentList( + agents: readonly NamedAgent[], + agent: NamedAgent, +): readonly NamedAgent[] { + const idx = agents.findIndex((a) => a.id === agent.id); + if (idx === -1) return [...agents, agent]; + return agents.map((a) => (a.id === agent.id ? agent : a)); +} + export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state<ProtocolState>(protocolInitialState()); let models = $state<readonly string[]>([]); @@ -566,8 +619,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // `~/.ssh/config`). let computers = $state<readonly ComputerEntry[]>([]); let activeModel = $state(DEFAULT_MODEL); + // The persisted Named Agents (global user config). Seeded from the backend on + // boot + kept in sync by the CRUD methods; drives both the sidebar editor and + // the synthetic model-selector provider. + let namedAgents = $state<readonly NamedAgent[]>([]); + // The active conversation's Named Agent snapshot (null when selected via a raw + // model). Derived from the active tab; survives a Named Agent being edited. + let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null); let fatalError = $state<string | null>(null); + /** Replace-or-append a Named Agent into the reactive list (immutably). */ + function upsertNamedAgent(agent: NamedAgent): void { + namedAgents = upsertNamedAgentList(namedAgents, agent); + } + // The workspace currently in view (its URL slug); "default" until routing // sets it. Tabs are filtered to this workspace; a new conversation is stamped // with it on `chat.send`. @@ -725,14 +790,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (!res.ok) return; const data = (await res.json()) as ModelResponse; if (workspaceConversationId() !== id) return; + // The backend is authoritative for the persisted snapshot: restore how the + // conversation was selected (a Named Agent, or none). Falls back to the tab's + // local snapshot when the response omits it (older backend). + namedAgentSnapshot = data.namedAgent ?? namedAgentSnapshot; if (typeof data.model === "string" && data.model.length > 0) { activeModel = data.model; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, data.model); + tabsStore.setSnapshot(activeId, namedAgentSnapshot); chatStores.get(activeId)?.setModel(data.model); + chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot); } else { draftStore.setModel(data.model); + draftStore.setNamedAgent(namedAgentSnapshot); } } } catch (err) { @@ -1267,6 +1339,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { reportError("Failed to load computer list", err); }); + // Fetch the persisted Named Agents (global user config). Seeded on boot so the + // sidebar editor + the synthetic model-selector provider have data immediately. + // Non-fatal: a failure leaves an empty list (the user can add agents). + void fetchImpl(`${httpBase}/named-agents`) + .then((res) => { + if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] }; + return res.json() as Promise<{ namedAgents: NamedAgent[] }>; + }) + .then((data) => { + namedAgents = normalizeNamedAgents(data); + }) + .catch((err) => { + reportError("Failed to load named agents", err); + }); + // Restore persisted tabs const persistedState = storageAdapter.load(); if (persistedState !== null && persistedState.tabs.length > 0) { @@ -1292,6 +1379,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { ); if (activeTab !== undefined) { activeModel = migrateLegacyModelName(activeTab.model); + namedAgentSnapshot = activeTab.snapshot ?? null; } } } @@ -1331,6 +1419,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftStore = createChatFor(nextDraftId, activeModel, workspaceId); draftConversationId = nextDraftId; tabsStore.newDraft(); + namedAgentSnapshot = null; refreshActiveChat(); syncSubscriptions(); void refreshCwd(); @@ -1414,14 +1503,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { send(text: string, images?: readonly ImageInput[]): void { if (tabsStore.activeConversationId === null) { - // Draft: promote to tab on first send + // Draft: promote to tab on first send. Carry a Named Agent selection's + // snapshot so the promoted conversation retains how it was selected (§7). const conversationId = draftConversationId; const model = activeModel; + const snapshot = namedAgentSnapshot ?? undefined; tabsStore.createTab({ conversationId, model, title: deriveTitle(text), workspaceId: activeWorkspaceId, + ...(snapshot ? { snapshot } : {}), }); chatStores.set(conversationId, draftStore); void draftStore.load(); @@ -1465,21 +1557,26 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { activeChat.cancelQueuedMessage(messageId); }, - selectModel(model: string): void { + selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void { activeModel = model; + namedAgentSnapshot = snapshot ?? null; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, model); + tabsStore.setSnapshot(activeId, snapshot ?? null); chatStores.get(activeId)?.setModel(model); + chatStores.get(activeId)?.setNamedAgent(snapshot ?? null); + const body: SetModelRequest = { model, ...(snapshot ? { namedAgent: snapshot } : {}) }; void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model } satisfies SetModelRequest), + body: JSON.stringify(body), }).catch((err) => { reportError("Failed to persist model", err); }); } else { draftStore.setModel(model); + draftStore.setNamedAgent(snapshot ?? null); } }, @@ -1488,6 +1585,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const nextDraftId = randomId(); draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); draftConversationId = nextDraftId; + namedAgentSnapshot = null; refreshActiveChat(); syncSubscriptions(); void refreshCwd(); @@ -1503,6 +1601,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); if (tab !== undefined) { activeModel = tab.model; + namedAgentSnapshot = tab.snapshot ?? null; + } else { + namedAgentSnapshot = null; } refreshActiveChat(); syncSubscriptions(); @@ -2285,6 +2386,110 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { unloadGate = gate; }, + // ── Named Agents (global config; plan §9) ──────────────────────────────── + + get namedAgents(): readonly NamedAgent[] { + return namedAgents; + }, + get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null { + return namedAgentSnapshot; + }, + + async refreshNamedAgents(): Promise<NamedAgentsResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`, + }; + } + const agents = normalizeNamedAgents(await res.json()); + namedAgents = agents; + return { ok: true, agents }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Named agents request failed", + }; + } + }, + + async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`, + }; + } + const agent = normalizeNamedAgentRecord(await res.json()); + if (agent === null) return { ok: false, error: "Malformed named agent response" }; + upsertNamedAgent(agent); + return { ok: true, agent }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Create named agent request failed", + }; + } + }, + + async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`, + }; + } + const agent = normalizeNamedAgentRecord(await res.json()); + if (agent === null) return { ok: false, error: "Malformed named agent response" }; + upsertNamedAgent(agent); + return { ok: true, agent }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Update named agent request failed", + }; + } + }, + + async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`, + }; + } + namedAgents = namedAgents.filter((a) => a.id !== id); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Delete named agent request failed", + }; + } + }, + get fatalError(): string | null { return fatalError; }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index d30148e..c41efc2 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1,5 +1,6 @@ import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import type { NamedAgent } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; import { createAppStore } from "./store.svelte"; @@ -104,16 +105,67 @@ interface FakeFetchOptions { models?: readonly string[]; history?: Record<string, ConversationHistoryResponse>; model?: string | null; + /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */ + namedAgents?: readonly NamedAgent[]; } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"]; const history = opts?.history ?? {}; - return async (input: string | URL | Request): Promise<Response> => { + // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the + // backend's /named-agents surface). + let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : []; + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = + init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET"); + const bodyText = + typeof init?.body === "string" + ? init.body + : typeof input !== "string" && + !(input instanceof URL) && + typeof input.body !== "undefined" && + "body" in input + ? await input.clone().text() + : null; if (url.endsWith("/models")) { return new Response(JSON.stringify({ models }), { status: 200 }); } + if (url.endsWith("/named-agents") && method === "GET") { + return new Response(JSON.stringify({ namedAgents }), { status: 200 }); + } + if (url.endsWith("/named-agents") && method === "POST") { + const payload = + bodyText === null + ? null + : (JSON.parse(bodyText) as { name?: string; model?: string } | null); + const agent: NamedAgent = { + id: `na-${namedAgents.length + 1}`, + name: payload?.name ?? "", + model: payload?.model ?? "", + }; + namedAgents = [...namedAgents, agent]; + return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 }); + } + if (url.includes("/named-agents/") && method === "PUT") { + const id = url.split("/named-agents/")[1] ?? ""; + const payload = + bodyText === null + ? null + : (JSON.parse(bodyText) as { name?: string; model?: string } | null); + const updated: NamedAgent = { + id, + name: payload?.name ?? "", + model: payload?.model ?? "", + }; + namedAgents = namedAgents.map((a) => (a.id === id ? updated : a)); + return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 }); + } + if (url.includes("/named-agents/") && method === "DELETE") { + const id = url.split("/named-agents/")[1] ?? ""; + namedAgents = namedAgents.filter((a) => a.id !== id); + return new Response(JSON.stringify({ id }), { status: 200 }); + } if (url.endsWith("/model")) { return new Response( JSON.stringify({ @@ -2276,3 +2328,123 @@ describe("createAppStore — vision settings (global)", () => { store.dispose(); }); }); + +describe("createAppStore — Named Agents", () => { + it("seeds namedAgents from GET /named-agents on boot", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => expect(store.namedAgents).toEqual(agents)); + store.dispose(); + }); + + it("createNamedAgent appends to the store list", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.agent.id).toBe("na-1"); + expect(store.namedAgents).toHaveLength(1); + expect(store.namedAgents[0]?.name).toBe("Research"); + } + store.dispose(); + }); + + it("updateNamedAgent updates the record in place", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.updateNamedAgent("na-1", { + name: "Coding", + model: "opencode-1/new", + }); + expect(result.ok).toBe(true); + expect(store.namedAgents[0]?.name).toBe("Coding"); + expect(store.namedAgents[0]?.model).toBe("opencode-1/new"); + store.dispose(); + }); + + it("deleteNamedAgent removes the record immediately", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "na-2", name: "Research Agent", model: "umans/glm" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length)); + + const result = await store.deleteNamedAgent("na-1"); + expect(result.ok).toBe(true); + expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]); + store.dispose(); + }); + + it("selectModel records a Named Agent snapshot on the active conversation", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.selectModel("opencode-1/claude-sonnet-4.5", { + namedAgentId: "na-1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5"); + expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent"); + store.dispose(); + }); + + it("selectModel without a snapshot clears a prior Named Agent selection", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.selectModel("opencode-1/claude-sonnet-4.5", { + namedAgentId: "na-1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + store.selectModel("openai/gpt-4o"); + + expect(store.activeModel).toBe("openai/gpt-4o"); + expect(store.namedAgentSnapshot).toBeNull(); + store.dispose(); + }); +}); |
