diff --git a/.specs/features/web-console/spec.md b/.specs/features/web-console/spec.md index 074f7cc..5967a7f 100644 --- a/.specs/features/web-console/spec.md +++ b/.specs/features/web-console/spec.md @@ -23,7 +23,7 @@ Setup and usage analytics currently require the terminal, while review already s - The setup wizard allows typed harness:model values through src/cli/picker-state.ts:113-141 and keeps configured models that are absent from the catalog in src/cli/commands/setup.ts:499-518. It also preserves a role's binding and effort when their screens are skipped; first-run skips produce an empty agents object sentinel. Tests are in tests/setup-wizard.test.ts:579-592, :757-764, and :1179-1183. - The setup command resolves explicit and active profiles in src/cli/commands/setup.ts:831-853. An active profile that does not exist raises SetupUsageError. An explicit profile without a saved snapshot starts from profile defaults with agents: {}. - SetupEnvelope in src/cli/commands/setup.ts:1000-1036 uses top-level fields proposta, validacoes, mudancas, and resultado. runSetupBatch reads config without replacing invalid JSON and returns code 14 for invalid config or 15 for a read error at :1369-1387. It validates only winning --bind entries at :1404 and :1444-1459. loadConfig in src/config/config.ts:531-540 swallows read and parse errors, so web setup must use readConfigForSetup. -- getBatchModels in src/core/models.ts:276-389 supports allowNetwork:false for cached reads and refresh:true for network discovery, with a default 12,000 ms timeout. If discovery is incomplete or a requested harness reports an error, it discards partial network results and returns the cache fallback with status and discoveryError. +- getBatchModels in src/core/models.ts:276-389 supports allowNetwork:false for cached reads and refresh:true for network discovery, with a default 25,000 ms timeout (`CATALOG_DISCOVERY_TIMEOUT_MS`). If discovery is incomplete or a requested harness reports an error, it discards partial network results and returns the cache fallback with status and discoveryError. - Custom orchestrator parallelism accepts positive finite numbers in src/cli/commands/setup.ts:319-345 and :361-394. README.md:149-186 currently describes setup as a terminal picker. ## Out of Scope @@ -56,7 +56,7 @@ Setup and usage analytics currently require the terminal, while review already s | Token bootstrap redirect | A valid token GET to an HTML route sets the cookie and returns HTTP 303 to the same path without t. | The token is removed from the address bar after bootstrap. | No | | Origin comparison | Require an HTTP Origin whose host and port match the accepted request Host and bound port on every POST; return HTTP 403 if absent or different. | This gives every action route one same-origin rule. | No | | Home links | Render a link only when its page route is registered in the active route table. | P1 must not advertise Usage before P5 registers it. | No | -| Setup catalog source | GET calls getBatchModels with allowNetwork:false; protected refresh calls it with refresh:true, allowNetwork:true, and timeoutMs:12000. | This is the single catalog source and matches the batch helper's fallback behavior. | No | +| Setup catalog source | GET calls getBatchModels with allowNetwork:false; protected refresh calls it with refresh:true, allowNetwork:true, and timeoutMs:25000 (CATALOG_DISCOVERY_TIMEOUT_MS). | This is the single catalog source and matches the batch helper's fallback behavior. | No | | Concurrent catalog refresh | Requests during one discovery share one in-flight refresh promise. | Duplicate provider requests do not improve the displayed result. | No | | Setup --refresh behavior | On an interactive setup web launch, open /setup and request protected catalog refresh on page load. | This preserves the current flag while keeping discovery behind the action route. | No | | Setup request size | Accept JSON bodies up to 64 KiB; return HTTP 400 for larger or malformed bodies. | A full role selection is small and a fixed limit bounds request parsing. | No | @@ -65,7 +65,7 @@ Setup and usage analytics currently require the terminal, while review already s | Usage --by with --web | Use --by as the initially selected breakdown; keep all available breakdown sections on the page. | This preserves the CLI grouping preference without hiding the other web sections. | No | | Usage interval | Use a floor of 1 second and a numeric-conversion fallback of 2 seconds for web polling. | Commander supplies the default string "2", so the implementation cannot distinguish an omitted option from explicit --interval 2. | No | | Aggregate usage --web --json | Start the browser page and print the token URL; --json affects aggregate output only when --web is absent. | The browser is the selected aggregate interface, while the positional run-id JSON contract remains unchanged. | No | -| Catalog refresh method | Use protected POST /api/setup/catalog/refresh with refresh:true, allowNetwork:true, and timeoutMs:12000. | Discovery may update the model cache, so it uses the guarded action path and the helper's current timeout. | No | +| Catalog refresh method | Use protected POST /api/setup/catalog/refresh with refresh:true, allowNetwork:true, and timeoutMs:25000 (CATALOG_DISCOVERY_TIMEOUT_MS). | Discovery may update the model cache, so it uses the guarded action path and the helper's current timeout. | No | | Web apply catalog validation | Validate changed harness:model bindings against the cached catalog with getBatchModels({allowNetwork:false}); reject an off-catalog model with HTTP 422 unless the request includes explicit confirmation for that role. | This keeps apply offline and mirrors the wizard's second-Enter confirmation for typed models absent from its catalog. | Yes | | Page behavior tests | Put state, rendering decisions, and polling transitions in TypeScript functions exported from each page module, then inject their source into its HTML string. | Node tests can exercise the same functions without installing a DOM implementation. | No | | Usage origin dependency | Use the required byOrigin type already present in main at 80ec486 (#103); tolerate an absent field only when an older daemon process answers over IPC. | The implementation branch has the usage changes already, while an older running daemon can still use the earlier protocol shape. | Yes | diff --git a/src/core/models.ts b/src/core/models.ts index 454903d..4b5e44e 100644 --- a/src/core/models.ts +++ b/src/core/models.ts @@ -249,7 +249,9 @@ function maxAge(entries: Array<{ age: CacheAge }>): number | null { return Math.max(...entries.map((entry) => entry.age.ageMs)); } -export const CATALOG_DISCOVERY_TIMEOUT_MS = 12_000; +// Covers the slowest driver budget: omp spends up to 10 s on `models refresh` +// and 12 s on `models --json`. Discovery returns as soon as every harness answers. +export const CATALOG_DISCOVERY_TIMEOUT_MS = 25_000; async function discoverWithTimeout( registry: DriverRegistry, diff --git a/src/drivers/opencode/driver.ts b/src/drivers/opencode/driver.ts index 9cc7191..f2b5edf 100644 --- a/src/drivers/opencode/driver.ts +++ b/src/drivers/opencode/driver.ts @@ -75,8 +75,12 @@ export class OpencodeDriver extends SessionDriver { if (!install.installed || !install.path) return []; try { - const args = options?.refresh ? ["models", "--refresh"] : ["models"]; - const stdout = await runCommandWithTimeout(install.path, args, { timeoutMs: 10000 }); + const list = (args: string[]) => runCommandWithTimeout(install.path!, args, { timeoutMs: 10000 }); + // OpenCode v2 dropped `models --refresh` and exits with its usage text, + // so a refresh falls back to the plain listing instead of an empty catalog. + const stdout = options?.refresh + ? await list(["models", "--refresh"]).catch(() => list(["models"])) + : await list(["models"]); const lines = stdout.split("\n"); const providerMap = new Map(); diff --git a/tests/opencode-driver.test.ts b/tests/opencode-driver.test.ts index 6b2bea9..d3d8c4b 100644 --- a/tests/opencode-driver.test.ts +++ b/tests/opencode-driver.test.ts @@ -19,6 +19,28 @@ afterEach(() => { }); describe("OpencodeDriver", () => { + it("lists models on refresh when the binary rejects models --refresh", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-opencode-models-")); + const binary = path.join(dir, "opencode"); + fs.writeFileSync( + binary, + '#!/bin/sh\nif [ "$2" = "--refresh" ]; then echo "USAGE" >&2; exit 1; fi\nprintf "alibaba-token-plan/qwen3.8-max\\nopencode/gemini-3.8-flash\\n"\n', + { mode: 0o755 }, + ); + mockedDetect.mockResolvedValue({ installed: true, path: binary, version: "opencode v2.0.15" }); + + try { + const providers = await new OpencodeDriver().listModels({ refresh: true }); + + expect(providers.map((provider) => [provider.provider, provider.models.map((model) => model.id)])).toEqual([ + ["alibaba-token-plan", ["alibaba-token-plan/qwen3.8-max"]], + ["opencode", ["opencode/gemini-3.8-flash"]], + ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("detects and launches the managed OpenCode v2 binary when PATH is stale", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-opencode-")); const binary = path.join(home, ".opencode", "bin", "opencode"); diff --git a/tests/setup-web.test.ts b/tests/setup-web.test.ts index 3aa345e..ffc54be 100644 --- a/tests/setup-web.test.ts +++ b/tests/setup-web.test.ts @@ -228,7 +228,7 @@ describe("setup catalog routes", () => { cacheWriteFailed: false, }; const getBatchModels = vi.fn(async (options: BatchModelsOptions) => { - expect(options).toEqual({ refresh: true, allowNetwork: true, timeoutMs: 12_000 }); + expect(options).toEqual({ refresh: true, allowNetwork: true, timeoutMs: 25_000 }); return await new Promise((resolve) => { resolveRefresh = resolve; }); }); const handle = await makeServer({ getBatchModels }); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 1cfa3a2..42764c3 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -204,7 +204,7 @@ describe("ui route table", () => { expect(await usage.json()).toEqual(emptyUsage); expect(fetchUsageQuery).toHaveBeenCalledOnce(); expect(getBatchModels).toHaveBeenCalledWith({ allowNetwork: false }); - expect(getBatchModels).toHaveBeenCalledWith({ refresh: true, allowNetwork: true, timeoutMs: 12_000 }); + expect(getBatchModels).toHaveBeenCalledWith({ refresh: true, allowNetwork: true, timeoutMs: 25_000 }); }); });