Skip to content

fix(opencode): restore variant controls with custom values and v2 argv - #32

Draft
andromarces wants to merge 6 commits into
rjx18:mainfrom
andromarces:fix/issue-30-opencode-thinking
Draft

andromarces wants to merge 6 commits into
rjx18:mainfrom
andromarces:fix/issue-30-opencode-thinking

Conversation

@andromarces

@andromarces andromarces commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Refs #30 (per-model validation from that issue is deliberately out of scope here).

Problem and result

This PR previously withdrew OpenCode thinking: --variant names are
per-model presets, not a fixed effort scale, so a fixed low/medium/high
slider could not tell the operator whether the value would be used. This PR
now restores variant controls with custom values and v2 argv support: the
fixed set low/medium/high, a bounded custom entry for exact per-model
variant keys, and per-line argv. v1 keeps --variant; v2 has no --variant,
so the value rides the model as provider/model#variant (observed against
opencode2 v0.0.0-dev-19272). Default still omits the variant. OpenCode
controls the effective behavior, including its native configuration.

Scope note: this PR is three things, in this order — (1) a protocol
widening of the thinking value, (2) the OpenCode feature, (3) turn-path
revalidation in claude-code, codex, and grok as a consequence of (1).

Protocol change and where strictness lives now

ThinkingLevel was a closed enum; it is now enum | bounded custom string.
Three consequences a reviewer will notice in the diff:

  • Four prior rejection checks were inverted on purpose — member, room-create
    starting agent, wire ThinkingLevelSchema, and preset parsing — so
    CreateRoomRequestSchema now parses
    { harness: 'claude-code', thinking: 'ludicrous' }. Strictness moved from
    the wire schema to the registry gate plus the per-adapter spawn/turn
    asserts, which is where the harness is known.
  • ThinkingLevel resolves to string, so
    as const satisfies readonly ThinkingLevel[] in claude-code and codex no
    longer proves anything at compile time, and claudeThinkingOptions needed
    an as unknown as cast. Enforcement moved to KnownThinkingLevelSchema
    at adapter registration: a declared thinking_levels entry outside the
    fixed set is refused.
  • The gate normalizes before checking, and every strict adapter normalizes
    at spawn and at turn build, so gate and adapters agree (covered by
    registry and adapter tests for all four thinking adapters).

Normalization and validation

The custom UI field itself does not trim — its native pattern rejects all
whitespace before submit. Normalization below applies at the protocol
schema, the registry gate, the adapters, and the UI helpers
(supportedThinking/reconcileConfig), though not in the input field
itself:

  • Trim outer whitespace. UI payload builders map blank to Default.
    Protocol, registry, and spawn boundaries reject an explicit blank value.
  • Lowercase only when the trimmed value case-insensitively equals a
    recognized level (" HIGH " becomes high).
  • Preserve case and punctuation otherwise: OpenCode resolves variants by
    exact object key.
  • Reject control characters, inner whitespace, a leading -, #, and
    names over 64 characters.

Line detection (once per resolution key per adapter instance, LRU-bounded to 16)

Probe <command> --version (fixed argv, no shell, 5 s timeout, capped
output, async; listModels and discoverSessions keep their pre-existing
synchronous probes, neither on the turn path) in the session cwd with the
merged session environment:

  • opencode v2.0.3 / opencode2 v0.0.0-dev-19272 shape: v2.
  • 1.18.30 / 0.0.0-dev-202609102034 bare-version shape: v1.
  • Any unrecognized successful banner, including a future opencode v3.0.0,
    falls back to run --help: --variant without #variant is v1,
    #variant without --variant is v2, both or neither is unknown. (A
    nonzero --version result returns unknown immediately.)

