From d4cc8879441329c935233f1bffffc07f48dc5680 Mon Sep 17 00:00:00 2001 From: Malcolm Riddoch Date: Thu, 11 Jun 2026 13:35:42 +1200 Subject: [PATCH 1/2] feat(vscode): add opencode model effort toggle Adds optional explicit model effort/variant selection for reasoning-capable models in the OpenCode chat webview, matching the opencode TUI Ctrl+T flow. Core: - Add optional effort (ModelVariantRef) to ModelRef-adjacent types: ModelInfo.variants, SendMessageOptions.effort, and UIToHostMessage sendMessage / editAndResend. - Add helper getModelVariants / isModelVariantSupported / validateModelVariant with 27 unit tests covering unsupported metadata, disabled variants, and model-change invalidation. - Add vitest setup for @opencodegui/core. Agent (opencode): - sendMessage forwards options.effort.id as top-level variant on client.session.promptAsync (sibling of model). Default payloads omit the key entirely so the server applies its own default. - executeShell is intentionally unchanged: SDK 1.2.17 shell body has no variant field. Webview: - useProviders tracks selectedModelEffort, validates on model change, and clears when the new model does not advertise the prior id. - ModelSelector displays effort (label or id) compactly next to the model name; no separator/text when unset. - InputArea handles Ctrl+T (textarea only, no meta/alt modifiers) to cycle valid variants from ModelInfo.variants; no preventDefault when the model is unsupported or no setter is wired. - App forwards effort in sendMessage / editAndResend payloads only when selected; executeShell payload remains { sessionId, command, model }. Host: - chat-view-provider forwards message.effort into IAgent.sendMessage options for send and edit-resend; never for executeShell. OpenSpec: archived change `2026-06-11-add-model-effort-toggle` and added spec `model-effort-control`. --- .../.openspec.yaml | 2 + .../design.md | 350 +++++++++++++++ .../proposal.md | 73 +++ .../tasks.md | 24 + openspec/specs/model-effort-control/spec.md | 104 +++++ .../src/__tests__/opencode-agent.test.ts | 62 +++ .../agents/opencode/src/opencode-agent.ts | 6 + packages/core/package.json | 7 +- .../core/src/__tests__/model-effort.test.ts | 289 ++++++++++++ packages/core/src/domain.ts | 29 ++ packages/core/src/index.ts | 1 + packages/core/src/model-effort.ts | 108 +++++ packages/core/src/protocol.ts | 17 + packages/core/vitest.config.ts | 7 + .../src/__tests__/chat-view-provider.test.ts | 127 ++++++ .../vscode/src/chat-view-provider.ts | 2 + packages/platforms/vscode/webview/App.tsx | 59 ++- .../molecules/ModelSelector.test.tsx | 62 +++ .../__tests__/hooks/useProviders.test.ts | 293 ++++++++++++ .../__tests__/scenarios/03-messaging.test.tsx | 147 +++++- .../scenarios/04-message-editing.test.tsx | 177 +++++++- .../scenarios/14-shell-command.test.tsx | 79 +++- .../scenarios/26-effort-cycle.test.tsx | 421 ++++++++++++++++++ .../ModelSelector/ModelSelector.module.css | 25 +- .../molecules/ModelSelector/ModelSelector.tsx | 43 +- .../organisms/InputArea/InputArea.tsx | 111 ++++- .../vscode/webview/hooks/useProviders.ts | 103 ++++- pnpm-lock.yaml | 3 + 28 files changed, 2697 insertions(+), 34 deletions(-) create mode 100644 openspec/changes/archive/2026-06-11-add-model-effort-toggle/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-11-add-model-effort-toggle/design.md create mode 100644 openspec/changes/archive/2026-06-11-add-model-effort-toggle/proposal.md create mode 100644 openspec/changes/archive/2026-06-11-add-model-effort-toggle/tasks.md create mode 100644 openspec/specs/model-effort-control/spec.md create mode 100644 packages/core/src/__tests__/model-effort.test.ts create mode 100644 packages/core/src/model-effort.ts create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/platforms/vscode/webview/__tests__/scenarios/26-effort-cycle.test.tsx diff --git a/openspec/changes/archive/2026-06-11-add-model-effort-toggle/.openspec.yaml b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/.openspec.yaml new file mode 100644 index 0000000..2cb8041 --- /dev/null +++ b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-10 diff --git a/openspec/changes/archive/2026-06-11-add-model-effort-toggle/design.md b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/design.md new file mode 100644 index 0000000..862f2ac --- /dev/null +++ b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/design.md @@ -0,0 +1,350 @@ +## Context + +opencode-gui currently has a provider/model selector and sends prompts using a `ModelRef` containing only `providerID` and `modelID`. The send path is: + +``` +InputArea -> App.handleSend -> UIToHostMessage.sendMessage + -> chat-view-provider -> IAgent.sendMessage + -> opencode-agent -> client.session.promptAsync({ model }) +``` + +The GUI displays model reasoning output when opencode returns reasoning parts, but it does not expose the input-side model effort/variant controls available in opencode TUI via `Ctrl+T`. Context7/OpenCode docs indicate model effort is represented through model variants or model `options` such as `reasoningEffort`, but the local GUI code does not currently inspect or forward those fields. + +## Goals / Non-Goals + +**Goals:** +- Preserve current default behavior unless the user explicitly selects effort. +- Match TUI muscle memory by supporting `Ctrl+T` from the webview message input. +- Derive valid effort choices from opencode/provider metadata rather than hardcoding OpenAI-only values. +- Forward explicit effort/variant through the existing UI protocol and opencode agent integration for the **chat prompt** and **edit/resend prompt** flows. +- Show the selected explicit effort compactly near the selected model. +- Keep the implementation additive and compatible with existing selected model state. +- Preserve existing `executeShell` behavior unchanged: shell commands continue to work, and current opencode SDK does not expose a variant hook for `client.session.shell(...)`, so shell sends do not include effort in this change. + +**Non-Goals:** +- Do not create a full model/provider configuration editor. +- Do not write effort into `opencode.json` as a persistent provider option. +- Do not guess provider effort lists when opencode metadata does not expose them. +- Do not alter disconnected-provider behavior or model persistence semantics. + +## Decisions + +### Decision 1: Represent GUI effort as an explicit optional selection + +Use an optional GUI state value such as: + +```ts +type ModelEffortSelection = { + id: string; + label?: string; +}; +``` + +The unset state means "use opencode default." This is critical because effort maps directly to intelligence, latency, and spend. + +Alternatives considered: +- Default to the first listed effort: rejected because it silently changes cost/intelligence. +- Infer defaults from model names: rejected because provider semantics differ and may drift. + +### Decision 2: Discover choices through normalized metadata helpers + +Add a small helper that accepts the selected provider/model metadata and returns a normalized effort list. The helper should be tolerant of the actual opencode metadata shape after verification. + +Expected metadata sources to verify before implementation: +- model-level `variants` with ids/labels and request options +- model-level `options` that includes `reasoningEffort` +- provider/model metadata exposed by `listAllProviders()` but not yet represented in TypeScript types + +The helper should initially support verified metadata only. If no verified choices exist, return an empty list and leave effort unsupported for that model. + +Alternatives considered: +- Hardcode provider maps for OpenAI/DeepSeek/Anthropic: rejected as brittle and contrary to provider-agnostic GUI behavior. +- Treat `reasoning: true` as enough to expose a default effort set: rejected because not all reasoning models share valid effort identifiers. + +### Decision 3: Extend existing contracts additively + +The verified opencode SDK 1.2.17 request shape puts `variant?: string` as a **top-level sibling of `model`** on `client.session.promptAsync` and `client.session.command` (see Discovery Findings §1). It does **not** belong inside `ModelRef`. Effort therefore rides as a separate optional field on send options and protocol messages. + +Verified additive contract (matches the SDK shape): + +```ts +export type ModelVariantRef = { + id: string; // variant id (e.g., "low" | "medium" | "high" | provider-specific) + disabled?: boolean; // optional; surfaces server-side disabled flag if present +}; + +export type ModelRef = { + providerID: string; + modelID: string; + // intentionally no effort/variant field — variant travels as a sibling in the wire payload +}; + +export type ModelInfo = { + id: string; + name: string; + // ... existing fields preserved ... + variants?: Record>; // opaque server-provided map +}; + +export type SendMessageOptions = { + model?: ModelRef; + effort?: ModelVariantRef; // NEW: optional, omitted = opencode default behavior + files?: FileAttachment[]; + agent?: string; + primaryAgent?: string; + skill?: string; +}; +``` + +UI protocol messages that carry a prompt payload (`sendMessage`, `editAndResend`) also grow an optional `effort?: ModelVariantRef`. The opencode agent integration forwards it as the wire-level `variant: effort.id` on `promptAsync` and `command`. `executeShell` is intentionally **not** extended in this change because the current `client.session.shell(...)` body has no `variant` field (Discovery Findings §1); shell continues to use `{ providerID, modelID }` only. + +Alternatives considered: +- Add effort only to webview local state and mutate modelID: rejected — opencode variants are selected by id, not by changing the model id. +- Put effort inside `ModelRef` as a nested field: rejected — the SDK wire shape places `variant` outside `model`; nesting would force the agent to unwrap and re-wrap, and the mapper cast is already permissive about field shape. +- Extend `executeShell` with effort: rejected for this change because the SDK 1.2.17 shell body has no `variant` key; revisit on SDK upgrade. + +### Decision 4: `Ctrl+T` is handled where text shortcuts already live + +Handle `Ctrl+T` in `InputArea.tsx`'s `handleKeyDown`, before popup navigation and Enter send logic. The handler should call a callback such as `onCycleModelEffort` when the selected model supports effort choices. + +Implementation notes: +- Use `e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === "t"`. +- Call `e.preventDefault()` only when a cycle action is available, to avoid interfering with unrelated contexts. +- Keep behavior scoped to the textarea/webview focus. + +Alternatives considered: +- Global `window.keydown`: rejected initially because it risks shortcut conflicts outside message input. +- VS Code command/keybinding bridge: useful fallback if textarea capture is unreliable, but can be added after verifying webview behavior. + +### Decision 5: Display effort compactly in the model selector button + +Show explicit effort next to the selected model label, for example `GPT-5.5 · medium`. When effort is unset, show only the model name to avoid implying a non-default choice. + +The model selector popover can later expose clickable effort controls, but the first parity slice should prioritize `Ctrl+T`, payload propagation, and visible state. + +Alternatives considered: +- Add a separate effort dropdown immediately: larger UI change; defer until keyboard parity and protocol correctness are established. + +## Risks / Trade-offs + +- **Risk: opencode metadata shape differs from assumptions** -> Mitigation: Step 1.1 ran a sanitized read-only spike against the installed SDK and a live opencode 1.16.2 server; the verified shape is recorded in Discovery Findings (variant map on model metadata, top-level `variant?: string` on `promptAsync` / `command`). +- **Risk: effort forwarding API is not `model.effort`** -> Resolved by Discovery Findings §1: `variant` is a top-level sibling of `model`; the opencode-agent must send `{ ...model, variant: effort.id }` and the existing additive `effort?: ModelVariantRef` option becomes the source of truth. +- **Risk: `Ctrl+T` is intercepted by VS Code or browser** -> Mitigation: test with React Testing Library and real VS Code after VSIX install; if not reliable, add extension-side command/keybinding fallback in a later task. +- **Risk: `client.session.shell(...)` lacks `variant` in SDK 1.2.17** -> Mitigation: shell sends continue to use `{ providerID, modelID }` only and do not include effort in this change. Existing `executeShell` behavior is preserved. Revisit on SDK upgrade. +- **Risk: invalid effort after model switch** -> Mitigation: centralize selected-model/effort validation in provider state hook or App-level derived state. +- **Risk: accidental cost change** -> Mitigation: unset state never sends effort; no guessed defaults; tests assert no effort in default payload. + +## Migration Plan + +1. Add type and helper support without changing default payloads. +2. Add UI state and `Ctrl+T` cycling for verified metadata. +3. Add payload propagation for explicit effort only. +4. Add display and tests. +5. Build and install VSIX for manual verification. + +Rollback path: +- Revert the additive effort fields and UI state changes. Existing model selection and prompt send paths remain compatible because default behavior omits effort. + +## Open Questions + +- Resolved (Step 1.1): provider model metadata shape — `variants?: Record>` on each model from both `client.config.providers()` and `client.provider.list()` (Discovery Findings §2). Live probe across 12 providers / 687 models confirms the shape and shows that 7 distinct variant ids appear in the wild, with sets varying per model. +- Resolved (Step 1.1): `client.session.promptAsync` request shape — top-level `variant?: string` sibling of `model: { providerID, modelID }` (Discovery Findings §1). `client.session.command` mirrors the same shape. +- Resolved (Step 1.1): `client.session.shell(...)` does not support `variant` in the current SDK 1.2.17 body; shell sends do not carry effort in this change (Discovery Findings §1). Revisit on SDK upgrade. +- Open: does `Ctrl+T` reliably reach the webview textarea in VS Code on macOS, or does it require an extension-side keybinding bridge? (Still requires live VS Code verification after VSIX install.) + +## Discovery Findings + +Step 1.1 inspected the GUI contracts, the installed opencode SDK types, and ran a sanitized live-server probe. No application source was modified. + +### 1. SDK request shape (verified) + +Local SDK: `@opencode-ai/sdk` v1.2.17 (`node_modules/.pnpm/@opencode-ai+sdk@1.2.17/.../dist/v2/gen/sdk.gen.d.ts`). + +- `client.session.promptAsync(...)` parameters include a top-level `variant?: string` field, separate from `model: { providerID, modelID }`. + - Evidence: `sdk.gen.d.ts:591-609` — `variant?: string;` listed as a sibling of `model`, `agent`, `noReply`, `tools`, `format`, `system`, `parts`. +- `client.session.command(...)` parameters include `variant?: string`. + - Evidence: `sdk.gen.d.ts:615-633` — `variant?: string;`. +- `client.session.shell(...)` parameters do **not** include `variant`. + - Evidence: `sdk.gen.d.ts:639-649` — body has only `agent`, `model?: { providerID; modelID }`, `command`. No `variant` key. +- `client.session.prompt(...)` (synchronous variant) also has `variant?: string`. + - Evidence: `types.gen.d.ts:2762-2793` (`SessionPromptData.body.variant?: string`). +- Wire-level messages (`UserMessage` / `AssistantMessage`) carry `variant?: string`, confirming the JSON field name. + - Evidence: `types.gen.d.ts:122` and `types.gen.d.ts:209`. + +Conclusion: effort/variant travels as a **sibling of `model`**, not inside it. This invalidates the `ModelRef.effort` sketch in Decision 3 and confirms the SHOULD guidance to keep effort outside `model`. + +### 2. Provider metadata shape (verified) + +Two metadata endpoints are consumed by the agent: + +- `client.config.providers()` → `ConfigProvidersResponses[200] = { providers: Provider[]; default }`. + - Each `Provider.models[k]` is a `Model` with `variants?: { [key: string]: { [key: string]: unknown } }`. + - Evidence: `types.gen.d.ts:1294-1361` (`Model.variants?`), and `types.gen.d.ts:1356-1360` (the variants map shape). +- `client.provider.list()` → `ProviderListResponses[200] = { all, default, connected }`. + - Each `all[i].models[k]` also has `variants?: { [key: string]: { [key: string]: unknown } }`. + - Evidence: `types.gen.d.ts:3342-3401` (the inline model shape inside `all[]`). + +Variant value is an **opaque config bag** (typically `{ reasoningEffort, reasoningSummary, include }` for OpenAI). The GUI does **not** need to read or pass those nested fields — it only needs to forward the variant id; the server applies the internal mapping. + +`Model` also exposes `capabilities.reasoning: boolean` and `options: { [key: string]: unknown }`, but `reasoning: true` does **not** imply variants exist. `options` is the provider-config object, not the per-call payload. + +### 3. Live-server probe (sanitized) + +Commands run (none of these print env vars, tokens, full config, or secret-bearing values): + +```bash +# Start an isolated server on a fixed port +opencode serve --port 14192 --hostname 127.0.0.1 --print-logs >/tmp/opencode-probe2.log 2>&1 & +# Hit /config/providers, then sanitize the response with a tiny Node script that +# keeps only provider IDs, model IDs, and variant keys/disabled flags. +curl -s --max-time 5 http://127.0.0.1:14192/config/providers -o /tmp/opencode-providers.json +# (sanitization script: see /var/folders/p_/42dxywxs37b9812bnhsy3jdr0000gn/T/opencode/effort-probe) +kill %1 +``` + +Probe server version: opencode 1.16.2 (matches local SDK family). + +Summary of the sanitized output (no model keys/tokens/cost headers printed): + +- 12 providers, 687 models total. +- 153 models expose `variants` (non-empty). +- 404 models advertise `capabilities.reasoning: true`; **251 of those have zero variants** (reasoning alone is not a sufficient signal for effort support). +- 7 distinct variant ids observed across all providers: `none, low, medium, high, xhigh, max, minimal`. +- Variant sets are **per-model**, not per-provider. Real examples: + - `openai/gpt-5.4` → `[none, low, medium, high, xhigh]` + - `openai/gpt-5.5-pro` → `[medium, high, xhigh]` (no `none`, no `low`) + - `openai/gpt-5.4-fast` / `gpt-5.4-mini*` → `[none, low, medium, high, xhigh]` + - `deepseek/deepseek-v4-pro` / `deepseek-v4-flash` → `[low, medium, high, max]` + - `deepseek/deepseek-reasoner` → `[]` (no variants) + - `alibaba/qwq-plus` / `qvq-max` → `[low, medium, high]` + - `deepinfra/openai/gpt-oss-120b` / `gpt-oss-20b` → `[low, medium, high]` + - `fireworks-ai/accounts/fireworks/models/deepseek-v4-pro` → `[low, medium, high, max]` +- No model in this server's `config.providers` payload returned a `disabled: true` variant; defensive filtering is still added in the helper for SDK evolution. +- Variant value sample for `openai/gpt-5.4`: `{ reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }` — opaque, server-applied; GUI never reads it. + +Wire-level smoke test (sanitized) — `POST /session/:id/message` with a JSON body containing `variant: "low"` was accepted by the running opencode 1.16.2 server without 4xx, confirming the field is part of the current request contract. + +### 4. CLI confirmation (no secrets printed) + +`opencode run --help` documents the user-facing flag and its meaning: + +``` +--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) +``` + +The "e.g." wording corroborates the probe: the values are not a fixed set; the CLI examples are illustrative. + +### 5. Current GUI surface + +- `packages/core/src/domain.ts:305-308` — `ModelRef = { providerID; modelID }`. No effort/variant field. +- `packages/core/src/domain.ts:464-470` — `SendMessageOptions = { model?; files?; agent?; primaryAgent?; skill? }`. No effort field. +- `packages/core/src/domain.ts:394-421` — `ProviderInfo` / `ModelInfo` types do not declare `variants` (the runtime payload is currently cast through `mappers.ts:89-95` which uses `as unknown as`). +- `packages/core/src/protocol.ts:55-82` — `sendMessage`, `editAndResend`, `executeShell` carry only `model?: ModelRef`. No `effort` channel. +- `packages/platforms/vscode/webview/App.tsx:244-275, 282-313` — `handleSend` and `handleShellExecute` post `model: prov.selectedModel ?? undefined`. Nothing for effort. +- `packages/platforms/vscode/src/chat-view-provider.ts:100-109, 207-224` — extension host passes `message.model` straight into `agent.sendMessage` and `agent.executeShell`. No effort handling. +- `packages/agents/opencode/src/opencode-agent.ts:241-300` — `sendMessage` calls `client.session.promptAsync({ sessionID, parts, model, agent })`; `executeShell` calls `client.session.shell({ sessionID, agent, command, model })`. No `variant` is ever forwarded. This is the single point where the additive `variant` must be wired. + +### 6. Recommended implementation contract (additive) + +Replace the candidate `ModelRef.effort` sketch in Decision 3 with a sibling field, because the SDK request shape puts `variant` outside `model`: + +```ts +// packages/core/src/domain.ts — additive +export type ModelVariantRef = { + id: string; // variant id (e.g., "low" | "medium" | "high" | provider-specific) + disabled?: boolean; // surfaces server-side disabled flag if present +}; + +export type ModelRef = { + providerID: string; + modelID: string; + // intentionally no effort/variant field — see Decision 3 SHOULD note +}; + +export type ModelInfo = { + id: string; + name: string; + // ... existing fields preserved ... + variants?: Record>; // NEW: opaque server-provided map +}; + +export type SendMessageOptions = { + model?: ModelRef; + effort?: ModelVariantRef; // NEW: optional, omitted = opencode default behavior + files?: FileAttachment[]; + agent?: string; + primaryAgent?: string; + skill?: string; +}; +``` + +Protocol extension (additive; existing `{ providerID, modelID }` callers remain valid): + +```ts +// packages/core/src/protocol.ts — additive +| { + type: "sendMessage"; + sessionId: string; + text: string; + model?: ModelRef; + effort?: ModelVariantRef; // NEW + files?: FileAttachment[]; + agent?: string; + primaryAgent?: string; + skill?: string; + } +| { + type: "editAndResend"; + sessionId: string; + messageId: string; + text: string; + model?: ModelRef; + effort?: ModelVariantRef; // NEW + files?: FileAttachment[]; + } +| { + type: "executeShell"; + sessionId: string; + command: string; + model?: ModelRef; + effort?: ModelVariantRef; // NEW (forwarded on a best-effort basis; see below) + } +``` + +Agent integration: + +```ts +// packages/agents/opencode/src/opencode-agent.ts — additive +// - sendMessage / editAndResend → forward `variant: options.effort?.id` on promptAsync +// - executeShell → SDK 1.2.17 SessionShellData has no `variant` body field +// Fallback: drop the effort for shell sends in this SDK version. Re-check +// against newer SDK CHANGELOG; do not invent a request shape. +``` + +Normalized helper (tolerates metadata evolution): + +```ts +// packages/core/src/model-effort.ts — new file +export function getModelVariants(model: ModelInfo): ModelVariantRef[] { + const variants = (model.variants ?? {}) as Record>; + return Object.entries(variants) + .filter(([, v]) => !(v && typeof v === "object" && (v as { disabled?: unknown }).disabled === true)) + .map(([id]) => ({ id })); +} +``` + +Empty result means "no cycle support for this model"; the GUI must not invent defaults from the model id or provider id (probe shows 251 reasoning models without variants). + +### 7. MUST compliance notes + +- Default behavior: when `effort` is `undefined`, neither `sendMessage` nor `executeShell` writes a `variant` key, so the opencode server applies its own default (server log accepted all three of: variant=low, no variant, and unknown variant; only the explicit one is preserved server-side as intent). +- No hardcoded effort list: choices come from `getModelVariants(model)`. The `e.g., high, max, minimal` text in CLI help is not a contract. +- Additive contract: every protocol and agent signature preserves the existing `{ providerID, modelID }` shape; effort is an optional sibling. +- Evidence documented: SDK file paths and line numbers, live-server probe command, CLI help excerpt, and per-provider model examples are all recorded above. + +### 8. Open blockers / follow-ups (deferred to Step 1.2+) + +- `client.session.shell` does not accept `variant` in SDK 1.2.17. Mitigation: drop effort on shell send in this SDK version; revisit on SDK upgrade. +- No test fixtures yet for variant metadata; mock the `ProviderInfo` / `ModelInfo` shape with a synthetic `variants` map for unit tests. +- `ModelInfo` is currently cast through `as unknown as` (`mappers.ts:89-95`); add an explicit `variants` field to the domain type and update the cast accordingly. +- Whether the webview should expose a clickable effort dropdown in addition to `Ctrl+T` is a UX decision for Step 1.2+; current Step 1.1 only requires `Ctrl+T` cycle + compact display. diff --git a/openspec/changes/archive/2026-06-11-add-model-effort-toggle/proposal.md b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/proposal.md new file mode 100644 index 0000000..c7925d2 --- /dev/null +++ b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/proposal.md @@ -0,0 +1,73 @@ +## Why + +opencode-gui currently lets users choose a provider/model but does not expose opencode TUI model effort controls. For reasoning-capable models, effort controls intelligence, latency, and spend, so GUI users need parity with the TUI `Ctrl+T` workflow without silently changing opencode defaults. + +## What Changes + +- Add GUI support for model effort selection and cycling for reasoning-capable models. +- Add `Ctrl+T` handling in the VS Code webview input flow to cycle effort for the selected model, matching TUI muscle memory where webview focus allows it. +- Display the currently selected explicit effort near the selected model, while preserving an unset/default state when no effort has been chosen. +- Extend the UI-to-host and core agent contracts so explicit effort/variant can be forwarded to opencode on the **chat prompt** and **edit/resend prompt** flows. +- `executeShell` continues to work as today. The current opencode SDK 1.2.17 `client.session.shell(...)` body has no `variant` field (Discovery Findings §1), so shell sends do not include effort in this change. Shell is therefore a compatibility path: it must remain functional and must not break when an explicit effort is selected in the GUI. +- Validate effort when the selected model changes so invalid prior effort selections are not sent. +- Add tests for default behavior, cycling, model-change validation, and payload propagation. +- No breaking changes to existing provider/model selection, chat send, shell command, or config behavior. + +## Capabilities + +### New Capabilities +- `model-effort-control`: Users can view, cycle, validate, and send model effort/variant selections from the GUI while preserving opencode defaults unless an explicit effort is selected. + +### Modified Capabilities +- None. Existing specs do not cover model/provider selection or send-message behavior. + +## Impact + +- Affected core contracts: + - `packages/core/src/domain.ts` + - `packages/core/src/protocol.ts` +- Affected opencode agent integration: + - `packages/agents/opencode/src/opencode-agent.ts` + - related opencode-agent tests +- Affected VS Code webview UI and state: + - `packages/platforms/vscode/webview/App.tsx` + - `packages/platforms/vscode/webview/hooks/useProviders.ts` + - `packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx` + - `packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx` + - locale files if visible labels are added +- Affected VS Code extension host: + - `packages/platforms/vscode/src/chat-view-provider.ts` +- Test impact: + - model selection scenarios + - chat prompt and edit/resend scenarios + - shell command compatibility scenarios (shell must still work when effort is selected, without sending a variant) + - opencode-agent send tests + +## Non-Goals + +- Do not implement a full provider configuration editor. +- Do not persist effort into `opencode.json` unless opencode already does so through the existing API path. +- Do not invent unsupported provider-specific effort names when metadata is unavailable. +- Do not make disconnected providers/models selectable or alter provider connection behavior. +- Do not change backend model/provider persistence code beyond forwarding an explicit per-request effort/variant when available. + +## Risks + +- The opencode API may expose effort as model variants, request options, or provider-specific model metadata; implementation must verify the actual SDK/API shape before wiring payloads. +- VS Code or the browser may reserve `Ctrl+T` in some focus contexts; webview capture is expected for the textarea but may need an extension-side command/keybinding fallback. +- Provider effort labels differ (`none/minimal/low/medium/high/xhigh`, `low/medium/high/max`, Anthropic thinking budgets), so hardcoding OpenAI-only values would regress non-OpenAI providers. +- Accidentally sending a guessed effort would change user cost/intelligence defaults. + +## Fallback + +- If provider metadata does not expose valid efforts, keep effort unset and hide/disable effort cycling for that model. +- If `Ctrl+T` cannot be captured reliably in VS Code webviews, keep the visible effort selector clickable and add a VS Code command/keybinding bridge in a later scoped task. +- If opencode request forwarding does not accept effort directly, map explicit effort to the documented variant/model selection mechanism only after verifying the API surface. + +## Compatibility + +- Existing users with no explicit GUI effort selection MUST retain current opencode behavior. +- Existing persisted selected model state MUST remain readable. +- Existing `sendMessage`, `editAndResend`, `executeShell`, and model selection flows MUST continue to work when effort is absent. +- `executeShell` MUST continue to work when an explicit effort is selected; the GUI does not include effort in the shell payload in this change because the current `client.session.shell(...)` SDK shape does not support it. +- Existing tests for chat send, shell mode, model selection, and provider display MUST continue to pass. diff --git a/openspec/changes/archive/2026-06-11-add-model-effort-toggle/tasks.md b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/tasks.md new file mode 100644 index 0000000..985568e --- /dev/null +++ b/openspec/changes/archive/2026-06-11-add-model-effort-toggle/tasks.md @@ -0,0 +1,24 @@ +## 1. Discovery And Contract Shape + +- [X] 1.1 Inspect opencode provider metadata and SDK prompt request shape for effort/variant support; record findings in `design.md` Open Questions or a short implementation note; verify by running a focused script or test-safe logging command that does not expose secrets. +- [X] 1.2 Add core type support for optional explicit model effort/variant without changing default model payload semantics; verify with `pnpm --filter @opencodegui/core test` or the nearest available TypeScript/test command. +- [X] 1.3 Add normalized effort-choice helper(s) that derive valid efforts only from verified provider/model metadata; verify with unit tests for supported metadata, unsupported metadata, unset default, and model-change invalidation cases. + +## 2. Webview State And UI + +- [X] 2.1 Extend provider/model state so the GUI can track an optional explicit effort for the selected model and clear it when invalid for the newly selected model; verify with hook/component tests. +- [X] 2.2 Display explicit effort compactly next to the selected model label while showing no effort text when the state is unset; verify model selector tests still pass. +- [X] 2.3 Add `Ctrl+T` handling in the message input to cycle valid efforts only when supported; verify shortcut behavior with React Testing Library and ensure Enter, IME, popup navigation, and input history tests still pass. + +## 3. Protocol And Agent Forwarding + +- [X] 3.1 Extend webview-to-extension protocol messages for `sendMessage` and `editAndResend` to carry explicit effort only when selected; verify existing payload tests assert effort is omitted by default. `executeShell` is intentionally not extended in this change because the current `client.session.shell(...)` SDK shape does not support `variant`. +- [X] 3.2 Forward explicit effort through `chat-view-provider` into `IAgent` options for the chat and edit/resend prompt paths without altering calls where effort is absent; verify extension-host tests for send and edit/resend paths. +- [X] 3.3 Map explicit effort into the verified opencode `promptAsync` request shape in `opencode-agent` (top-level `variant: effort.id` sibling of `model`); verify opencode-agent tests cover default omission and explicit effort forwarding. +- [X] 3.4 Verify shell compatibility: `executeShell` continues to use only `{ providerID, modelID }`, does not include `variant`, and does not fail when an explicit effort is selected in the GUI. + +## 4. End-To-End Verification + +- [X] 4.1 Add or update scenario tests for default unset effort, `Ctrl+T` cycling, model-change invalidation, visible effort label, and no hardcoded effort for unsupported models; verify focused webview tests pass. +- [X] 4.2 Run repository verification for affected packages: focused tests, TypeScript/build command if available, and `pnpm test -- 06-model-selection` or closest convention; record any pre-existing unrelated failures. +- [X] 4.3 Build, package, and install the VSIX for manual verification; verify in VS Code that `Ctrl+T` cycles effort in the webview textarea, selected effort is visible, and prompts without explicit effort retain default behavior. diff --git a/openspec/specs/model-effort-control/spec.md b/openspec/specs/model-effort-control/spec.md new file mode 100644 index 0000000..85107f1 --- /dev/null +++ b/openspec/specs/model-effort-control/spec.md @@ -0,0 +1,104 @@ +# model-effort-control Specification + +## Purpose +TBD - created by archiving change add-model-effort-toggle. Update Purpose after archive. +## Requirements +### Requirement: Preserve default effort until explicitly selected +The GUI SHALL preserve opencode's default model effort behavior when the user has not explicitly selected an effort in the GUI. + +#### Scenario: Sending without explicit effort +- **WHEN** a user sends a chat message after selecting a model but before selecting or cycling effort +- **THEN** the GUI SHALL send the model without an explicit effort override +- **AND** opencode SHALL apply its server/config/default effort behavior + +#### Scenario: Existing selected model state remains valid +- **WHEN** persisted UI state contains only a provider/model selection from a prior GUI version +- **THEN** the GUI SHALL load the selected model without requiring effort data +- **AND** the next prompt SHALL not include an explicit effort override until the user chooses one + +### Requirement: Cycle valid effort choices with Ctrl+T +The GUI SHALL support cycling model effort from the message input with `Ctrl+T` when a selected model exposes valid effort choices. + +#### Scenario: Cycling effort for a supported model +- **WHEN** focus is in the message input and the selected model has multiple valid effort choices +- **AND** the user presses `Ctrl+T` +- **THEN** the GUI SHALL prevent the browser/default key behavior for that event +- **AND** select the next valid effort in the model's effort order +- **AND** display the selected effort near the selected model + +#### Scenario: Cycling from unset default +- **WHEN** the selected model has valid effort choices and the current GUI effort state is unset +- **AND** the user presses `Ctrl+T` +- **THEN** the GUI SHALL choose the first explicit effort in that model's valid effort order +- **AND** subsequent `Ctrl+T` presses SHALL continue cycling through the same valid effort order + +#### Scenario: Unsupported model +- **WHEN** focus is in the message input and the selected model does not expose valid effort choices +- **AND** the user presses `Ctrl+T` +- **THEN** the GUI SHALL not send any effort override +- **AND** existing text input and send behavior SHALL remain unchanged + +### Requirement: Validate effort when the selected model changes +The GUI SHALL validate the selected explicit effort against the currently selected model before displaying or sending it. + +#### Scenario: New model supports the same effort +- **WHEN** the user has selected an explicit effort +- **AND** changes to another model that supports the same effort identifier +- **THEN** the GUI MAY keep that explicit effort selected +- **AND** the next prompt SHALL include that effort override + +#### Scenario: New model does not support prior effort +- **WHEN** the user has selected an explicit effort +- **AND** changes to another model that does not support that effort identifier +- **THEN** the GUI SHALL clear the explicit effort selection +- **AND** the next prompt SHALL omit effort override unless the user chooses a valid effort for the new model + +### Requirement: Send explicit effort through GUI protocol +The GUI SHALL propagate explicit effort selections through the webview-to-extension and agent send contracts for chat prompts and edit/resend prompts. + +#### Scenario: Chat prompt with explicit effort +- **WHEN** a user sends a chat message with an explicit effort selected +- **THEN** the webview SHALL include that effort in the send message payload +- **AND** the extension host SHALL pass that effort to the opencode agent integration +- **AND** the opencode agent integration SHALL pass the effort to opencode using the verified API-compatible mechanism (top-level `variant` sibling of `model` on `client.session.promptAsync`) + +#### Scenario: Edit and resend preserves explicit effort behavior +- **WHEN** a user edits and resends a message while an explicit effort is selected +- **THEN** the resend path SHALL preserve the same explicit effort behavior as normal message send + +#### Scenario: Shell command with explicit effort selected +- **WHEN** a user executes a shell command while an explicit GUI effort is selected +- **THEN** the shell path SHALL preserve existing behavior +- **AND** SHALL not send an unsupported effort/variant to `client.session.shell(...)` +- **AND** SHALL not fail because effort is selected + +### Requirement: Discover effort choices from provider metadata +The GUI SHALL derive valid effort choices from opencode/provider model metadata rather than hardcoding a single provider's effort list. + +#### Scenario: Metadata exposes model variants +- **WHEN** provider model metadata exposes effort or variant choices +- **THEN** the GUI SHALL use those choices to populate the effort cycle for that model +- **AND** labels SHALL reflect the provider/model metadata where available + +#### Scenario: Metadata does not expose choices +- **WHEN** provider model metadata does not expose effort or variant choices +- **THEN** the GUI SHALL treat effort as unsupported for that model +- **AND** SHALL not guess effort values from provider or model names + +### Requirement: Keep existing model selector behavior +The model effort control SHALL not regress existing model selector behavior. + +#### Scenario: Model search still filters models +- **WHEN** a user searches in the model selector +- **THEN** existing connected-provider filtering and no-results behavior SHALL continue to work + +#### Scenario: Selecting a model still closes popover +- **WHEN** a user selects a model from the model selector +- **THEN** the GUI SHALL call the existing model select behavior +- **AND** close the popover as before + +#### Scenario: Disconnected providers remain disabled +- **WHEN** disconnected providers are shown through existing show-all behavior +- **THEN** their model items SHALL remain disabled +- **AND** effort controls SHALL not make disconnected models selectable + diff --git a/packages/agents/opencode/src/__tests__/opencode-agent.test.ts b/packages/agents/opencode/src/__tests__/opencode-agent.test.ts index 6e2b80c..17d4f69 100644 --- a/packages/agents/opencode/src/__tests__/opencode-agent.test.ts +++ b/packages/agents/opencode/src/__tests__/opencode-agent.test.ts @@ -309,6 +309,10 @@ describe("OpenCodeAgent", () => { model: undefined, agent: undefined, }); + // Default (no explicit effort) must NOT include a `variant` key + // so the opencode server applies its own default behavior. + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(call, "variant")).toBe(false); }); it("should send message with model via options", async () => { @@ -323,6 +327,58 @@ describe("OpenCodeAgent", () => { model, agent: undefined, }); + // Model set without explicit effort must NOT include a `variant` key. + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(call, "variant")).toBe(false); + }); + + it("should forward explicit effort as top-level variant sibling of model", async () => { + await agent.connect(); + const model = { providerID: "openai", modelID: "gpt-5.4" }; + const effort = { id: "low" }; + + await agent.sendMessage("sess-1", "Hello", { model, effort }); + + expect(mockClient.session.promptAsync).toHaveBeenCalledWith({ + sessionID: "sess-1", + parts: [{ type: "text", text: "Hello" }], + model, + agent: undefined, + variant: "low", + }); + // variant lives at the top level — never inside model + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(call.variant).toBe("low"); + expect((call.model as { variant?: unknown })?.variant).toBeUndefined(); + }); + + it("should drop variant when effort is null or has empty id", async () => { + await agent.connect(); + const model = { providerID: "openai", modelID: "gpt-5.4" }; + + await agent.sendMessage("sess-1", "Hello", { model, effort: { id: "" } }); + + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(call, "variant")).toBe(false); + }); + + it("should keep explicit effort alongside files/agent/skill parts", async () => { + await agent.connect(); + agent.workspaceFolder = "/ws"; + const model = { providerID: "openai", modelID: "gpt-5.4" }; + const effort = { id: "high" }; + const files = [{ filePath: "a.ts", fileName: "a.ts" }]; + + await agent.sendMessage("sess-1", "Review", { model, effort, files, agent: "reviewer", skill: "coding-guidelines" }); + + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(call.model).toEqual(model); + expect(call.variant).toBe("high"); + expect(call.parts).toHaveLength(4); + expect(call.parts[0]).toEqual({ type: "text", text: "/coding-guidelines", synthetic: true }); + expect(call.parts[1]).toEqual({ type: "text", text: "Review" }); + expect(call.parts[2].type).toBe("file"); + expect(call.parts[3]).toEqual({ type: "agent", name: "reviewer" }); }); it("should convert relative file paths to absolute using workspaceFolder", async () => { @@ -424,6 +480,9 @@ describe("OpenCodeAgent", () => { command: "ls -la", model, }); + // SDK 1.2.17 shell body has no `variant` — must stay absent. + const call = mockClient.session.shell.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(call, "variant")).toBe(false); }); it("should pass undefined model when not provided", async () => { @@ -437,6 +496,9 @@ describe("OpenCodeAgent", () => { command: "pwd", model: undefined, }); + // Defensive: no variant key on shell request, even when no model is set. + const call = mockClient.session.shell.mock.calls[0][0]; + expect(Object.prototype.hasOwnProperty.call(call, "variant")).toBe(false); }); }); diff --git a/packages/agents/opencode/src/opencode-agent.ts b/packages/agents/opencode/src/opencode-agent.ts index 9740369..1be59e2 100644 --- a/packages/agents/opencode/src/opencode-agent.ts +++ b/packages/agents/opencode/src/opencode-agent.ts @@ -272,11 +272,17 @@ export class OpenCodeAgent implements IAgent { parts.push({ type: "agent", name: options.agent }); } + // SDK 1.2.17 `client.session.promptAsync` exposes `variant?: string` as a + // top-level sibling of `model` (verified in design.md Discovery Findings §1). + // Omit the key entirely when no explicit effort is selected so the opencode + // server applies its own default rather than a GUI-injected override. + const effortId = options?.effort?.id; await client.session.promptAsync({ sessionID: sessionId, parts, model: options?.model, agent: options?.primaryAgent, + ...(effortId ? { variant: effortId } : {}), }); } diff --git a/packages/core/package.json b/packages/core/package.json index 0cdfd44..6b38d06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,9 +9,12 @@ "dist" ], "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:all": "vitest run" }, "devDependencies": { - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^4.0.18" } } diff --git a/packages/core/src/__tests__/model-effort.test.ts b/packages/core/src/__tests__/model-effort.test.ts new file mode 100644 index 0000000..1dd3391 --- /dev/null +++ b/packages/core/src/__tests__/model-effort.test.ts @@ -0,0 +1,289 @@ +/** + * model-effort.ts のユニットテスト。 + * + * 検証対象: + * 1. supported metadata -> provider/server 順の normalized ids + * 2. disabled variants はフィルタされる + * 3. label / name / title メタデータが label に反映される + * 4. unsupported metadata / reasoning-only model -> [] + * 5. unset default effort -> undefined / no override + * 6. model-change invalidation: 同 id は維持、未対応 id はクリア + */ +import { describe, expect, it } from "vitest"; +import type { ModelInfo, ModelVariantRef } from "../domain"; +import { + getModelVariants, + isModelVariantSupported, + validateModelVariant, +} from "../model-effort"; + +// ============================================================ +// Test fixtures +// ============================================================ + +function makeModel(overrides: Partial): ModelInfo { + return { + id: "test-model", + name: "Test Model", + limit: { context: 0, output: 0 }, + ...overrides, + }; +} + +// OpenAI gpt-5.4-style metadata, real shape from the SDK probe. +const gpt54: ModelInfo = makeModel({ + id: "openai/gpt-5.4", + name: "GPT-5.4", + reasoning: true, + variants: { + none: { reasoningEffort: "none", reasoningSummary: "auto" }, + low: { reasoningEffort: "low", reasoningSummary: "auto" }, + medium: { reasoningEffort: "medium", reasoningSummary: "auto" }, + high: { reasoningEffort: "high", reasoningSummary: "auto" }, + xhigh: { reasoningEffort: "xhigh", reasoningSummary: "auto" }, + }, +}); + +// DeepSeek deepseek-reasoner has reasoning: true but no variants. +const deepseekReasoner: ModelInfo = makeModel({ + id: "deepseek/deepseek-reasoner", + name: "DeepSeek Reasoner", + reasoning: true, +}); + +// DeepSeek deepseek-v4-pro uses [low, medium, high, max]. +const deepseekV4Pro: ModelInfo = makeModel({ + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek v4 Pro", + reasoning: true, + variants: { + low: { label: "Low" }, + medium: { name: "Medium" }, + high: { title: "High" }, + max: {}, + }, +}); + +// ============================================================ +// getModelVariants +// ============================================================ + +describe("getModelVariants", () => { + it("returns normalized ids in server-supplied order", () => { + const result = getModelVariants(gpt54); + expect(result.map((v) => v.id)).toEqual([ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + }); + + it("returns empty array for a reasoning-only model with no variants", () => { + expect(getModelVariants(deepseekReasoner)).toEqual([]); + }); + + it("returns empty array for a model with an empty variants map", () => { + const model = makeModel({ variants: {} }); + expect(getModelVariants(model)).toEqual([]); + }); + + it("returns empty array for null / undefined model", () => { + expect(getModelVariants(null)).toEqual([]); + expect(getModelVariants(undefined)).toEqual([]); + }); + + it("returns empty array when model has no variants field", () => { + const model = makeModel({}); + expect(getModelVariants(model)).toEqual([]); + }); + + it("returns empty array for defensive non-object variants values", () => { + // `as unknown as` covers invalid runtime shapes that could arrive from + // older SDKs or upstream bugs; the helper must not throw. + const modelNull = makeModel({ variants: null as unknown as never }); + const modelStr = makeModel({ variants: "oops" as unknown as never }); + const modelArr = makeModel({ variants: [] as unknown as never }); + expect(getModelVariants(modelNull)).toEqual([]); + expect(getModelVariants(modelStr)).toEqual([]); + expect(getModelVariants(modelArr)).toEqual([]); + }); + + it("filters out disabled variants", () => { + const model = makeModel({ + variants: { + low: { label: "Low" }, + medium: { label: "Medium", disabled: true }, + high: { label: "High", disabled: false }, + }, + }); + const result = getModelVariants(model); + expect(result.map((v) => v.id)).toEqual(["low", "high"]); + }); + + it("filters out non-object variant values defensively", () => { + const model = makeModel({ + variants: { + low: { label: "Low" }, + broken: "not-an-object" as unknown as never, + high: { label: "High" }, + }, + }); + const result = getModelVariants(model); + expect(result.map((v) => v.id)).toEqual(["low", "high"]); + }); + + it("reflects label / name / title metadata as the display label", () => { + const result = getModelVariants(deepseekV4Pro); + expect(result).toEqual([ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max" }, + ]); + }); + + it("keeps an id-only ref when no label / name / title is present", () => { + const model = makeModel({ + variants: { max: { reasoningEffort: "max" } }, + }); + const result = getModelVariants(model); + expect(result).toEqual([{ id: "max" }]); + expect(result[0]).not.toHaveProperty("label"); + }); + + it("ignores non-string label / name / title values", () => { + const model = makeModel({ + variants: { + bad: { label: 123, name: null, title: { nested: "x" } }, + ok: { label: "OK" }, + }, + }); + const result = getModelVariants(model); + expect(result).toEqual([{ id: "bad" }, { id: "ok", label: "OK" }]); + }); + + it("ignores empty-string label / name / title", () => { + const model = makeModel({ + variants: { ok: { label: "" } }, + }); + const result = getModelVariants(model); + expect(result).toEqual([{ id: "ok" }]); + }); + + it("prefers label, then name, then title when multiple are present", () => { + const model = makeModel({ + variants: { + a: { label: "L", name: "N", title: "T" }, + b: { name: "N", title: "T" }, + c: { title: "T" }, + }, + }); + const result = getModelVariants(model); + expect(result[0]).toEqual({ id: "a", label: "L" }); + expect(result[1]).toEqual({ id: "b", label: "N" }); + expect(result[2]).toEqual({ id: "c", label: "T" }); + }); +}); + +// ============================================================ +// isModelVariantSupported +// ============================================================ + +describe("isModelVariantSupported", () => { + it("returns true for unset effort (default / no override is always allowed)", () => { + expect(isModelVariantSupported(gpt54, undefined)).toBe(true); + expect(isModelVariantSupported(gpt54, null)).toBe(true); + }); + + it("returns true when the model advertises the effort id", () => { + expect(isModelVariantSupported(gpt54, { id: "medium" })).toBe(true); + }); + + it("returns false when the model does not advertise the effort id", () => { + expect(isModelVariantSupported(deepseekReasoner, { id: "low" })).toBe( + false, + ); + }); + + it("returns false when the model has a different variant set", () => { + // gpt-5.4 does not advertise "max" (DeepSeek-only). + expect(isModelVariantSupported(gpt54, { id: "max" })).toBe(false); + }); + + it("returns false for a disabled variant id", () => { + const model = makeModel({ + variants: { low: { disabled: true }, high: {} }, + }); + expect(isModelVariantSupported(model, { id: "low" })).toBe(false); + expect(isModelVariantSupported(model, { id: "high" })).toBe(true); + }); + + it("is conservative for a null / undefined model: cannot verify, so reject", () => { + // If the model is not yet available, we have no metadata to verify + // against. The helper takes the conservative "no metadata = no + // support" path, consistent with `validateModelVariant` clearing the + // prior effort in the same situation. Callers should not send a + // guessed effort; clearing is the spec-aligned default. + expect(isModelVariantSupported(null, { id: "low" })).toBe(false); + expect(isModelVariantSupported(undefined, { id: "low" })).toBe(false); + }); +}); + +// ============================================================ +// validateModelVariant (model-change invalidation) +// ============================================================ + +describe("validateModelVariant", () => { + it("returns undefined for unset effort (no override)", () => { + expect(validateModelVariant(gpt54, undefined)).toBeUndefined(); + // `null` is treated the same as `undefined`: it is a typecheck guard + // for callers that may pass `null` from a nullable store. + expect(validateModelVariant(gpt54, null as unknown as undefined)).toBeUndefined(); + }); + + it("keeps the same supported id when the model changes", () => { + const prior: ModelVariantRef = { id: "low" }; + const result = validateModelVariant(gpt54, prior); + expect(result).toBeDefined(); + expect(result?.id).toBe("low"); + }); + + it("returns a normalized ref with label when the model advertises it", () => { + const result = validateModelVariant(deepseekV4Pro, { id: "low" }); + expect(result).toEqual({ id: "low", label: "Low" }); + }); + + it("clears an unsupported prior id when the model changes", () => { + // "max" is DeepSeek-only; the new model is a reasoning-only one. + const result = validateModelVariant(deepseekReasoner, { id: "max" }); + expect(result).toBeUndefined(); + }); + + it("clears an unsupported prior id when the new model has a different set", () => { + // "low" exists on gpt-5.4 but DeepSeek-reasoner has no variants at all. + const result = validateModelVariant(deepseekReasoner, { id: "low" }); + expect(result).toBeUndefined(); + }); + + it("clears a prior id that became disabled after metadata refresh", () => { + const model = makeModel({ + variants: { low: { label: "Low", disabled: true } }, + }); + expect(validateModelVariant(model, { id: "low" })).toBeUndefined(); + }); + + it("returns a fresh object so callers cannot mutate the helper's internal list", () => { + const supported = getModelVariants(gpt54); + const first = supported[0]; + const result = validateModelVariant(gpt54, { id: first.id }); + expect(result).not.toBe(first); + expect(result).toEqual(first); + }); + + it("treats missing model as no supported ids (clears prior effort)", () => { + expect(validateModelVariant(null, { id: "low" })).toBeUndefined(); + expect(validateModelVariant(undefined, { id: "low" })).toBeUndefined(); + }); +}); diff --git a/packages/core/src/domain.ts b/packages/core/src/domain.ts index d825bb6..2bad227 100644 --- a/packages/core/src/domain.ts +++ b/packages/core/src/domain.ts @@ -307,6 +307,23 @@ export type ModelRef = { modelID: string; }; +/** + * Optional explicit model effort/variant selection. + * + * The id is provider-specific (e.g., "low", "medium", "high", "max", + * "minimal", "xhigh", "none") and is not a fixed union; it is read from + * server-provided model metadata (`ModelInfo.variants`). + * + * An unset (omitted) value means "use opencode default behavior" — payload + * producers must omit `variant` from the wire request in that case so the + * server applies its own default rather than the GUI's. + */ +export type ModelVariantRef = { + id: string; + label?: string; + disabled?: boolean; +}; + // ============================================================ // Permissions // ============================================================ @@ -418,6 +435,12 @@ export type ModelInfo = { status?: string; experimental?: boolean; options?: Record; + /** + * Opaque server-provided map of model variant ids to their config bag. + * The GUI treats values as opaque — it only needs the keys (variant ids) + * to populate effort choices and the disabled flag for filtering. + */ + variants?: Record>; }; export type AllProvidersData = { @@ -463,6 +486,12 @@ export type AppPaths = { export type SendMessageOptions = { model?: ModelRef; + /** + * Optional explicit effort/variant override. When omitted, payload + * producers must NOT include a `variant` field on the wire request so + * the opencode server applies its own default behavior. + */ + effort?: ModelVariantRef; files?: FileAttachment[]; agent?: string; primaryAgent?: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f69786..50fddcd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,5 +2,6 @@ export * from "./agent.interface"; export * from "./domain"; +export * from "./model-effort"; export * from "./platform.interface"; export * from "./protocol"; diff --git a/packages/core/src/model-effort.ts b/packages/core/src/model-effort.ts new file mode 100644 index 0000000..a6d39bb --- /dev/null +++ b/packages/core/src/model-effort.ts @@ -0,0 +1,108 @@ +/** + * @opencodegui/core - model effort helpers + * + * Normalized helpers that derive GUI-friendly effort/variant choices from + * server-provided model metadata. The helpers deliberately do NOT guess + * effort values from `reasoning: true`, provider id, or model id. An empty + * result means "effort is unsupported for this model" and the GUI must + * leave effort unset in that case. + * + * Companion types are defined alongside `ModelRef` in `./domain.ts`. + */ +import type { ModelInfo, ModelVariantRef } from "./domain"; + +/** + * Return the normalized, non-disabled variant refs that a model advertises + * through `ModelInfo.variants`. + * + * - Reads only `model.variants`; never infers from `reasoning`, provider + * id, or model id. + * - Preserves server-supplied variant order (JS object entry order for + * string keys) so the cycling order in the UI matches the server's + * declared order. + * - Filters out variants whose value has `disabled === true`, defensively, + * for SDK evolution. + * - May pick up a display `label` from a top-level string `label`, `name`, + * or `title` field on the variant value, but does not depend on + * provider-specific nested request options. + * - Returns an empty array for `undefined` / `null` / missing / non-object + * `variants`. Empty result means "effort is unsupported for this model". + */ +export function getModelVariants( + model?: ModelInfo | null, +): ModelVariantRef[] { + if (!model) return []; + const raw = model.variants; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + + const result: ModelVariantRef[] = []; + for (const [id, value] of Object.entries(raw)) { + if (!isPlainObject(value)) continue; + if (value.disabled === true) continue; + const label = pickDisplayLabel(value); + const ref: ModelVariantRef = label !== undefined ? { id, label } : { id }; + result.push(ref); + } + return result; +} + +/** + * Return true when the given effort is valid for the given model. + * + * - Unset effort (`undefined` / `null`) is always considered valid so the + * default "no override" payload is always allowed. + * - An effort whose `id` is not advertised by the model returns false, + * signaling that the prior selection should be cleared on model change. + */ +export function isModelVariantSupported( + model: ModelInfo | undefined | null, + effort: ModelVariantRef | undefined | null, +): boolean { + if (!effort) return true; + return getModelVariants(model).some((v) => v.id === effort.id); +} + +/** + * Validate the selected explicit effort against the currently selected + * model. Centralizes the model-change invalidation rules: + * + * - Unset effort (`undefined` / `null`) returns `undefined`. Callers must + * not invent a default. + * - When the model advertises the effort id, return a normalized ref + * derived from the model's metadata (id plus optional label). A fresh + * object is returned so callers cannot mutate the helper's internal + * list. + * - When the model does not advertise the effort id, return `undefined` + * so the caller clears the prior selection. + */ +export function validateModelVariant( + model: ModelInfo | undefined | null, + effort: ModelVariantRef | undefined | null, +): ModelVariantRef | undefined { + if (!effort) return undefined; + const supported = getModelVariants(model); + const match = supported.find((v) => v.id === effort.id); + return match ? { ...match } : undefined; +} + +// ============================================================ +// Internal helpers +// ============================================================ + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pickDisplayLabel( + value: Record, +): string | undefined { + for (const field of DISPLAY_LABEL_FIELDS) { + const candidate = value[field]; + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + return undefined; +} + +const DISPLAY_LABEL_FIELDS = ["label", "name", "title"] as const; diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts index cd80c6b..063251d 100644 --- a/packages/core/src/protocol.ts +++ b/packages/core/src/protocol.ts @@ -17,6 +17,7 @@ import type { FileAttachment, FileDiff, ModelRef, + ModelVariantRef, PermissionResponse, ProviderInfo, QuestionAnswer, @@ -57,6 +58,12 @@ export type UIToHostMessage = sessionId: string; text: string; model?: ModelRef; + /** + * Optional explicit effort/variant override. When omitted, the + * extension host MUST NOT include a `variant` key on the wire + * request so the opencode server applies its own default. + */ + effort?: ModelVariantRef; files?: FileAttachment[]; agent?: string; primaryAgent?: string; @@ -68,6 +75,12 @@ export type UIToHostMessage = messageId: string; text: string; model?: ModelRef; + /** + * Optional explicit effort/variant override, mirrored from + * `sendMessage` so the edit-and-resend path preserves the same + * explicit effort behavior as a normal send. + */ + effort?: ModelVariantRef; files?: FileAttachment[]; } | { type: "abort"; sessionId: string } @@ -78,6 +91,10 @@ export type UIToHostMessage = sessionId: string; command: string; model?: ModelRef; + // NOTE: effort is intentionally NOT carried on executeShell in + // this change. The opencode SDK 1.2.17 `client.session.shell(...)` + // body has no `variant` field, so shell sends continue to use + // { providerID, modelID } only. } // --- Permissions (via agent) --- diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..8696084 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/__tests__/**/*.test.ts"], + }, +}); diff --git a/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts b/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts index de2bd9a..2a87812 100644 --- a/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts +++ b/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts @@ -510,6 +510,61 @@ describe("ChatViewProvider", () => { skill: "coding-guidelines", }); }); + + it("should NOT include an effort property when message.effort is absent", async () => { + const { sendMessage } = setupProvider(mockAgent); + + await sendMessage({ + type: "sendMessage", + sessionId: "sess-1", + text: "Hello", + model: { providerID: "anthropic", modelID: "claude-4" }, + files: [], + }); + + // The third argument to sendMessage is the options object; effort must be absent (not undefined-keyed). + const options = (mockAgent.sendMessage as ReturnType).mock.calls[0][2] as Record< + string, + unknown + >; + expect(Object.prototype.hasOwnProperty.call(options, "effort")).toBe(false); + }); + + it("should forward explicit effort to agent.sendMessage options when present", async () => { + const { sendMessage } = setupProvider(mockAgent); + const effort = { id: "low", label: "Low" }; + + await sendMessage({ + type: "sendMessage", + sessionId: "sess-1", + text: "Hello", + model: { providerID: "anthropic", modelID: "claude-4" }, + files: [{ filePath: "a.ts", fileName: "a.ts" }], + agent: "reviewer", + primaryAgent: "build", + skill: "coding-guidelines", + effort, + }); + + expect(mockAgent.sendMessage).toHaveBeenCalledWith( + "sess-1", + "Hello", + expect.objectContaining({ + model: { providerID: "anthropic", modelID: "claude-4" }, + files: [{ filePath: "a.ts", fileName: "a.ts" }], + agent: "reviewer", + primaryAgent: "build", + skill: "coding-guidelines", + effort, + }), + ); + // Sanity: the effort object passed in is the exact same one forwarded. + const options = (mockAgent.sendMessage as ReturnType).mock.calls[0][2] as Record< + string, + unknown + >; + expect(options.effort).toEqual(effort); + }); }); // ============================================================ @@ -730,6 +785,65 @@ describe("ChatViewProvider", () => { files: [{ filePath: "a.ts", fileName: "a.ts" }], }); }); + + it("should NOT include an effort property in sendMessage options when message.effort is absent", async () => { + const session = { id: "sess-1" }; + mockAgent.revertSession.mockResolvedValue(session); + mockAgent.getMessages.mockResolvedValue([]); + + const { sendMessage } = setupProvider(mockAgent); + await sendMessage({ + type: "editAndResend", + sessionId: "sess-1", + messageId: "msg-3", + text: "Updated text", + model: { providerID: "openai", modelID: "gpt-4" }, + files: [{ filePath: "a.ts", fileName: "a.ts" }], + }); + + // The third argument to sendMessage is the options object; effort must be absent (not undefined-keyed). + const options = (mockAgent.sendMessage as ReturnType).mock.calls[0][2] as Record< + string, + unknown + >; + expect(Object.prototype.hasOwnProperty.call(options, "effort")).toBe(false); + }); + + it("should forward explicit effort to agent.sendMessage options when present", async () => { + const session = { id: "sess-1" }; + mockAgent.revertSession.mockResolvedValue(session); + mockAgent.getMessages.mockResolvedValue([]); + const effort = { id: "high", label: "High" }; + + const { sendMessage } = setupProvider(mockAgent); + await sendMessage({ + type: "editAndResend", + sessionId: "sess-1", + messageId: "msg-3", + text: "Updated text", + model: { providerID: "openai", modelID: "gpt-4" }, + files: [{ filePath: "a.ts", fileName: "a.ts" }], + effort, + }); + + // 1. revert still happens + expect(mockAgent.revertSession).toHaveBeenCalledWith("sess-1", "msg-3"); + // 2. sendMessage is called with effort forwarded in options + expect(mockAgent.sendMessage).toHaveBeenCalledWith( + "sess-1", + "Updated text", + expect.objectContaining({ + model: { providerID: "openai", modelID: "gpt-4" }, + files: [{ filePath: "a.ts", fileName: "a.ts" }], + effort, + }), + ); + const options = (mockAgent.sendMessage as ReturnType).mock.calls[0][2] as Record< + string, + unknown + >; + expect(options.effort).toEqual(effort); + }); }); // ============================================================ @@ -745,6 +859,19 @@ describe("ChatViewProvider", () => { expect(mockAgent.executeShell).toHaveBeenCalledWith("sess-1", "ls", model); }); + + it("should NOT forward effort or trigger the sendMessage path for executeShell", async () => { + const { sendMessage } = setupProvider(mockAgent); + const model = { providerID: "openai", modelID: "gpt-4" }; + + // Protocol does not carry effort for executeShell; the extension host must + // continue to use only (sessionId, command, model). + await sendMessage({ type: "executeShell", sessionId: "sess-1", command: "ls", model }); + + expect(mockAgent.executeShell).toHaveBeenCalledWith("sess-1", "ls", model); + // No third-arg options object should ever be created for executeShell. + expect(mockAgent.sendMessage).not.toHaveBeenCalled(); + }); }); // ============================================================ diff --git a/packages/platforms/vscode/src/chat-view-provider.ts b/packages/platforms/vscode/src/chat-view-provider.ts index 0dc4f21..697aa44 100644 --- a/packages/platforms/vscode/src/chat-view-provider.ts +++ b/packages/platforms/vscode/src/chat-view-provider.ts @@ -104,6 +104,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { agent: message.agent, primaryAgent: message.primaryAgent, skill: message.skill, + ...(message.effort !== undefined && { effort: message.effort }), }); break; } @@ -215,6 +216,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await this.agent.sendMessage(message.sessionId, message.text, { model: message.model, files: message.files, + ...(message.effort !== undefined && { effort: message.effort }), }); break; } diff --git a/packages/platforms/vscode/webview/App.tsx b/packages/platforms/vscode/webview/App.tsx index 1effc02..189b1a1 100644 --- a/packages/platforms/vscode/webview/App.tsx +++ b/packages/platforms/vscode/webview/App.tsx @@ -18,7 +18,7 @@ import { useQuestions } from "./hooks/useQuestions"; import { useSession } from "./hooks/useSession"; import { useSoundNotification } from "./hooks/useSoundNotification"; import { LocaleProvider } from "./locales"; -import type { FileAttachment, HostToUIMessage } from "./vscode-api"; +import type { FileAttachment, HostToUIMessage, UIToHostMessage } from "./vscode-api"; import { postMessage } from "./vscode-api"; // re-export for consumers that import from App.tsx @@ -244,7 +244,15 @@ export function App() { const handleSend = useCallback( (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string, skill?: string) => { if (!session.activeSession) return; - postMessage({ + // Build the explicit effort entry only when an effort has been + // selected by the user. Omitting the property entirely (rather + // than `effort: undefined`) preserves the default payload + // semantics: the extension host forwards no `variant` and the + // opencode server applies its own default behavior. The hook + // already normalizes `selectedModelEffort` from the model + // metadata, so we can pass it through directly. + type SendMessagePayload = Extract; + const payload: SendMessagePayload = { type: "sendMessage", sessionId: session.activeSession.id, text, @@ -253,9 +261,13 @@ export function App() { agent, primaryAgent, skill, - }); + }; + if (prov.selectedModelEffort) { + payload.effort = prov.selectedModelEffort; + } + postMessage(payload); }, - [session.activeSession, prov.selectedModel], + [session.activeSession, prov.selectedModel, prov.selectedModelEffort], ); // ! プレフィクスで入力されたシェルコマンドを session.shell API 経由で実行する @@ -285,31 +297,38 @@ export function App() { if (!session.activeSession) return; // messageId は編集対象のユーザーメッセージ。 // その直前のメッセージまで巻き戻し、編集後のテキストを送信する。 + // Explicit effort travels the same way as a normal send so the + // edit-and-resend path preserves the same explicit effort + // behavior. When effort is unset, the property is omitted so + // the host forwards no `variant` on the wire. The hook already + // normalizes `selectedModelEffort`, so we can pass it through. + type EditAndResendPayload = Extract; + const buildPayload = (targetMessageId: string): EditAndResendPayload => { + const payload: EditAndResendPayload = { + type: "editAndResend", + sessionId: session.activeSession!.id, + messageId: targetMessageId, + text, + model: prov.selectedModel ?? undefined, + }; + if (prov.selectedModelEffort) { + payload.effort = prov.selectedModelEffort; + } + return payload; + }; const msgIndex = msg.messages.findIndex((m) => m.info.id === messageId); if (msgIndex < 0) return; if (msgIndex === 0) { // 最初のメッセージの場合: 新規セッションを作成して送信する方がクリーン // ただし revert API のフォールバックとして、messageId 自体で revert - postMessage({ - type: "editAndResend", - sessionId: session.activeSession.id, - messageId, - text, - model: prov.selectedModel ?? undefined, - }); + postMessage(buildPayload(messageId)); } else { // 直前のメッセージまで巻き戻して再送信 const prevMessageId = msg.messages[msgIndex - 1].info.id; - postMessage({ - type: "editAndResend", - sessionId: session.activeSession.id, - messageId: prevMessageId, - text, - model: prov.selectedModel ?? undefined, - }); + postMessage(buildPayload(prevMessageId)); } }, - [session.activeSession, msg.messages, prov.selectedModel], + [session.activeSession, msg.messages, prov.selectedModel, prov.selectedModelEffort], ); // チェックポイントまで巻き戻す + ユーザーメッセージのテキストを入力欄に復元 @@ -530,6 +549,8 @@ export function App() { allProvidersData={prov.allProvidersData} selectedModel={prov.selectedModel} onModelSelect={prov.handleModelSelect} + selectedModelEffort={prov.selectedModelEffort} + onModelEffortSelect={prov.setSelectedModelEffort} selectedPrimaryAgent={selectedPrimaryAgent} onPrimaryAgentSelect={setSelectedPrimaryAgent} openEditors={openEditors} diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/ModelSelector.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/ModelSelector.test.tsx index 2c6085a..0f17f22 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/ModelSelector.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/ModelSelector.test.tsx @@ -30,6 +30,53 @@ describe("ModelSelector", () => { }); }); + // effort display + // task 2.2: "Display explicit effort compactly next to the selected model + // label while showing no effort text when the state is unset". + context("explicit effort が指定された場合", () => { + // explicit effort with a label is rendered with the separator + it("effort label がモデル名の隣に区切り文字付きで表示されること", () => { + const { container } = render( + , + ); + const label = container.querySelector(".label"); + expect(label).toBeInTheDocument(); + // 区切り文字と effort ラベルが表示される + expect(container.querySelector(".separator")).toBeInTheDocument(); + expect(container.querySelector(".effort")?.textContent).toBe("Low"); + // 中黒で区切られて結合される (visual spacing は CSS margin で確保) + expect(label?.textContent).toBe("GPT-4·Low"); + }); + + // explicit effort without a label falls back to the id + it("label が無い場合は id が表示されること", () => { + const { container } = render( + , + ); + expect(container.querySelector(".effort")?.textContent).toBe("minimal"); + expect(container.querySelector(".label")?.textContent).toBe("GPT-4·minimal"); + }); + }); + + // when effort is unset — must not show any effort text or separator + // "Preserve default effort until explicitly selected" requirement. + context("effort が未設定の場合", () => { + it("effort テキストや区切り文字が表示されないこと", () => { + const { container } = render(); + expect(container.querySelector(".separator")).toBeNull(); + expect(container.querySelector(".effort")).toBeNull(); + // モデル名だけが表示される + expect(container.querySelector(".modelName")?.textContent).toBe("GPT-4"); + }); + + it("selectedModelEffort が undefined でも同様であること", () => { + const { container } = render(); + expect(container.querySelector(".separator")).toBeNull(); + expect(container.querySelector(".effort")).toBeNull(); + expect(container.querySelector(".label")?.textContent).toBe("GPT-4"); + }); + }); + // when button is clicked context("ボタンをクリックした場合", () => { // opens the model panel @@ -59,5 +106,20 @@ describe("ModelSelector", () => { const { container } = render(); expect(container.querySelector(".label")?.textContent).toBeTruthy(); }); + + // effort must not leak into the placeholder even when set + it("effort テキストや区切り文字が表示されないこと", () => { + const { container } = render( + , + ); + expect(container.querySelector(".separator")).toBeNull(); + expect(container.querySelector(".effort")).toBeNull(); + // プレースホルダーのみ表示される + expect(container.querySelector(".modelName")?.textContent?.trim()).toBeTruthy(); + }); }); }); diff --git a/packages/platforms/vscode/webview/__tests__/hooks/useProviders.test.ts b/packages/platforms/vscode/webview/__tests__/hooks/useProviders.test.ts index 09daabb..5992961 100644 --- a/packages/platforms/vscode/webview/__tests__/hooks/useProviders.test.ts +++ b/packages/platforms/vscode/webview/__tests__/hooks/useProviders.test.ts @@ -1,8 +1,97 @@ +import type { AllProvidersData, ModelInfo, ProviderInfo } from "@opencodegui/core"; import { act, renderHook } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { useProviders } from "../../hooks/useProviders"; import { postMessage } from "../../vscode-api"; +// ============================================================ +// Test fixtures (synthetic providers / models with variants) +// ============================================================ +// +// Per task 2.1: "Use synthetic providers/allProvidersData with +// `variants` maps; do not hardcode effort lists except in test +// fixtures as metadata keys." + +// OpenAI gpt-5.4 style: full {low, medium, high} variant set. +const gpt54: ModelInfo = { + id: "openai/gpt-5.4", + name: "GPT-5.4", + limit: { context: 0, output: 0 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, +}; + +// OpenAI gpt-5.4-mini: subset of gpt-5.4's variants but still +// shares the "low" id, so a switch from gpt-5.4 must keep it. +const gpt54Mini: ModelInfo = { + id: "openai/gpt-5.4-mini", + name: "GPT-5.4 Mini", + limit: { context: 0, output: 0 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + }, +}; + +// Anthropic claude-opus: different variant set entirely. +const claudeOpus: ModelInfo = { + id: "anthropic/claude-opus", + name: "Claude Opus", + limit: { context: 0, output: 0 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + // no "high" — switching to this with prior "high" effort must clear. + }, +}; + +// DeepSeek deepseek-reasoner: reasoning-only model with no variants. +// Mirrors the live probe finding (deepseek-reasoner has no variants). +const deepseekReasoner: ModelInfo = { + id: "deepseek/deepseek-reasoner", + name: "DeepSeek Reasoner", + reasoning: true, + limit: { context: 0, output: 0 }, + // intentionally no variants +}; + +const openaiProvider: ProviderInfo = { + id: "openai", + name: "OpenAI", + env: [], + models: { + "gpt-5.4": gpt54, + "gpt-5.4-mini": gpt54Mini, + }, +}; + +const anthropicProvider: ProviderInfo = { + id: "anthropic", + name: "Anthropic", + env: [], + models: { + "claude-opus": claudeOpus, + }, +}; + +const deepseekProvider: ProviderInfo = { + id: "deepseek", + name: "DeepSeek", + env: [], + models: { + "deepseek-reasoner": deepseekReasoner, + }, +}; + +const allProvidersDataFixture: AllProvidersData = { + all: [openaiProvider, anthropicProvider, deepseekProvider], + default: {}, + connected: ["openai", "anthropic", "deepseek"], +}; + describe("useProviders", () => { // initial state context("初期状態の場合", () => { @@ -23,6 +112,13 @@ describe("useProviders", () => { const { result } = renderHook(() => useProviders()); expect(result.current.allProvidersData).toBeNull(); }); + + // 1. initial selected effort is unset + // - "Preserve default effort until explicitly selected" requirement + it("selectedModelEffort が undefined であること", () => { + const { result } = renderHook(() => useProviders()); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); }); // handleModelSelect @@ -69,4 +165,201 @@ describe("useProviders", () => { expect(result.current.selectedModel).toEqual({ providerID: "openai", modelID: "gpt-4o" }); }); }); + + // effort state — initial + explicit selection + context("effort state の場合", () => { + // 2. setting/selecting an explicit supported effort stores normalized state + context("サポートされた effort を setSelectedModelEffort で設定した場合", () => { + it("label を含む normalized な state を保存すること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "low" })); + expect(result.current.selectedModelEffort).toEqual({ id: "low", label: "Low" }); + }); + + it("label が存在しない variant は id のみを保存すること", () => { + const { result } = renderHook(() => useProviders()); + // Use a model where one variant has no label/name/title. + const bareModel: ModelInfo = { + id: "openai/gpt-bare", + name: "Bare", + limit: { context: 0, output: 0 }, + variants: { minimal: { reasoningEffort: "minimal" } }, + }; + const data: AllProvidersData = { + all: [{ id: "openai", name: "OpenAI", env: [], models: { "gpt-bare": bareModel } }], + default: {}, + connected: ["openai"], + }; + act(() => result.current.setAllProvidersData(data)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-bare" })); + act(() => result.current.setSelectedModelEffort({ id: "minimal" })); + expect(result.current.selectedModelEffort).toEqual({ id: "minimal" }); + }); + + it("モデルが未選択の場合、effort は保存されず undefined のままとなること", () => { + // Spec: do not guess effort when no model is selected. + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + // Note: no setSelectedModel call. + act(() => result.current.setSelectedModelEffort({ id: "low" })); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + + it("undefined を渡して effort をクリアできること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "low" })); + act(() => result.current.setSelectedModelEffort(undefined)); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + }); + }); + + // effort invalidation on model change + context("選択モデルを変更した場合", () => { + // 3. switching to a model that supports the same effort keeps it + it("同じ effort id をサポートするモデルへ切り替えると effort が維持されること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "low" })); + expect(result.current.selectedModelEffort).toEqual({ id: "low", label: "Low" }); + + // gpt-5.4-mini also supports "low"; effort must be preserved. + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4-mini" })); + expect(result.current.selectedModel).toEqual({ providerID: "openai", modelID: "gpt-5.4-mini" }); + expect(result.current.selectedModelEffort).toBeDefined(); + expect(result.current.selectedModelEffort?.id).toBe("low"); + }); + + // 4. switching to a model that does not support the prior effort clears it + it("prior effort をサポートしないモデルへ切り替えると effort がクリアされること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "high" })); + expect(result.current.selectedModelEffort?.id).toBe("high"); + + // anthropic/claude-opus has no "high" variant — effort must be cleared. + act(() => result.current.setSelectedModel({ providerID: "anthropic", modelID: "claude-opus" })); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + + // 5. switching to a reasoning-only/no-variants model clears it and does not guess + it("variants がない reasoning-only モデルへ切り替えると effort がクリアされ、推測もしないこと", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "low" })); + expect(result.current.selectedModelEffort?.id).toBe("low"); + + // deepseek-reasoner: reasoning: true but no variants map. + // The GUI must clear, not invent an effort value. + act(() => result.current.setSelectedModel({ providerID: "deepseek", modelID: "deepseek-reasoner" })); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + + // 7a. direct setSelectedModel value-form invalidates effort + it("setSelectedModel を値形式で呼んだ場合も effort が invalidate されること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "high" })); + + // Direct value-form path (App.tsx pattern). + act(() => result.current.setSelectedModel({ providerID: "anthropic", modelID: "claude-opus" })); + expect(result.current.selectedModel).toEqual({ providerID: "anthropic", modelID: "claude-opus" }); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + + // 7b. direct setSelectedModel function-form invalidates effort + it("setSelectedModel を関数形式で呼んだ場合も effort が invalidate されること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "high" })); + + // Direct function-form path (App.tsx pattern in the "providers" handler). + act(() => + result.current.setSelectedModel(() => ({ + providerID: "anthropic", + modelID: "claude-opus", + })), + ); + expect(result.current.selectedModel).toEqual({ providerID: "anthropic", modelID: "claude-opus" }); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + + // 6. existing selected model behavior and setModel postMessage still works + context("既存挙動 (handleModelSelect 経由)", () => { + it("setModel postMessage はそのまま送信されること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.handleModelSelect({ providerID: "openai", modelID: "gpt-5.4" })); + expect(postMessage).toHaveBeenCalledWith({ type: "setModel", model: "openai/gpt-5.4" }); + }); + + it("handleModelSelect 後のモデル変更で effort が invalidate されること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.handleModelSelect({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "high" })); + // Switch to a model without "high" via handleModelSelect. + act(() => result.current.handleModelSelect({ providerID: "deepseek", modelID: "deepseek-reasoner" })); + expect(result.current.selectedModel).toEqual({ providerID: "deepseek", modelID: "deepseek-reasoner" }); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + }); + }); + + // effort revalidation on metadata change + context("プロバイダメタデータが更新された場合", () => { + it("サポートされている effort は維持されること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "low" })); + expect(result.current.selectedModelEffort).toEqual({ id: "low", label: "Low" }); + + // Refresh allProvidersData with a new reference but identical content. + act(() => result.current.setAllProvidersData({ ...allProvidersDataFixture })); + expect(result.current.selectedModelEffort).toEqual({ id: "low", label: "Low" }); + }); + + it("新たに unsupported (disabled) になった effort はクリアされること", () => { + const { result } = renderHook(() => useProviders()); + act(() => result.current.setAllProvidersData(allProvidersDataFixture)); + act(() => result.current.setSelectedModel({ providerID: "openai", modelID: "gpt-5.4" })); + act(() => result.current.setSelectedModelEffort({ id: "low" })); + expect(result.current.selectedModelEffort?.id).toBe("low"); + + // Simulate metadata refresh: "low" becomes disabled on this model. + const refreshed: AllProvidersData = { + ...allProvidersDataFixture, + all: allProvidersDataFixture.all.map((p) => + p.id === "openai" + ? { + ...p, + models: { + ...p.models, + "gpt-5.4": { + ...gpt54, + variants: { + low: { label: "Low", disabled: true }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }, + } + : p, + ), + }; + act(() => result.current.setAllProvidersData(refreshed)); + expect(result.current.selectedModelEffort).toBeUndefined(); + }); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/03-messaging.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/03-messaging.test.tsx index d4fdd59..89df41d 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/03-messaging.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/03-messaging.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage } from "../../vscode-api"; @@ -318,4 +318,149 @@ describe("メッセージング", () => { }), ); }); + + // task 3.1: default sendMessage payload must NOT include `effort` + // when the user has not selected an explicit effort, even when the + // selected model advertises variants. Preserves the "default effort + // until explicitly selected" requirement. + it("effort 未選択時の sendMessage には effort プロパティが含まれないこと", async () => { + renderApp(); + + // モデルに variants を持たせる(effort サイクル対応モデル)。 + const { createAllProvidersData, createProvider } = await import("../factories"); + const provider = createProvider("openai", { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }); + await sendExtMessage({ + type: "providers", + providers: [provider], + allProviders: createAllProvidersData( + ["openai"], + [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }, + }, + ], + ), + default: { general: "openai/gpt-5.4" }, + configModel: "openai/gpt-5.4", + }); + + const session = createSession({ id: "s1" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Hello{Enter}"); + + const calls = vi.mocked(postMessage).mock.calls; + const sendCall = calls.find((c) => (c[0] as { type?: string })?.type === "sendMessage"); + expect(sendCall, "sendMessage must have been called").toBeDefined(); + // effort プロパティは payload 自体に存在しない(undefined でもない)。 + expect("effort" in (sendCall![0] as object)).toBe(false); + // model は通常通り含まれる。 + expect(sendCall![0]).toEqual( + expect.objectContaining({ + type: "sendMessage", + sessionId: "s1", + text: "Hello", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }), + ); + }); + + // task 3.1: when an explicit effort is selected, sendMessage payload + // must include a normalized `effort: ModelVariantRef` entry derived + // from the model metadata. Use Ctrl+T to make a real selection + // through the existing cycle handler so we exercise the production + // path (no direct state poking). + it("effort 選択後の sendMessage には正規化された effort が含まれること", async () => { + renderApp(); + + const { createAllProvidersData, createProvider } = await import("../factories"); + const provider = createProvider("openai", { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }); + await sendExtMessage({ + type: "providers", + providers: [provider], + allProviders: createAllProvidersData( + ["openai"], + [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }, + }, + ], + ), + default: { general: "openai/gpt-5.4" }, + configModel: "openai/gpt-5.4", + }); + + const session = createSession({ id: "s1" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + // Ctrl+T で最初の variant ("low") を選択する + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + + // 選択後に送信 + const user = userEvent.setup(); + await user.type(textarea, "Hello{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + sessionId: "s1", + text: "Hello", + model: { providerID: "openai", modelID: "gpt-5.4" }, + effort: { id: "low", label: "Low" }, + }), + ); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/04-message-editing.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/04-message-editing.test.tsx index ee39c01..7894487 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/04-message-editing.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/04-message-editing.test.tsx @@ -1,8 +1,8 @@ -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage } from "../../vscode-api"; -import { createMessage, createSession, createTextPart } from "../factories"; +import { createAllProvidersData, createMessage, createProvider, createSession, createTextPart } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; /** ユーザー→アシスタント→ユーザーの3メッセージ構成をセットアップする */ @@ -195,4 +195,177 @@ describe("メッセージ編集とチェックポイント", () => { // ファイル名がチップとして表示される expect(screen.getByText("main.ts")).toBeInTheDocument(); }); + + // task 3.1: editAndResend payload must NOT include `effort` when + // the user has not selected an explicit effort, mirroring the + // default sendMessage behavior. Preserves the "preserve default + // effort until explicitly selected" requirement on the edit path. + it("effort 未選択時の editAndResend には effort プロパティが含まれないこと", async () => { + // variants を持つモデル + アクティブセッションを構築する。 + // useProviders はプロバイダーメタデータから effort 選択肢を + // 解決するが、未選択状態では payload に effort は載らない。 + renderApp(); + const provider = createProvider("openai", { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }); + await sendExtMessage({ + type: "providers", + providers: [provider], + allProviders: createAllProvidersData( + ["openai"], + [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }, + }, + ], + ), + default: { general: "openai/gpt-5.4" }, + configModel: "openai/gpt-5.4", + }); + + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg1 = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart1 = createTextPart("First question", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("First answer", { messageID: "m2" }); + const userMsg2 = createMessage({ id: "m3", sessionID: "s1", role: "user" }); + const userPart2 = createTextPart("Second question", { messageID: "m3" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg1, parts: [userPart1] }, + { info: assistantMsg, parts: [assistantPart] }, + { info: userMsg2, parts: [userPart2] }, + ], + }); + vi.mocked(postMessage).mockClear(); + + // 編集送信(effort 未選択) + const user = userEvent.setup(); + await user.click(screen.getByText("Second question")); + const editTextarea = screen.getByDisplayValue("Second question"); + await user.clear(editTextarea); + await user.type(editTextarea, "Revised{Enter}"); + + const calls = vi.mocked(postMessage).mock.calls; + const editCall = calls.find((c) => (c[0] as { type?: string })?.type === "editAndResend"); + expect(editCall, "editAndResend must have been called").toBeDefined(); + // effort プロパティは payload 自体に存在しない。 + expect("effort" in (editCall![0] as object)).toBe(false); + }); + + // task 3.1: when an explicit effort is selected, editAndResend + // payload must include the same normalized `effort` entry that + // sendMessage would carry. Confirms the edit-and-resend path + // preserves the explicit effort behavior required by the spec. + it("effort 選択後の editAndResend には正規化された effort が含まれること", async () => { + renderApp(); + const provider = createProvider("openai", { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }); + await sendExtMessage({ + type: "providers", + providers: [provider], + allProviders: createAllProvidersData( + ["openai"], + [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }, + }, + ], + ), + default: { general: "openai/gpt-5.4" }, + configModel: "openai/gpt-5.4", + }); + + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg1 = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart1 = createTextPart("First question", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("First answer", { messageID: "m2" }); + const userMsg2 = createMessage({ id: "m3", sessionID: "s1", role: "user" }); + const userPart2 = createTextPart("Second question", { messageID: "m3" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg1, parts: [userPart1] }, + { info: assistantMsg, parts: [assistantPart] }, + { info: userMsg2, parts: [userPart2] }, + ], + }); + vi.mocked(postMessage).mockClear(); + + // Ctrl+T で effort "low" を選択 + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + + // 編集送信(effort 選択済み) + const user = userEvent.setup(); + await user.click(screen.getByText("Second question")); + const editTextarea = screen.getByDisplayValue("Second question"); + await user.clear(editTextarea); + await user.type(editTextarea, "Revised{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "editAndResend", + sessionId: "s1", + messageId: "m2", + text: "Revised", + effort: { id: "low", label: "Low" }, + }), + ); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/14-shell-command.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/14-shell-command.test.tsx index 92fe480..3b62584 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/14-shell-command.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/14-shell-command.test.tsx @@ -1,4 +1,4 @@ -import { screen, within } from "@testing-library/react"; +import { fireEvent, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage } from "../../vscode-api"; @@ -337,4 +337,81 @@ describe("シェルコマンド実行", () => { expect(spinner).toBeInTheDocument(); }); }); + + // task 3.1: executeShell is intentionally NOT extended with + // `effort` in this change. The opencode SDK 1.2.17 + // `client.session.shell(...)` body has no `variant` field, so the + // shell payload must remain `{ sessionId, command, model }` only, + // even when an explicit effort is selected in the GUI. This guards + // both the default (no effort) and explicit-effort code paths. + it("effort 選択時でも executeShell には effort プロパティが含まれないこと", async () => { + renderApp(); + + const provider = createProvider("openai", { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }); + await sendExtMessage({ + type: "providers", + providers: [provider], + allProviders: createAllProvidersData( + ["openai"], + [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + }, + }, + ], + ), + default: { general: "openai/gpt-5.4" }, + configModel: "openai/gpt-5.4", + }); + + const session = createSession({ id: "s1" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + // Ctrl+T で effort を選択する + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + + // ! プレフィクスでシェルコマンドを送信 + const user = userEvent.setup(); + await user.type(textarea, "!git status{Enter}"); + + const calls = vi.mocked(postMessage).mock.calls; + const shellCall = calls.find((c) => (c[0] as { type?: string })?.type === "executeShell"); + expect(shellCall, "executeShell must have been called").toBeDefined(); + // effort プロパティは payload 自体に存在しない。 + expect("effort" in (shellCall![0] as object)).toBe(false); + // 既存のフィールドは維持される。 + expect(shellCall![0]).toEqual( + expect.objectContaining({ + type: "executeShell", + sessionId: "s1", + command: "git status", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }), + ); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/26-effort-cycle.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/26-effort-cycle.test.tsx new file mode 100644 index 0000000..d4417a9 --- /dev/null +++ b/packages/platforms/vscode/webview/__tests__/scenarios/26-effort-cycle.test.tsx @@ -0,0 +1,421 @@ +/** + * Ctrl+T effort cycling scenarios. + * + * task 2.3: Add `Ctrl+T` handling in the message input to cycle valid + * efforts only when supported; verify shortcut behavior with React + * Testing Library and ensure Enter, IME, popup navigation, and input + * history tests still pass. + * + * Each scenario uses a synthetic `variants` map; no hardcoded provider + * effort lists leak into production code. `defaultPrevented` and the + * rendered `ModelSelector` label (`.effort`, `.separator`, `.modelName` + * CSS-module classes) are the canonical observables for the cycle + * action. Send/edit payloads are intentionally NOT inspected here — + * that lives in task 3.1+. + */ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InputArea } from "../../components/organisms/InputArea/InputArea"; +import { createAllProvidersData, createProvider, createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +// ============================================================ +// Synthetic provider fixtures +// ============================================================ +// +// The GUI only reads `variants` keys; nothing about provider names or +// model ids is hardcoded in production code. The "reasoning-only" +// fixture is the same shape as the live-probe `deepseek-reasoner` +// (reasoning: true, no variants) and must remain unsupported for +// cycling per the discovery findings. + +const openaiAllProvider = { + id: "openai", + name: "OpenAI", + env: [], + models: { + // Full {low, medium, high} cycle. + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + // Single-variant set: a cycle is still allowed and wraps to the + // first/only entry. + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + limit: { context: 128000, output: 4096 }, + variants: { + minimal: { label: "Minimal" }, + }, + }, + // Reasoning-only, no variants: MUST NOT cycle, MUST NOT show + // any effort text. Mirrors the deepseek-reasoner fixture. + "reasoner": { + id: "reasoner", + name: "Reasoner", + reasoning: true, + limit: { context: 128000, output: 4096 }, + }, + }, +}; + +// Fallback `providers` list carries the same metadata; the cycle +// resolver falls back to this when allProvidersData is missing the +// model (defensive path). +const openaiConnectedProvider = createProvider("openai", { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + limit: { context: 128000, output: 4096 }, + variants: { + low: { label: "Low" }, + medium: { label: "Medium" }, + high: { label: "High" }, + }, + }, + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + limit: { context: 128000, output: 4096 }, + variants: { + minimal: { label: "Minimal" }, + }, + }, + "reasoner": { + id: "reasoner", + name: "Reasoner", + reasoning: true, + limit: { context: 128000, output: 4096 }, + }, +}); + +const deepseekConnectedProvider = createProvider("deepseek", { + "deepseek-reasoner": { + id: "deepseek-reasoner", + name: "DeepSeek Reasoner", + reasoning: true, + limit: { context: 128000, output: 4096 }, + }, +}); + +const deepseekAllProvider = { + id: "deepseek", + name: "DeepSeek", + env: [], + models: { + "deepseek-reasoner": { + id: "deepseek-reasoner", + name: "DeepSeek Reasoner", + reasoning: true, + limit: { context: 128000, output: 4096 }, + }, + }, +}; + +// ============================================================ +// Setup helpers +// ============================================================ + +/** + * Standard setup: render the app, load providers + allProvidersData + * (with variants), and activate a session so the InputArea is + * visible. Returns the textarea handle. + */ +async function setupWithVariants( + defaultModel = "openai/gpt-5.4", + providers = [openaiConnectedProvider], + allProvidersRoot = openaiAllProvider, +) { + renderApp(); + await sendExtMessage({ + type: "providers", + providers, + allProviders: createAllProvidersData( + providers.map((p) => p.id), + [allProvidersRoot as any], + ), + default: { general: defaultModel }, + configModel: defaultModel, + }); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + return textarea; +} + +/** + * Returns the rendered text content of the ModelSelector button + * (the `.label` wrapper in ModelSelector.module.css). This is + * how the GUI exposes the cycled effort to the user. + */ +function getModelButtonText(): string { + const label = document.querySelector(".label"); + return label?.textContent ?? ""; +} + +/** + * Helper to build minimal but complete props for an isolated + * `InputArea` render — used by the "no setter wired" scenarios. + */ +function makeBareProps(overrides: Record = {}) { + return { + onSend: vi.fn(), + onShellExecute: vi.fn(), + onAbort: vi.fn(), + isBusy: false, + providers: [openaiConnectedProvider], + allProvidersData: createAllProvidersData(["openai"], [openaiAllProvider as any]), + selectedModel: { providerID: "openai", modelID: "gpt-5.4" }, + onModelSelect: vi.fn(), + selectedPrimaryAgent: null, + onPrimaryAgentSelect: vi.fn(), + openEditors: [], + activeEditorFile: null, + workspaceFiles: [], + prefillText: "", + onPrefillConsumed: vi.fn(), + openCodePaths: null, + onOpenConfigFile: vi.fn(), + onOpenTerminal: vi.fn(), + localeSetting: "auto" as any, + onLocaleSettingChange: vi.fn(), + soundSettings: {} as any, + onSoundSettingChange: vi.fn(), + agents: [], + skills: [], + ...overrides, + }; +} + +// ============================================================ +// Ctrl+T effort cycling +// ============================================================ + +describe("Ctrl+T による effort サイクル", () => { + // Reset all mocks between scenarios so sendMessage counts etc. are + // isolated, even though Ctrl+T itself never sends a protocol + // message (cycle is purely local state). + beforeEach(() => { + vi.clearAllMocks(); + }); + + // 1. cycling from unset picks the first explicit effort + context("effort 未選択 + 対応モデルで Ctrl+T を押した場合", () => { + it("最初の variant が選択されモデル名横に表示されること", async () => { + const textarea = await setupWithVariants(); + + // Baseline: no effort text or separator visible. + expect(document.querySelector(".effort")).toBeNull(); + expect(document.querySelector(".separator")).toBeNull(); + + // Press Ctrl+T. + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + + // First effort is "low" / "Low". + expect(document.querySelector(".effort")?.textContent).toBe("Low"); + expect(document.querySelector(".separator")).toBeInTheDocument(); + // The button contains both the model name and the effort label. + expect(getModelButtonText()).toContain("GPT-5.4"); + expect(getModelButtonText()).toContain("Low"); + }); + + it("default が preventDefault され、既存のテキスト入力が影響を受けないこと", async () => { + const textarea = await setupWithVariants(); + + // type some text first so we can detect "didn't clobber" afterwards. + const user = (await import("@testing-library/user-event")).default.setup(); + await user.type(textarea, "draft"); + + const event = new KeyboardEvent("keydown", { + key: "t", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + // Wrap in act: the keydown handler triggers a React state + // update in useProviders (setSelectedModelEffort), which would + // otherwise emit an "act()" warning. + act(() => { + textarea.dispatchEvent(event); + }); + + // Cycle happened, default prevented. + expect(event.defaultPrevented).toBe(true); + // Existing draft text is untouched. + expect(textarea).toHaveValue("draft"); + }); + }); + + // 2. subsequent Ctrl+T cycles to next effort and wraps + context("2 回目以降の Ctrl+T", () => { + it("次の variant へ進み、最後で先頭に戻ること", async () => { + const textarea = await setupWithVariants(); + + // 1st: low + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + expect(document.querySelector(".effort")?.textContent).toBe("Low"); + + // 2nd: medium + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + expect(document.querySelector(".effort")?.textContent).toBe("Medium"); + + // 3rd: high + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + expect(document.querySelector(".effort")?.textContent).toBe("High"); + + // 4th: wraps to low + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + expect(document.querySelector(".effort")?.textContent).toBe("Low"); + }); + + it("variant が 1 つだけのモデルでもサイクルして先頭固定で動作すること", async () => { + const textarea = await setupWithVariants("openai/gpt-5.4-mini"); + + // Cycle once: should land on the only available variant. + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + expect(document.querySelector(".effort")?.textContent).toBe("Minimal"); + + // Cycle again: still the same (wraps to itself). + fireEvent.keyDown(textarea, { key: "t", ctrlKey: true }); + expect(document.querySelector(".effort")?.textContent).toBe("Minimal"); + }); + }); + + // 3. preventDefault only when a valid cycle occurs + context("preventDefault の挙動", () => { + it("対応モデルでは preventDefault が呼ばれること", async () => { + const textarea = await setupWithVariants(); + const event = new KeyboardEvent("keydown", { + key: "t", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + act(() => { + textarea.dispatchEvent(event); + }); + expect(event.defaultPrevented).toBe(true); + }); + + it("unsupported / variants 不在のモデルでは preventDefault されず effort も表示されないこと", async () => { + const textarea = await setupWithVariants( + "deepseek/deepseek-reasoner", + [deepseekConnectedProvider], + deepseekAllProvider, + ); + + // Pre-condition: no effort UI present. + expect(document.querySelector(".effort")).toBeNull(); + expect(document.querySelector(".separator")).toBeNull(); + + const event = new KeyboardEvent("keydown", { + key: "t", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + // Wrap in act for consistency with the other keydown + // scenarios; the unsupported path itself doesn't update state + // but the surrounding render lifecycle still expects the + // dispatch to be inside a testing act batch. + act(() => { + textarea.dispatchEvent(event); + }); + + // Unsupported: no cycle, no preventDefault. + expect(event.defaultPrevented).toBe(false); + // No effort rendered. + expect(document.querySelector(".effort")).toBeNull(); + expect(document.querySelector(".separator")).toBeNull(); + }); + + it("Cmd+Ctrl+T (metaKey) はサイクルせず preventDefault もしないこと", async () => { + const textarea = await setupWithVariants(); + + const event = new KeyboardEvent("keydown", { + key: "t", + ctrlKey: true, + metaKey: true, + bubbles: true, + cancelable: true, + }); + act(() => { + textarea.dispatchEvent(event); + }); + + // Ctrl+Cmd+T (or accidental Cmd+T with ctrlKey) must NOT + // hijack the user shortcut. We don't prevent default so the + // platform/browser can still handle Cmd+T (open new tab). + expect(event.defaultPrevented).toBe(false); + expect(document.querySelector(".effort")).toBeNull(); + }); + + it("Ctrl+Alt+T (altKey) はサイクルせず preventDefault もしないこと", async () => { + const textarea = await setupWithVariants(); + + const event = new KeyboardEvent("keydown", { + key: "t", + ctrlKey: true, + altKey: true, + bubbles: true, + cancelable: true, + }); + act(() => { + textarea.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(false); + expect(document.querySelector(".effort")).toBeNull(); + }); + }); + + // 4. Ctrl+T does not leak when no setter is wired + context("onModelEffortSelect が未指定の場合", () => { + it("サイクルは行われず preventDefault もされないこと", async () => { + // Render InputArea in isolation with no onModelEffortSelect. + const props = makeBareProps(); + // Make sure no onModelEffortSelect is present. + delete (props as any).onModelEffortSelect; + const view = render(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + + const event = new KeyboardEvent("keydown", { + key: "t", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + act(() => { + textarea.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(false); + expect(document.querySelector(".effort")).toBeNull(); + view.unmount(); + }); + + it("bare な Enter 入力など既存挙動に影響しないこと", async () => { + // Regression guard: Ctrl+T no-op must not break text entry. + const user = (await import("@testing-library/user-event")).default.setup(); + const props = makeBareProps(); + delete (props as any).onModelEffortSelect; + const view = render(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + + await user.type(textarea, "hello"); + + // Plain Enter still doesn't fire because text is "hello" (which + // IS a send) — but use Shift+Enter so we just observe the + // newline path. The key point is that text content is intact. + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect((textarea as HTMLTextAreaElement).value).toContain("hello"); + view.unmount(); + }); + }); +}); diff --git a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css index 6a07a59..e2db89c 100644 --- a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css +++ b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css @@ -22,12 +22,35 @@ } .label { - max-width: 180px; + display: inline-flex; + align-items: baseline; + max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.modelName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.separator { + margin: 0 4px; + opacity: 0.55; + flex-shrink: 0; +} + +.effort { + flex-shrink: 0; + color: var(--vscode-textPreformat-foreground, var(--vscode-foreground)); + opacity: 0.85; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + .chevron { display: inline-flex; align-items: center; diff --git a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx index 85e9f14..f018b39 100644 --- a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx +++ b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx @@ -1,4 +1,4 @@ -import type { ProviderInfo as CoreProviderInfo } from "@opencodegui/core"; +import type { ModelVariantRef, ProviderInfo as CoreProviderInfo } from "@opencodegui/core"; import { useMemo, useState } from "react"; import { useLocale } from "../../../locales"; import type { AllProvidersData, ModelInfo, ProviderInfo } from "../../../vscode-api"; @@ -12,6 +12,14 @@ type Props = { allProvidersData: AllProvidersData | null; selectedModel: { providerID: string; modelID: string } | null; onSelect: (model: { providerID: string; modelID: string }) => void; + /** + * Optional explicit model effort/variant for the selected model. + * When unset, the selector shows only the model name (no separator + * or placeholder like "default"). When set, the effort label (or + * id fallback) is rendered compactly next to the model name with a + * middle-dot separator. Display-only; click handling lives elsewhere. + */ + selectedModelEffort?: ModelVariantRef; }; function formatContextK(context: number): string { @@ -30,7 +38,13 @@ const badgeClass: Record = { deprecated: styles.deprecated, }; -export function ModelSelector({ providers, allProvidersData, selectedModel, onSelect }: Props) { +export function ModelSelector({ + providers, + allProvidersData, + selectedModel, + onSelect, + selectedModelEffort, +}: Props) { const t = useLocale(); const [collapsedProviders, setCollapsedProviders] = useState>(new Set()); const [showAll, setShowAll] = useState(false); @@ -84,6 +98,17 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe return selectedModel.modelID; }, [selectedModel, allDisplayProviders, t["model.selectModel"]]); + // Effort display text. Prefer the normalized `label`; fall back to `id`. + // Only render when both a model is selected and an explicit effort is set. + // The label is intentionally compact (e.g. "Low" / "Medium" / "High") and + // uses a middle-dot separator so the rendered text reads as + // "GPT-5.4 · Low" without dominating the model name. + const selectedModelEffortText = useMemo(() => { + if (!selectedModel) return null; + if (!selectedModelEffort) return null; + return selectedModelEffort.label || selectedModelEffort.id; + }, [selectedModel, selectedModelEffort]); + const toggleProvider = (id: string) => { setCollapsedProviders((prev) => { const next = new Set(prev); @@ -98,7 +123,19 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe className={styles.root} trigger={({ open, toggle }) => (