diff options
| author | Adam Malczewski <[email protected]> | 2026-08-12 22:01:59 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-08-12 22:01:59 +0900 |
| commit | 5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443 (patch) | |
| tree | 2f1d5ee2674d1dcc9909e89f06a82a7b10b4191a /src/app/store.test.ts | |
| parent | 774519e052fd869952cc5e05aca159e4bf978fa1 (diff) | |
| download | dispatch-web-5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443.tar.gz dispatch-web-5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443.zip | |
feat(models): numbered OpenCode credential selection, labels, migration
Adopt the numbered OPENCODE_API_KEY_N scheme in the model picker and
concurrency surfaces.
- model-select: numeric sort of opencode-<n> keys (#2 before #10); friendly
OpenCode #N labels (raw opencode-N/<model> value still selected/persisted);
migrateLegacyModelName rewrites opencode/<model> -> opencode-1/<model>.
- ModelSelector: renders friendly key labels; surfaces a disappeared saved
credential as UNAVAILABLE (disabled option + notice) without silently
switching to another model.
- app store: DEFAULT_MODEL -> opencode-1/...; catalogs no longer auto-fallback
the active model when the selected credential is absent; boot restore
migrates persisted legacy opencode/<model> tab models idempotently.
- concurrency: providerLabel renders opencode-<n> as OpenCode #<n> in the
limit rows and Add dropdown (raw provider id preserved on the wire).
- Tests: parsing/sorting/labels/migration, unavailable-state selector behavior,
no-silent-fallback, and concurrency provider label; fixtures updated to the
numbered scheme.
Verification: bun install, bun run typecheck (0 errors), bun run test
(1179 passed), bun run check (clean, exit 0), bun run build (vite OK).
Diffstat (limited to 'src/app/store.test.ts')
| -rw-r--r-- | src/app/store.test.ts | 73 |
1 files changed, 62 insertions, 11 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 47c3977..16d1760 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -107,7 +107,7 @@ interface FakeFetchOptions { } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { - const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"]; const history = opts?.history ?? {}; return async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; @@ -524,7 +524,7 @@ describe("createAppStore", () => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchedUrls.push(url); if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -573,7 +573,7 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl({ - models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], }), localStorage: createFakeStorage(), }); @@ -582,7 +582,7 @@ describe("createAppStore", () => { await new Promise((r) => setTimeout(r, 50)); expect(store.models).toEqual([ - "opencode/deepseek-v4-flash", + "opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3", ]); @@ -599,11 +599,62 @@ describe("createAppStore", () => { }); ws.resolveOpen(); - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); store.dispose(); }); + it("does not silently switch the active model when the selected one is absent from the catalog", async () => { + // The catalog only has umans; the active (default/selected) OpenCode model is + // NOT available. The FE must NOT auto-fall back to the first catalog model — + // it keeps the selection and lets the model selector surface it as unavailable. + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await new Promise((r) => setTimeout(r, 50)); + + expect(store.models).toEqual(["umans/umans-glm-5.2"]); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + + store.dispose(); + }); + + it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => { + const ws = fakeSocket(); + // Seed localStorage with a persisted tab whose model is the OBSOLETE + // `opencode/deepseek-v4-flash` namespace. + const storage = createFakeStorage(); + storage.setItem( + "dispatch.tabs", + JSON.stringify({ + tabs: [ + { + conversationId: "c-legacy", + title: "Legacy", + model: "opencode/deepseek-v4-flash", + workspaceId: "default", + }, + ], + activeConversationId: null, + }), + ); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + // After restore, the restored tab's model + the persisted store are migrated. + const restored = store.tabs.find((t) => t.conversationId === "c-legacy"); + expect(restored?.model).toBe("opencode-1/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + store.dispose(); + }); + it("draft: sending the first message creates a tab titled from the message", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -679,7 +730,7 @@ describe("createAppStore", () => { store.send("first message"); // New tab opens with the default model until the persisted-model fetch resolves. - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); // Wait for the persisted model fetch to resolve. await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); @@ -1212,7 +1263,7 @@ describe("createAppStore", () => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchedUrls.push(url); if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -1518,7 +1569,7 @@ describe("createAppStore", () => { const limits = opts?.limits ?? { limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }; const limit = opts?.limit ?? { providerId: "umans", limit: 4 }; @@ -1526,7 +1577,7 @@ describe("createAppStore", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -1586,7 +1637,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); store.dispose(); }); @@ -1758,7 +1809,7 @@ describe("createAppStore", () => { autoReduced: false, }); expect(result.providers[1]).toMatchObject({ - providerId: "openai-compat", + providerId: "opencode-1", paused: true, pausedUntil: 1_719_408_000_000, cooldownMs: 350, |