Unknown fails the turn with opencode line not recognized plus a version
token or (probe failed) — never raw probe output. Detection runs before
the thinking value is read, so an unrecognized line fails even Default
turns with no thinking value: a broken probe breaks every turn on that
install, not only thinking turns. Fail-loud is deliberate — silently
running a future line's turns on guessed argv is worse — and unknown
results are never cached, so each unrecognized turn retries (up to two
5 s probes). The success cache keys on PATH (last case-insensitive match
on win32) plus cwd exactly when cwd can change resolution; Windows bare
commands always carry cwd (the launcher searches it first), so a Windows
daemon with more than 16 active directories re-probes past the LRU bound.
On the daemon path today, sessions carry only the five CODOR_*
environment variables (never PATH), so PATH-based re-probing is a
contract guarantee exercised by tests rather than by live daemon traffic.

v2 Default-model rule

v2 with the Default model and a thinking value resolves the default per
turn via <command> api GET /api/model/default and sends
--model <providerID>/<id>#<value> using data.id (the selectable catalog
reference, not data.modelID). Never persisted; session.model stays
undefined; never cached across turns. data: null, a failed call, or
unparsable/invalid output fails the turn with a fixed explicit-model
message; the stored thinking value is preserved. v1 ignores an unknown
variant silently (exit 0); v2 fails the turn with
Variant unavailable for <model> through the existing failed-turn path.
Codor does not validate a value against the selected model.

Malformed custom values, precisely

  • Typed UI entry: pattern + maxLength block submit natively (proven by
    e2e patternMismatch).
  • Saved presets: applying a preset with an unsupported value returns an
    explicit named error before spawn; nothing is partially applied.
  • REST create/update: malformed values are rejected by schema validation.
    Well-formed but harness-unsupported values save, then fail: unsupported
    presets fail during application, and direct spawn and configure requests
    fail at the registry gate.
  • Only raw spawn payload building (buildSpawnSpec) omits an unsupported
    value, and the configure dialog serializes it as null (clear). No path
    sends a rejected value to a harness.

Harn intent (not edited — .harn/ untouched)

  • harness-declares-supported-thinking-levels: the registry now enforces
    the declared list plus a thinking_custom flag, and the controls render
    declared levels plus a custom entry. Strict adapters keep exact-list
    rejections at the gate and at spawn/turn build.
  • individual-agent-presets-are-bounded-catalog-validated-configurations:
    the persisted thinking value is now enum | bounded custom string;
    "bounded" is enforced by ThinkingLevelSchema after normalization.

Tests

pnpm install --frozen-lockfile                      # completed, no changes
pnpm -r build                                       # green
pnpm -r test                                        # stops at first failing package (see below)
pnpm --filter @codor/protocol exec vitest run       # 257 pass
pnpm --filter @codor/adapter-opencode exec vitest run        # 40 pass, 1 skipped
pnpm --filter @codor/adapter-claude-code exec vitest run    # 98 pass, 2 skipped
pnpm --filter @codor/adapter-codex exec vitest run          # 127 pass, 5 skipped
pnpm --filter @codor/adapter-grok exec vitest run           # 8 pass, 1 pre-existing /var failure
pnpm --filter @codor/switchboard exec vitest run src/adapter-registry.spec.ts  # 24 pass
pnpm --filter @codor/web-next exec playwright test tests/room10-dialogs.e2e.spec.ts  # 31 tests; 31 pass when the axe check holds, 30 pass + 1 fail when it flakes (below)

pnpm -r test does not go green on macOS for pre-existing,
unrelated reasons: copilot/gemini/grok assert /var/... against
/private/var/..., and 19 switchboard worktree/artifact/server tests fail
the same way (plus load-sensitive 5 s timeouts under parallel runs — counts
vary run to run; use per-package totals). Web-next unit shows 734 pass /
17 fail across three localStorage-related files plus one brand-assets
timeout, also untouched by this diff. Room10's single axe color-contrast
failure is intermittent and no branch-specific cause was found (it
alternates across dialogs and passes in isolation and on reruns).

The opencode spec fakes are POSIX-only (shebang scripts plus chmod).
This prevents package-test validation on Windows. Production Windows
behavior remains untested. The cross-cwd identity test is a regression
guard for the cache key, not a field fix.

CLI compatibility evidence (2026-09-13 probes, macOS)

Direct CLI invocations covering argv shapes only (no end-to-end Codor
adapter turns against real CLIs). v1, re-verified live on this machine via
pnpm dlx opencode-ai@1.18.30 (banner 1.18.30; run --help documents
--variant as "model variant (provider-specific reasoning effort, e.g.,
high, max, minimal)" alongside --model provider/model, --session,
--format json, --auto; unknown --variant ignored, exit 0).
v2 (@opencode/cli 2.0.3, dev opencode2 v0.0.0-dev-19272, re-verified
live via the installed CLI): run has no --variant;
--model provider/model#variant accepted; --variant rejected with usage

  • non-zero exit; unknown variant fails the turn with
    Variant unavailable for <model>; GET /api/model/default returns
    {location, data: {id, modelID, providerID, variants}} with nullable
    data. These rows prove CLI shapes, not that a model applies a value.

Known v2 gaps (planned follow-ups)

  1. Session discovery: no db subcommand; discoverSessions returns []
    via its error path. Fix: session list --format json per line with a cwd.
  2. Usage: no step_finish observed on v2, so run.completed carries no
    usage. Confirm on a paid model first.
  3. Model catalog: models is cwd-scoped on v2; listModels runs daemon-cwd
    and can return empty.
  4. Process model: v2 run is a client of an auto-started service; process-
    group SIGINT interrupt on the client is unverified.

Not tested

End-to-end Codor adapter turns against real CLIs; a v2 turn with a
supported variant; a v2 turn with the resolved default plus a variant; v2
tool events; v2 interrupt; Windows; Linux.

Incidental notes

  • One-line test-only fix folded in: realpathSync(cwd) in the opencode
    argv test for the same macOS /var symlink gemini/copilot/grok still carry.
  • Operator validation errors join the final_text failure path on grok and
    opencode vs error on codex — inherited convention per adapter.

opencode --variant names are per-model presets, not a fixed effort
scale. Models declare variants:{} or sets without medium, and unknown
values are accepted with exit 0, so low/medium/high misleads. Declare
thinking:false and reject any thinking value loudly until per-model
variants are exposed. See rjx18#30.

Intended behavior change for harn:assume harness-declares-supported-thinking-levels: codor stops offering opencode a fixed effort set the selected model may not declare. Harn hashes not edited per CONTRIBUTING.
Probe result anchored to opencode 0.0.0-dev-202609102034 on 2026-09-12,
separate from the pinned 1.17.14 July evidence. Documents the chosen
upgrade contract for rjx18#30: persisted opencode thinking fails
loudly and clears on manual Configure save.
Applying an opencode preset with stored thinking is refused with a
named message, the preset editor blocks save, and a stale default
roster fails channel creation. Document the switch-harness-back
recovery and edit/remove path instead of implying a normal save
clears it.
reconcileConfig clears model alongside thinking, so the documented
preset recovery costs a model reselect.
Replacing a stale preset goes through the API without thinking;
deletion requires removing it from the default roster first.
Restores the fixed thinking set (low/medium/high) with a bounded custom
entry for exact per-model variant keys, detects the installed OpenCode
line per adapter instance (v1 --variant, v2 --model provider/model#variant),
and resolves the v2 default model per turn. Widens the protocol thinking
value to enum | bounded custom string with normalization; strict adapters
keep exact-list enforcement at the registry gate and at spawn/turn build.

Refs rjx18#30
@andromarces andromarces changed the title fix(opencode): withdraw fixed thinking levels models silently ignore fix(opencode): restore variant controls with custom values and v2 argv Sep 13, 2026
@andromarces

Copy link
Copy Markdown
Contributor Author

Scope-change notice: since the withdrawal commits, this PR changed direction from withdrawing OpenCode thinking to restoring it with v2 support. It now restores low/medium/high, adds a bounded custom variant entry, detects the installed OpenCode line per resolution key within each adapter instance (v1 --variant, v2 --model provider/model#variant), and resolves the v2 default model per turn. The withdrawal commits stay in history; the new commit above reverses their effect. Keeping this PR a draft until review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant