From 78040ae8b00779e60e0e397819b6fc4d694705cf Mon Sep 17 00:00:00 2001
From: Andro Marces
Date: Sat, 12 Sep 2026 17:41:31 +0800
Subject: [PATCH 1/6] fix(opencode): withdraw fixed thinking levels models
silently ignore
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/codor#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.
---
packages/adapters/opencode/NOTES.md | 24 +++++++----
.../adapters/opencode/src/adapter.spec.ts | 18 +++++---
packages/adapters/opencode/src/adapter.ts | 43 +++++++------------
packages/adapters/opencode/src/index.spec.ts | 3 +-
.../switchboard/src/adapter-registry.spec.ts | 8 +---
5 files changed, 44 insertions(+), 52 deletions(-)
diff --git a/packages/adapters/opencode/NOTES.md b/packages/adapters/opencode/NOTES.md
index cb0b1d88..c5ebeb61 100644
--- a/packages/adapters/opencode/NOTES.md
+++ b/packages/adapters/opencode/NOTES.md
@@ -45,26 +45,33 @@ Phase 2 rechecked the installed `run --help` plus the first-party
[permissions](https://opencode.ai/docs/permissions/) and
[models](https://opencode.ai/docs/models/) references on 2026-07-11.
Codor emits no policy flag for `read-only` or `workspace-write`, and maps
-`full-access` to `--auto`. Thinking `low`, `medium`, and `high` maps directly
-to `run --variant `, so the adapter declares `thinking:true`.
-Variant availability is provider/model-dependent; the docs and no-spend help
-probe did not establish unsupported-model behavior, so a native rejection is
-reported as an ordinary failed turn.
+`full-access` to `--auto`. Thinking is not offered: `--variant` names are
+per-model variant presets (see `opencode models --verbose`), not a fixed
+effort scale, and an unsupported or unknown value is accepted and silently
+ignored with exit 0, so a fixed low/medium/high control cannot tell the
+operator whether the value will be used. The adapter therefore declares
+`thinking:false` and never sends `--variant` until variants are exposed per
+model.
## Invocation
New turn:
```text
-opencode run --format json [--model PROVIDER/MODEL] [--auto] [--variant LEVEL] PAYLOAD
+opencode run --format json [--model PROVIDER/MODEL] [--auto] PAYLOAD
```
Continued turn:
```text
-opencode run --format json [--model PROVIDER/MODEL] [--auto] [--variant LEVEL] --session SESSION_ID PAYLOAD
+opencode run --format json [--model PROVIDER/MODEL] [--auto] --session SESSION_ID PAYLOAD
```
+Codor never sends `--variant`. The CLI accepts it, but the name must come
+from the selected model's own `variants` object (`opencode models
+--verbose`), which varies per model and install and admits operator-defined
+custom names; an unknown name is ignored with exit 0.
+
The process starts in the member's persisted cwd, stdin is closed, stdout is
read through EOF, stderr is bounded for failure detail, and the detached process
group is signalled on interrupt and cleanup. OpenCode emits no result record:
@@ -122,7 +129,7 @@ unchanged and never derives prices from tokens.
| ask | false | `run` exposes no question response channel |
| approvals | spawn-time | `--auto` or CLI-owned rejection; no Codor runtime response |
| extensions | false | completed task tools do not provide authoritative child lifecycle |
-| thinking | true | documented `run --variant`; argv tests cover low/medium/high |
+| thinking | false | `--variant` is per-model and silently ignored when unsupported; no fixed effort set is offered |
`fixtures/live-pong-1.17.14.jsonl` is the one real authenticated capture required
by P1.7b, using the configured free model and the tiny prompt `Reply PONG only.`.
@@ -144,7 +151,6 @@ Any hardcoded list would have been wrong for somebody.
Discovery is best-effort by design. A missing binary, a non-zero exit, a hang, or output the
daemon cannot validate all degrade silently to no list, and the dialog falls back to its
`Custom…` escape (placeholder `provider/model`, the form opencode's `--model` takes).
-Thinking is supported and maps to `run --variant `.
diff --git a/packages/adapters/opencode/src/adapter.spec.ts b/packages/adapters/opencode/src/adapter.spec.ts
index fca0c076..51b001e7 100644
--- a/packages/adapters/opencode/src/adapter.spec.ts
+++ b/packages/adapters/opencode/src/adapter.spec.ts
@@ -6,7 +6,6 @@ import type { WireEvent } from '@codor/protocol';
import { afterEach, describe, expect, it } from 'vitest';
import {
- OPENCODE_THINKING_LEVELS,
OpenCodeAdapter,
openCodeArgs,
openCodeAutoApprove,
@@ -44,18 +43,23 @@ describe('OpenCode subprocess and capability conformance', () => {
});
// harn:assume harness-declares-supported-thinking-levels ref=opencode-thinking-level-regression
- it('maps canonical policies and its declared thinking levels to documented argv', () => {
+ it('maps canonical policies to documented argv and offers no thinking control', () => {
+ // Verifies the issue #30 fix: --variant names are per-model presets and an
+ // unsupported value is silently ignored, so no fixed effort set is offered.
const base = { harness: 'opencode', cwd: '/work' };
expect(openCodeArgs({ ...base, policy: 'read-only' }, 'go')).not.toContain('--auto');
expect(openCodeArgs({ ...base, policy: 'workspace-write' }, 'go')).not.toContain('--auto');
expect(openCodeArgs({ ...base, policy: 'full-access' }, 'go')).toContain('--auto');
- for (const thinking of OPENCODE_THINKING_LEVELS) {
- expect(openCodeArgs({ ...base, thinking }, 'go')).toEqual(
- expect.arrayContaining(['--variant', thinking]),
+ expect(openCodeArgs({ ...base }, 'go')).not.toContain('--variant');
+ for (const thinking of ['low', 'medium', 'high', 'xhigh'] as const) {
+ expect(() => openCodeArgs({ ...base, thinking }, 'go')).toThrow(
+ "adapter 'opencode' does not support thinking levels",
);
}
- expect(() => openCodeArgs({ ...base, thinking: 'xhigh' }, 'go')).toThrow(
- "adapter 'opencode' does not support thinking level 'xhigh'",
+ expect(new OpenCodeAdapter().capabilities.thinking).toBe(false);
+ expect(new OpenCodeAdapter().capabilities).not.toHaveProperty('thinking_levels');
+ expect(() => new OpenCodeAdapter().spawn({ cwd: '/work', thinking: 'low' })).toThrow(
+ "adapter 'opencode' does not support thinking levels",
);
});
// harn:end harness-declares-supported-thinking-levels
diff --git a/packages/adapters/opencode/src/adapter.ts b/packages/adapters/opencode/src/adapter.ts
index 63debd83..eb0f32b3 100644
--- a/packages/adapters/opencode/src/adapter.ts
+++ b/packages/adapters/opencode/src/adapter.ts
@@ -12,7 +12,7 @@ import type {
ThinkingLevel,
WireEvent,
} from '@codor/protocol';
-import { PolicySchema, ThinkingLevelSchema } from '@codor/protocol';
+import { PolicySchema } from '@codor/protocol';
import { createTurnTranslator } from './translate.js';
@@ -20,25 +20,21 @@ const DISCOVER_QUERY =
'SELECT id FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC';
// harn:assume harness-declares-supported-thinking-levels ref=opencode-thinking-level-declaration
-export const OPENCODE_THINKING_LEVELS = [
- 'low',
- 'medium',
- 'high',
-] as const satisfies readonly ThinkingLevel[];
+// opencode --variant names are per-model variant presets, not a fixed effort
+// scale: `opencode models --verbose` shows models with `variants: {}`, models
+// without `medium`, and names like `minimal`, `xhigh`, `max`, `none`, and
+// `thinking`. An unknown --variant is accepted and silently ignored (exit 0),
+// so offering a fixed low/medium/high slider cannot tell the operator whether
+// the value will be used. The adapter therefore declares no effort control
+// until variants are exposed per model. See NOTES.md.
+function assertNoThinkingLevel(thinking: ThinkingLevel | undefined): void {
+ if (thinking === undefined) return;
+ throw new Error(`adapter 'opencode' does not support thinking levels`);
+}
function invalidPolicy(policy: string): Error {
return new Error(`unknown policy '${policy}'; valid policies: ${PolicySchema.options.join(', ')}`);
}
-
-function assertThinkingLevel(thinking: ThinkingLevel | undefined): void {
- if (thinking === undefined) return;
- if (!(OPENCODE_THINKING_LEVELS as readonly string[]).includes(thinking)) {
- throw new Error(
- `adapter 'opencode' does not support thinking level '${thinking}'; ` +
- `valid levels: ${OPENCODE_THINKING_LEVELS.join(', ')}`,
- );
- }
-}
// harn:end harness-declares-supported-thinking-levels
export function openCodeAutoApprove(policy: string | undefined): boolean {
@@ -53,11 +49,7 @@ export function openCodeArgs(session: Session, payload: string): string[] {
const args = ['run', '--format', 'json'];
if (session.model !== undefined) args.push('--model', session.model);
if (autoApprove) args.push('--auto');
- if (session.thinking !== undefined) {
- ThinkingLevelSchema.parse(session.thinking);
- assertThinkingLevel(session.thinking);
- args.push('--variant', session.thinking);
- }
+ assertNoThinkingLevel(session.thinking);
if (session.session_ref !== undefined) args.push('--session', session.session_ref);
args.push(payload);
return args;
@@ -73,8 +65,7 @@ export class OpenCodeAdapter implements HarnessAdapter {
ask: false,
approvals: 'spawn-time',
extensions: false,
- thinking: true,
- thinking_levels: OPENCODE_THINKING_LEVELS,
+ thinking: false,
// harn:assume live-inbox-capability-is-evidence-backed-v2 ref=opencode-live-inbox-capability
live_inbox: false,
// harn:end live-inbox-capability-is-evidence-backed-v2
@@ -97,16 +88,12 @@ export class OpenCodeAdapter implements HarnessAdapter {
if (opts.policy !== undefined && !PolicySchema.safeParse(opts.policy).success) {
throw invalidPolicy(opts.policy);
}
- if (opts.thinking !== undefined) {
- ThinkingLevelSchema.parse(opts.thinking);
- assertThinkingLevel(opts.thinking);
- }
+ assertNoThinkingLevel(opts.thinking);
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
- thinking: opts.thinking,
};
}
// harn:end canonical-spawn-controls-enforced
diff --git a/packages/adapters/opencode/src/index.spec.ts b/packages/adapters/opencode/src/index.spec.ts
index f8924c46..880d7752 100644
--- a/packages/adapters/opencode/src/index.spec.ts
+++ b/packages/adapters/opencode/src/index.spec.ts
@@ -14,8 +14,7 @@ describe('@codor/adapter-opencode barrel', () => {
ask: false,
approvals: 'spawn-time',
extensions: false,
- thinking: true,
- thinking_levels: ['low', 'medium', 'high'],
+ thinking: false,
live_inbox: false,
// Only full-access is enforced; the other two defer to opencode's own rules.
policies: {
diff --git a/packages/switchboard/src/adapter-registry.spec.ts b/packages/switchboard/src/adapter-registry.spec.ts
index 07a4142b..cf7e9f7c 100644
--- a/packages/switchboard/src/adapter-registry.spec.ts
+++ b/packages/switchboard/src/adapter-registry.spec.ts
@@ -99,7 +99,7 @@ describe('adapter registry spawn controls', () => {
['cursor', false, undefined],
['gemini', false, undefined],
['grok', true, ['low', 'medium', 'high']],
- ['opencode', true, ['low', 'medium', 'high']],
+ ['opencode', false, undefined],
]);
});
@@ -116,7 +116,7 @@ describe('adapter registry spawn controls', () => {
it('rejects thinking before delegating to unsupported adapters', async () => {
const adapters = await loadAdapterRegistry();
- for (const id of ['antigravity', 'copilot', 'copilot-vscode', 'gemini']) {
+ for (const id of ['antigravity', 'copilot', 'copilot-vscode', 'gemini', 'opencode']) {
const adapter = adapters.find((candidate) => candidate.id === id)!;
expect(() => adapter.spawn({ cwd: '/work', thinking: 'high' })).toThrow(
`adapter '${id}' does not support thinking levels`,
@@ -128,16 +128,12 @@ describe('adapter registry spawn controls', () => {
const adapters = await loadAdapterRegistry();
const codex = adapters.find((adapter) => adapter.id === 'codex')!;
const claude = adapters.find((adapter) => adapter.id === 'claude-code')!;
- const opencode = adapters.find((adapter) => adapter.id === 'opencode')!;
expect(() => codex.spawn({ cwd: '/work', thinking: 'ultracode' })).toThrow(
"adapter 'codex' does not support thinking level 'ultracode'",
);
expect(() => claude.spawn({ cwd: '/work', thinking: 'ultra' })).toThrow(
"adapter 'claude-code' does not support thinking level 'ultra'",
);
- expect(() => opencode.spawn({ cwd: '/work', thinking: 'xhigh' })).toThrow(
- "adapter 'opencode' does not support thinking level 'xhigh'",
- );
});
it('preserves low, medium, and high for older thinking-capable adapters', () => {
From 9711b48f0036d0a2bb15134ae3bf9bc475909d6d Mon Sep 17 00:00:00 2001
From: Andro Marces
Date: Sat, 12 Sep 2026 17:56:39 +0800
Subject: [PATCH 2/6] docs(opencode): anchor variant evidence and note thinking
upgrade
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/codor#30: persisted opencode thinking fails
loudly and clears on manual Configure save.
---
packages/adapters/opencode/NOTES.md | 24 +++++++++++++++++-------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/packages/adapters/opencode/NOTES.md b/packages/adapters/opencode/NOTES.md
index c5ebeb61..c7b4d25c 100644
--- a/packages/adapters/opencode/NOTES.md
+++ b/packages/adapters/opencode/NOTES.md
@@ -45,13 +45,23 @@ Phase 2 rechecked the installed `run --help` plus the first-party
[permissions](https://opencode.ai/docs/permissions/) and
[models](https://opencode.ai/docs/models/) references on 2026-07-11.
Codor emits no policy flag for `read-only` or `workspace-write`, and maps
-`full-access` to `--auto`. Thinking is not offered: `--variant` names are
-per-model variant presets (see `opencode models --verbose`), not a fixed
-effort scale, and an unsupported or unknown value is accepted and silently
-ignored with exit 0, so a fixed low/medium/high control cannot tell the
-operator whether the value will be used. The adapter therefore declares
-`thinking:false` and never sends `--variant` until variants are exposed per
-model.
+`full-access` to `--auto`.
+
+2026-09-12 probe with opencode `0.0.0-dev-202609102034`: `--variant`
+names are per-model variant presets (see `opencode models --verbose`), not
+a fixed effort scale, and an unsupported or unknown value is accepted and
+silently ignored with exit 0, so a fixed low/medium/high control cannot
+tell the operator whether the value will be used. The adapter therefore
+declares `thinking:false` and never sends `--variant` until variants are
+exposed per model.
+
+Upgrade note: an existing opencode member with a persisted `thinking`
+value fails loudly on its next turn (`validateSpawnOptions` on rebuild,
+`openCodeArgs` on attach) with `adapter 'opencode' does not support
+thinking levels`. Recovery is a manual save in the Configure agent
+dialog, which submits `thinking: null` for an unsupported level and
+clears the stored value. The same applies to an agent preset carrying
+`thinking` for opencode.
## Invocation
From 467d48e703a5369b5848c71957e35b2e89e6802c Mon Sep 17 00:00:00 2001
From: Andro Marces
Date: Sat, 12 Sep 2026 18:02:43 +0800
Subject: [PATCH 3/6] docs(opencode): correct preset upgrade path for withdrawn
thinking
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.
---
packages/adapters/opencode/NOTES.md | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/packages/adapters/opencode/NOTES.md b/packages/adapters/opencode/NOTES.md
index c7b4d25c..c943ef83 100644
--- a/packages/adapters/opencode/NOTES.md
+++ b/packages/adapters/opencode/NOTES.md
@@ -60,8 +60,15 @@ value fails loudly on its next turn (`validateSpawnOptions` on rebuild,
`openCodeArgs` on attach) with `adapter 'opencode' does not support
thinking levels`. Recovery is a manual save in the Configure agent
dialog, which submits `thinking: null` for an unsupported level and
-clears the stored value. The same applies to an agent preset carrying
-`thinking` for opencode.
+clears the stored value.
+
+Presets behave differently: applying an opencode preset with a stored
+`thinking` value is refused with a named message rather than a failed
+turn, and the preset editor blocks save while the unsupported value is
+present. Clear it by switching the draft to another harness and back
+(`reconcileConfig` drops the level), or by editing or removing the
+preset. A saved default roster holding such a preset fails channel
+creation at roster expansion, not one turn.
## Invocation
From 25aedb65a30ca34aa133ccc66c6a10ba10e7488e Mon Sep 17 00:00:00 2001
From: Andro Marces
Date: Sat, 12 Sep 2026 18:08:26 +0800
Subject: [PATCH 4/6] docs(opencode): note model reselect on preset harness
round trip
reconcileConfig clears model alongside thinking, so the documented
preset recovery costs a model reselect.
---
packages/adapters/opencode/NOTES.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/adapters/opencode/NOTES.md b/packages/adapters/opencode/NOTES.md
index c943ef83..0e0f0948 100644
--- a/packages/adapters/opencode/NOTES.md
+++ b/packages/adapters/opencode/NOTES.md
@@ -66,7 +66,8 @@ Presets behave differently: applying an opencode preset with a stored
`thinking` value is refused with a named message rather than a failed
turn, and the preset editor blocks save while the unsupported value is
present. Clear it by switching the draft to another harness and back
-(`reconcileConfig` drops the level), or by editing or removing the
+(`reconcileConfig` drops the level, and also clears the model, which must
+be reselected), or by editing or removing the
preset. A saved default roster holding such a preset fails channel
creation at roster expansion, not one turn.
From fb524af7894be0c27850d5c1a35aa4e3b72f10ef Mon Sep 17 00:00:00 2001
From: Andro Marces
Date: Sat, 12 Sep 2026 18:17:26 +0800
Subject: [PATCH 5/6] docs(opencode): state exact preset remediation routes
Replacing a stale preset goes through the API without thinking;
deletion requires removing it from the default roster first.
---
packages/adapters/opencode/NOTES.md | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/packages/adapters/opencode/NOTES.md b/packages/adapters/opencode/NOTES.md
index 0e0f0948..c3c33bd5 100644
--- a/packages/adapters/opencode/NOTES.md
+++ b/packages/adapters/opencode/NOTES.md
@@ -66,10 +66,11 @@ Presets behave differently: applying an opencode preset with a stored
`thinking` value is refused with a named message rather than a failed
turn, and the preset editor blocks save while the unsupported value is
present. Clear it by switching the draft to another harness and back
-(`reconcileConfig` drops the level, and also clears the model, which must
-be reselected), or by editing or removing the
-preset. A saved default roster holding such a preset fails channel
-creation at roster expansion, not one turn.
+(`reconcileConfig` drops the level, and also clears the model, which
+must be reselected), by replacing the preset through the API without
+`thinking`, or by removing it from the default roster before deleting
+it. A saved default roster holding such a preset fails channel creation
+at roster expansion, not one turn.
## Invocation
From 715edafa3c72d0b09a1278565a20aa1de8ab16a9 Mon Sep 17 00:00:00 2001
From: Andro Marces
Date: Sun, 13 Sep 2026 15:53:14 +0800
Subject: [PATCH 6/6] fix(opencode): restore variant controls with custom
values and v2 argv
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 #30
---
.../adapters/claude-code/src/adapter.spec.ts | 23 +-
packages/adapters/claude-code/src/adapter.ts | 21 +-
packages/adapters/codex/src/adapter.spec.ts | 25 +
packages/adapters/codex/src/adapter.ts | 32 +-
packages/adapters/grok/src/adapter.spec.ts | 37 +-
packages/adapters/grok/src/adapter.ts | 49 +-
packages/adapters/opencode/NOTES.md | 87 +-
.../adapters/opencode/src/adapter.spec.ts | 876 +++++++++++++++++-
packages/adapters/opencode/src/adapter.ts | 395 +++++++-
packages/adapters/opencode/src/index.spec.ts | 4 +-
packages/protocol/src/adapter.ts | 44 +-
packages/protocol/src/agent-presets.spec.ts | 6 +-
packages/protocol/src/schemas.spec.ts | 28 +-
.../switchboard/src/adapter-registry.spec.ts | 49 +-
packages/switchboard/src/adapter-registry.ts | 33 +-
packages/web-next/src/room/AgentControls.tsx | 37 +-
packages/web-next/src/room/ThinkingSlider.tsx | 17 +-
packages/web-next/src/room/agent-spec.spec.ts | 59 ++
packages/web-next/src/room/agent-spec.ts | 31 +-
packages/web-next/tests/harness.mjs | 1 +
.../web-next/tests/room10-dialogs.e2e.spec.ts | 58 ++
21 files changed, 1759 insertions(+), 153 deletions(-)
diff --git a/packages/adapters/claude-code/src/adapter.spec.ts b/packages/adapters/claude-code/src/adapter.spec.ts
index 615666ac..173736e3 100644
--- a/packages/adapters/claude-code/src/adapter.spec.ts
+++ b/packages/adapters/claude-code/src/adapter.spec.ts
@@ -286,8 +286,29 @@ describe('Claude Agent SDK query lifecycle', () => {
});
});
- it('routes interrupt through query.interrupt and completes the active turn', async () => {
+ // Requirement: an attached session with padded thinking normalizes onto the
+ // query options instead of reaching the engine raw. Non-redundant: the only
+ // attached-path normalization proof for claude-code.
+ it('normalizes padded thinking from an attached session onto the query', async () => {
const records: MockQueryRecord[] = [];
+ const factory = queryFactory(async function* (input) {
+ for await (const _user of input.prompt) {
+ yield init();
+ yield result('normalized');
+ }
+ }, records);
+ const adapter = new ClaudeCodeAdapter({ queryFactory: factory });
+ const session = tracked(adapter, adapter.attach(SESSION_ID));
+ session.cwd = process.cwd();
+ session.thinking = ' HIGH ' as 'high';
+
+ const events = await collect(adapter.deliver(session, 'hi'));
+
+ expect(events.at(-1)).toMatchObject({ final_text: 'normalized' });
+ expect(records[0]!.input.options).toMatchObject({ effort: 'high', resume: SESSION_ID });
+ });
+
+ it('routes interrupt through query.interrupt and completes the active turn', async () => { const records: MockQueryRecord[] = [];
let release: (() => void) | undefined;
const stopped = new Promise((resolve) => {
release = resolve;
diff --git a/packages/adapters/claude-code/src/adapter.ts b/packages/adapters/claude-code/src/adapter.ts
index 81c65c56..727be094 100644
--- a/packages/adapters/claude-code/src/adapter.ts
+++ b/packages/adapters/claude-code/src/adapter.ts
@@ -26,7 +26,7 @@ import type {
SpawnOpts,
WireEvent,
} from '@codor/protocol';
-import { PolicySchema, ThinkingLevelSchema } from '@codor/protocol';
+import { PolicySchema, normalizeThinkingLevel, ThinkingLevelSchema } from '@codor/protocol';
import { probeClaudeLimits } from './limits-probe.js';
import { peekClaudeContextUsage } from './peek.js';
@@ -94,9 +94,11 @@ function claudeThinkingOptions(
thinking: Session['thinking'],
): Pick {
if (thinking === undefined) return {};
- ThinkingLevelSchema.parse(thinking);
- assertThinkingLevel(thinking);
- if (thinking === 'ultracode') {
+ // Normalized here as well as in spawn: rebuilt sessions reach this path directly.
+ const normalized = normalizeThinkingLevel(thinking);
+ ThinkingLevelSchema.parse(normalized);
+ assertThinkingLevel(normalized);
+ if (normalized === 'ultracode') {
return {
thinking: { type: 'adaptive' },
effort: 'xhigh',
@@ -105,7 +107,7 @@ function claudeThinkingOptions(
}
return {
thinking: { type: 'adaptive' },
- effort: thinking as Exclude,
+ effort: normalized as unknown as Exclude<(typeof CLAUDE_THINKING_LEVELS)[number], 'ultracode'>,
};
}
// harn:end canonical-spawn-controls-enforced
@@ -427,16 +429,19 @@ export class ClaudeCodeAdapter implements HarnessAdapter {
spawn(opts: SpawnOpts): Session {
claudePermissionMode(opts.policy);
+ let thinking: Session['thinking'];
if (opts.thinking !== undefined) {
- ThinkingLevelSchema.parse(opts.thinking);
- assertThinkingLevel(opts.thinking);
+ const normalized = normalizeThinkingLevel(opts.thinking);
+ ThinkingLevelSchema.parse(normalized);
+ assertThinkingLevel(normalized);
+ thinking = normalized;
}
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
- thinking: opts.thinking,
+ thinking,
};
}
diff --git a/packages/adapters/codex/src/adapter.spec.ts b/packages/adapters/codex/src/adapter.spec.ts
index 48ed019e..57f5e932 100644
--- a/packages/adapters/codex/src/adapter.spec.ts
+++ b/packages/adapters/codex/src/adapter.spec.ts
@@ -617,6 +617,31 @@ describe('persistent Codex app-server lifecycle', () => {
error: 'codex missing',
});
});
+
+ // Requirement: a rebuilt session carrying an unlisted thinking value fails
+ // the turn before any app-server request, and a padded recognized value is
+ // normalized onto the turn. Non-redundant: the only deliver-path thinking
+ // validation proof for codex.
+ it('revalidates thinking on rebuilt sessions before starting a turn', async () => {
+ const server = createFakeCodexAppServer();
+ const { adapter } = fixtureAdapter(server);
+ const rebuilt: Session = { harness: 'codex', cwd: '/work', thinking: 'extreme' as 'high' };
+ const events = await collect(adapter, rebuilt, 'stale');
+ expect(events.at(-1)).toMatchObject({
+ type: 'run.completed',
+ status: 'failed',
+ error: expect.stringContaining('extreme'),
+ });
+ expect(server.messages.some((message) => message.method === 'turn/start')).toBe(false);
+
+ const padded: Session = { harness: 'codex', cwd: '/work', thinking: ' HIGH ' as 'high' };
+ const run = collect(adapter, padded, 'normalized');
+ await server.waitForRequest('turn/start');
+ const start = server.messages.find((message) => message.method === 'turn/start');
+ expect((start?.params as { effort?: string }).effort).toBe('high');
+ completeTurn(server, 'turn-1');
+ expect((await run).at(-1)).toMatchObject({ status: 'completed' });
+ });
});
// harn:end codex-app-server-is-the-member-runtime
diff --git a/packages/adapters/codex/src/adapter.ts b/packages/adapters/codex/src/adapter.ts
index ec31899b..ba06605a 100644
--- a/packages/adapters/codex/src/adapter.ts
+++ b/packages/adapters/codex/src/adapter.ts
@@ -17,7 +17,7 @@ import type {
ThinkingLevel,
WireEvent,
} from '@codor/protocol';
-import { PolicySchema, ThinkingLevelSchema } from '@codor/protocol';
+import { PolicySchema, normalizeThinkingLevel, ThinkingLevelSchema } from '@codor/protocol';
import {
CodexAppServerClient,
@@ -97,10 +97,12 @@ export function codexPolicyOptions(policy: string | undefined): CodexPolicyOptio
};
}
-function validateThinking(thinking: ThinkingLevel | undefined): void {
- if (thinking === undefined) return;
- ThinkingLevelSchema.parse(thinking);
- assertThinkingLevel(thinking);
+function validateThinking(thinking: ThinkingLevel | undefined): ThinkingLevel | undefined {
+ if (thinking === undefined) return undefined;
+ const normalized = normalizeThinkingLevel(thinking) as ThinkingLevel;
+ ThinkingLevelSchema.parse(normalized);
+ assertThinkingLevel(normalized);
+ return normalized;
}
// harn:end canonical-spawn-controls-enforced
@@ -466,13 +468,13 @@ export class CodexAdapter implements HarnessAdapter {
spawn(opts: SpawnOpts): Session {
codexPolicyOptions(opts.policy);
- validateThinking(opts.thinking);
+ const thinking = validateThinking(opts.thinking);
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
- thinking: opts.thinking,
+ thinking,
};
}
@@ -507,6 +509,16 @@ export class CodexAdapter implements HarnessAdapter {
): AsyncIterable {
const runtime = this.runtimeFor(session);
if (runtime.active !== null) throw new Error('a Codex turn is already in flight for this member');
+ // Revalidated before touching the runtime: rebuilt sessions bypass spawn,
+ // so a stored unlisted value must fail here rather than reach the app server.
+ let thinking: ThinkingLevel | undefined;
+ try {
+ thinking = validateThinking(session.thinking);
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error);
+ yield { type: 'run.completed', status: 'failed', error: detail };
+ return;
+ }
await this.prepareRuntime(runtime, session);
runtime.context.latestResolvedModel = runtime.threadModel;
@@ -533,7 +545,7 @@ export class CodexAdapter implements HarnessAdapter {
threadId: runtime.threadId,
input: [{ type: 'text', text: payload, text_elements: [] }],
cwd: session.cwd,
- ...this.turnOptions(session),
+ ...this.turnOptions(session, thinking),
});
turn.turnId ??= responseId(response, 'turn');
} catch (error) {
@@ -703,13 +715,13 @@ export class CodexAdapter implements HarnessAdapter {
};
}
- private turnOptions(session: Session): Record {
+ private turnOptions(session: Session, thinking: ThinkingLevel | undefined): Record {
const policy = codexPolicyOptions(session.policy);
return {
approvalPolicy: policy.approvalPolicy,
sandboxPolicy: policy.sandboxPolicy,
...(session.model !== undefined && { model: session.model }),
- ...(session.thinking !== undefined && { effort: session.thinking }),
+ ...(thinking !== undefined && { effort: thinking }),
};
}
diff --git a/packages/adapters/grok/src/adapter.spec.ts b/packages/adapters/grok/src/adapter.spec.ts
index 9a906945..571d5166 100644
--- a/packages/adapters/grok/src/adapter.spec.ts
+++ b/packages/adapters/grok/src/adapter.spec.ts
@@ -1,4 +1,4 @@
-import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -84,6 +84,41 @@ console.log(JSON.stringify({type:'response.completed',status:'completed',usage:{
expect(events.at(-1)).toMatchObject({ type: 'run.completed', status: 'failed' });
});
+ // Requirement: the attach/rebuild path revalidates thinking at argv build,
+ // so a stored unlisted value fails the turn instead of reaching the CLI.
+ // Non-redundant: the only deliver-path thinking validation proof for grok.
+ it('revalidates thinking on the attach path and never spawns for an unlisted level', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-grok-attach-'));
+ dirs.push(dir);
+ const marker = join(dir, 'spawned');
+ const argvLog = join(dir, 'argv.json');
+ const command = executable(`
+const fs = require('node:fs');
+fs.writeFileSync(${JSON.stringify(marker)}, 'spawned');
+fs.writeFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)));
+process.stderr.write('boom\\n');
+process.exit(7);
+`);
+ const adapter = new GrokAdapter(command);
+ const attached = adapter.attach('ses_attach');
+ attached.cwd = process.cwd();
+ attached.thinking = 'extreme' as 'high';
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(attached, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({
+ type: 'run.completed', status: 'failed', final_text: expect.stringContaining('extreme'),
+ });
+ expect(() => readFileSync(marker)).toThrow();
+
+ const normalized = adapter.attach('ses_normalized');
+ normalized.cwd = process.cwd();
+ normalized.thinking = ' HIGH ' as 'high';
+ const runEvents: WireEvent[] = [];
+ for await (const event of adapter.deliver(normalized, 'hi')) runEvents.push(event);
+ expect(runEvents.at(-1)).toMatchObject({ type: 'run.completed', status: 'failed', final_text: 'boom' });
+ expect(JSON.parse(readFileSync(argvLog, 'utf8'))).toContain('high');
+ });
+
it('keeps supervision when a completed child still has an active delivery', async () => {
const adapter = new GrokAdapter();
const session = adapter.spawn({ cwd: process.cwd() });
diff --git a/packages/adapters/grok/src/adapter.ts b/packages/adapters/grok/src/adapter.ts
index c14f41c1..4a9035e6 100644
--- a/packages/adapters/grok/src/adapter.ts
+++ b/packages/adapters/grok/src/adapter.ts
@@ -14,7 +14,7 @@ import type {
SpawnOpts,
WireEvent,
} from '@codor/protocol';
-import { PolicySchema } from '@codor/protocol';
+import { normalizeThinkingLevel, PolicySchema, ThinkingLevelSchema } from '@codor/protocol';
import { createTurnTranslator } from './translate.js';
@@ -27,10 +27,24 @@ export function grokApprovalArgs(policy: string | undefined): string[] {
}
// harn:assume canonical-spawn-controls-enforced ref=grok-spawn-control-mapping
+export const GROK_THINKING_LEVELS = ['low', 'medium', 'high'] as const;
+
export function grokArgs(session: Session, payload: string): string[] {
const args = ['-p', payload, '--output-format', 'streaming-json', '--no-auto-update'];
if (session.model !== undefined) args.push('--model', session.model);
- if (session.thinking !== undefined) args.push('--effort', session.thinking);
+ if (session.thinking !== undefined) {
+ // Revalidated at every argv build: the attach/rebuild path never passes
+ // through spawn, so a stored unlisted value must fail here, not at the CLI.
+ const thinking = normalizeThinkingLevel(session.thinking);
+ if (!ThinkingLevelSchema.safeParse(thinking).success ||
+ !(GROK_THINKING_LEVELS as readonly string[]).includes(thinking)) {
+ throw new Error(
+ `adapter 'grok' does not support thinking level '${session.thinking}'; ` +
+ `valid levels: ${GROK_THINKING_LEVELS.join(', ')}`,
+ );
+ }
+ args.push('--effort', thinking);
+ }
args.push(...grokApprovalArgs(session.policy));
if (session.session_ref !== undefined) args.push('--resume', session.session_ref);
return args;
@@ -51,7 +65,7 @@ export class GrokAdapter implements HarnessAdapter {
extensions: false,
// harn:assume harness-declares-supported-thinking-levels ref=grok-thinking-level-declaration
thinking: true,
- thinking_levels: ['low', 'medium', 'high'] as const,
+ thinking_levels: GROK_THINKING_LEVELS,
// harn:end harness-declares-supported-thinking-levels
live_inbox: false,
// Grok exposes --always-approve, but its CLI does not document a native
@@ -74,19 +88,24 @@ export class GrokAdapter implements HarnessAdapter {
spawn(opts: SpawnOpts): Session {
grokApprovalArgs(opts.policy);
- if (opts.thinking !== undefined &&
- !(this.capabilities.thinking_levels as readonly string[]).includes(opts.thinking)) {
- throw new Error(
- `adapter 'grok' does not support thinking level '${opts.thinking}'; ` +
- `valid levels: ${this.capabilities.thinking_levels.join(', ')}`,
- );
+ let thinking: Session['thinking'];
+ if (opts.thinking !== undefined) {
+ const normalized = normalizeThinkingLevel(opts.thinking);
+ if (!ThinkingLevelSchema.safeParse(normalized).success ||
+ !(this.capabilities.thinking_levels as readonly string[]).includes(normalized)) {
+ throw new Error(
+ `adapter 'grok' does not support thinking level '${opts.thinking}'; ` +
+ `valid levels: ${this.capabilities.thinking_levels.join(', ')}`,
+ );
+ }
+ thinking = normalized;
}
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
- thinking: opts.thinking,
+ thinking,
};
}
@@ -106,7 +125,15 @@ export class GrokAdapter implements HarnessAdapter {
payload: string,
hooks: AdapterTurnHooks = {},
): AsyncIterable {
- const args = grokArgs(session, payload);
+ let args: string[];
+ try {
+ args = grokArgs(session, payload);
+ } catch (error) {
+ const translator = createTurnTranslator(session.session_ref);
+ const detail = error instanceof Error ? error.message : String(error);
+ yield* translator.end({ status: 'failed', final_text: detail });
+ return;
+ }
// harn:assume grok-cli-resolves-windows-command-shims ref=grok-windows-cli-spawn-provider
// harn:assume adapter-children-inherit-session-env ref=grok-child-environment
const child = spawn(this.command, args, {
diff --git a/packages/adapters/opencode/NOTES.md b/packages/adapters/opencode/NOTES.md
index c3c33bd5..062226f7 100644
--- a/packages/adapters/opencode/NOTES.md
+++ b/packages/adapters/opencode/NOTES.md
@@ -50,46 +50,73 @@ Codor emits no policy flag for `read-only` or `workspace-write`, and maps
2026-09-12 probe with opencode `0.0.0-dev-202609102034`: `--variant`
names are per-model variant presets (see `opencode models --verbose`), not
a fixed effort scale, and an unsupported or unknown value is accepted and
-silently ignored with exit 0, so a fixed low/medium/high control cannot
-tell the operator whether the value will be used. The adapter therefore
-declares `thinking:false` and never sends `--variant` until variants are
-exposed per model.
-
-Upgrade note: an existing opencode member with a persisted `thinking`
-value fails loudly on its next turn (`validateSpawnOptions` on rebuild,
-`openCodeArgs` on attach) with `adapter 'opencode' does not support
-thinking levels`. Recovery is a manual save in the Configure agent
-dialog, which submits `thinking: null` for an unsupported level and
-clears the stored value.
-
-Presets behave differently: applying an opencode preset with a stored
-`thinking` value is refused with a named message rather than a failed
-turn, and the preset editor blocks save while the unsupported value is
-present. Clear it by switching the draft to another harness and back
-(`reconcileConfig` drops the level, and also clears the model, which
-must be reselected), by replacing the preset through the API without
-`thinking`, or by removing it from the default roster before deleting
-it. A saved default roster holding such a preset fails channel creation
-at roster expansion, not one turn.
+silently ignored with exit 0 on the v1 line.
+
+Thinking support (restored 2026-09-13): the adapter declares `thinking: true`
+with the fixed set `low`, `medium`, `high` plus a bounded custom entry
+(`thinking_custom`). A custom name is an exact per-model variant key:
+normalization trims outer whitespace, folds a recognized level name to
+lowercase (`" HIGH "` becomes `high`), and otherwise preserves case and
+punctuation. Control characters, inner whitespace, a leading `-`, `#`, and
+names over 64 characters are rejected. Default omits the variant. OpenCode
+controls the effective behavior, including its native configuration.
## Invocation
-New turn:
+Invocation is per detected line. The version probe runs with the session cwd
+and environment, and successful detections cache per adapter instance under a
+bounded LRU (16 entries) keyed by PATH (last case-insensitive match on
+win32) plus cwd exactly when cwd can change resolution (a relative command,
+or a relative or empty PATH entry; always on Windows, where the launcher
+searches cwd first). An unknown result is never cached, so each unrecognized
+turn retries with up to two probes. Codor probes ` --version`
+(fixed argv, no shell, hard timeout, capped output):
+
+- `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 other banner (including a future `opencode v3.0.0`) falls back to
+ `run --help`: `--variant` without `#variant` means v1, `#variant` without
+ `--variant` means v2, both or neither means unknown.
+
+New turn (v1):
```text
-opencode run --format json [--model PROVIDER/MODEL] [--auto] PAYLOAD
+opencode run --format json [--model PROVIDER/MODEL] [--auto] [--variant VALUE] PAYLOAD
```
-Continued turn:
+New turn (v2, `--variant` does not exist; the variant rides the model):
```text
-opencode run --format json [--model PROVIDER/MODEL] [--auto] --session SESSION_ID PAYLOAD
+opencode run --format json [--model PROVIDER/MODEL#VALUE] [--auto] PAYLOAD
```
-Codor never sends `--variant`. The CLI accepts it, but the name must come
-from the selected model's own `variants` object (`opencode models
---verbose`), which varies per model and install and admits operator-defined
-custom names; an unknown name is ignored with exit 0.
+Continued turns add `--session SESSION_ID` on both lines. An unrecognized
+line or a failed probe fails the turn with `opencode line not recognized`
+plus a version token or `(probe failed)` — never raw probe output.
+
+v2 with the Default model and a thinking value resolves the default per turn
+with ` api GET /api/model/default` and sends
+`--model /#` using `data.id` (the selectable catalog
+reference, not `data.modelID`). The resolved model is never persisted;
+`session.model` stays undefined. When `data` is null, the call fails, or the
+output does not parse or validate, the turn fails with a fixed message that
+an explicit model is required, and the stored thinking value is preserved.
+v1 ignores an unknown variant silently (exit 0); v2 fails the turn with
+`Variant unavailable for ` through the existing failed-turn path.
+Codor does not validate a value against the selected model.
+
+2026-09-13 probes (macOS): v1 `run` accepts `--variant`, `--model
+provider/model`, `--session`, `--format json`, `--auto`; v2 `run` has no
+`--variant` and takes `--model provider/model#variant`; v2 rejects
+`--variant` with usage plus non-zero exit and no turn; an unknown v2 variant
+fails the turn with `Variant unavailable for `; v2 JSON error shape
+matches the translator; v2 `GET /api/model/default` returns
+`{location, data: {id, modelID, providerID, variants, ...}}` with `data`
+nullable (`opencode-go/deepseek-v4.1-flash` observed, variants `low`,
+`high`, `max`); v2 stable 2.0.3 exposes the same endpoint (`data` null in
+standalone mode); v2 has no `db` subcommand and `models` output is
+cwd-scoped. OpenCode aliases: `/` is the selectable
+reference, `modelID` the provider-side name.
The process starts in the member's persisted cwd, stdin is closed, stdout is
read through EOF, stderr is bounded for failure detail, and the detached process
@@ -148,7 +175,7 @@ unchanged and never derives prices from tokens.
| ask | false | `run` exposes no question response channel |
| approvals | spawn-time | `--auto` or CLI-owned rejection; no Codor runtime response |
| extensions | false | completed task tools do not provide authoritative child lifecycle |
-| thinking | false | `--variant` is per-model and silently ignored when unsupported; no fixed effort set is offered |
+| thinking | true | fixed `low`/`medium`/`high` plus a bounded custom exact-key variant; v1 `--variant`, v2 `#variant` |
`fixtures/live-pong-1.17.14.jsonl` is the one real authenticated capture required
by P1.7b, using the configured free model and the tiny prompt `Reply PONG only.`.
diff --git a/packages/adapters/opencode/src/adapter.spec.ts b/packages/adapters/opencode/src/adapter.spec.ts
index 51b001e7..160e6be6 100644
--- a/packages/adapters/opencode/src/adapter.spec.ts
+++ b/packages/adapters/opencode/src/adapter.spec.ts
@@ -1,23 +1,27 @@
-import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { delimiter, join } from 'node:path';
import type { WireEvent } from '@codor/protocol';
import { afterEach, describe, expect, it } from 'vitest';
import {
+ OPENCODE_THINKING_LEVELS,
+ OPENCODE_V2_EXPLICIT_MODEL_MESSAGE,
OpenCodeAdapter,
openCodeArgs,
openCodeAutoApprove,
+ openCodeDetectionKey,
+ unknownOpenCodeLineMessage,
} from './adapter.js';
const dirs: string[] = [];
-function executable(source: string): string {
+function executable(source: string, version = '1.18.30'): string {
const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-adapter-'));
dirs.push(dir);
const path = join(dir, 'fake-opencode');
- writeFileSync(path, `#!/usr/bin/env node\n${source}`);
+ writeFileSync(path, `#!/usr/bin/env node\nif (process.argv[2] === '--version') { console.log(${JSON.stringify(version)}); process.exit(0); }\n${source}`);
chmodSync(path, 0o755);
return path;
}
@@ -43,24 +47,28 @@ describe('OpenCode subprocess and capability conformance', () => {
});
// harn:assume harness-declares-supported-thinking-levels ref=opencode-thinking-level-regression
- it('maps canonical policies to documented argv and offers no thinking control', () => {
- // Verifies the issue #30 fix: --variant names are per-model presets and an
- // unsupported value is silently ignored, so no fixed effort set is offered.
- const base = { harness: 'opencode', cwd: '/work' };
- expect(openCodeArgs({ ...base, policy: 'read-only' }, 'go')).not.toContain('--auto');
- expect(openCodeArgs({ ...base, policy: 'workspace-write' }, 'go')).not.toContain('--auto');
- expect(openCodeArgs({ ...base, policy: 'full-access' }, 'go')).toContain('--auto');
- expect(openCodeArgs({ ...base }, 'go')).not.toContain('--variant');
- for (const thinking of ['low', 'medium', 'high', 'xhigh'] as const) {
- expect(() => openCodeArgs({ ...base, thinking }, 'go')).toThrow(
- "adapter 'opencode' does not support thinking levels",
- );
- }
- expect(new OpenCodeAdapter().capabilities.thinking).toBe(false);
- expect(new OpenCodeAdapter().capabilities).not.toHaveProperty('thinking_levels');
- expect(() => new OpenCodeAdapter().spawn({ cwd: '/work', thinking: 'low' })).toThrow(
- "adapter 'opencode' does not support thinking levels",
- );
+ // Requirement: the adapter declares fixed levels plus custom entry and
+ // normalizes at spawn. Non-redundant: the only capability-shape proof.
+ it('declares fixed thinking levels plus a bounded custom entry, normalized at the boundary', () => {
+ // Verifies the issue #30 restore: low/medium/high are offered again and a
+ // custom exact-key variant is accepted; malformed shapes are refused.
+ expect([...OPENCODE_THINKING_LEVELS]).toEqual(['low', 'medium', 'high']);
+ const capabilities = new OpenCodeAdapter().capabilities;
+ expect(capabilities.thinking).toBe(true);
+ expect(capabilities.thinking_levels).toEqual(['low', 'medium', 'high']);
+ expect(capabilities.thinking_custom).toBe(true);
+ expect(new OpenCodeAdapter().spawn({ cwd: '/work', thinking: 'low' }).thinking).toBe('low');
+ expect(new OpenCodeAdapter().spawn({ cwd: '/work', thinking: 'extreme' }).thinking)
+ .toBe('extreme');
+ expect(new OpenCodeAdapter().spawn({ cwd: '/work', thinking: ' HIGH ' }).thinking)
+ .toBe('high');
+ expect(new OpenCodeAdapter().spawn({ cwd: '/work', thinking: 'Minimal' }).thinking)
+ .toBe('Minimal');
+ expect(new OpenCodeAdapter().spawn({ cwd: '/work' }).thinking).toBeUndefined();
+ for (const bad of ['has space', 'bad#value', '-lead', 'a\nb']) {
+ expect(() => new OpenCodeAdapter().spawn({ cwd: '/work', thinking: bad as 'low' }))
+ .toThrow('unknown thinking level');
+ }
});
// harn:end harness-declares-supported-thinking-levels
@@ -90,7 +98,7 @@ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_existing',part:{ty
'--session', 'ses_existing',
'PONG',
],
- cwd,
+ cwd: realpathSync(cwd),
input: '',
});
expect(done.usage).toEqual({ input_tokens: 1, output_tokens: 2, cost_usd: 0.01 });
@@ -190,7 +198,7 @@ describe('the declared policy mapping matches the arguments actually built', ()
it('declares a flag only where it emits one, and null where it enforces nothing', () => {
const { policies } = new OpenCodeAdapter().capabilities;
for (const [policy, native] of Object.entries(policies)) {
- const args = openCodeArgs({ harness: 'opencode', cwd: '/work', policy }, 'go');
+ const args = openCodeArgs({ harness: 'opencode', cwd: '/work', policy }, 'go', 'v1');
expect(args.includes('--auto'), policy).toBe(native !== null);
expect(openCodeAutoApprove(policy), policy).toBe(native !== null);
}
@@ -200,7 +208,823 @@ describe('the declared policy mapping matches the arguments actually built', ()
it('builds the SAME arguments for both unenforced levels', () => {
const base = { harness: 'opencode', cwd: '/work' };
- expect(openCodeArgs({ ...base, policy: 'read-only' }, 'go'))
- .toEqual(openCodeArgs({ ...base, policy: 'workspace-write' }, 'go'));
+ expect(openCodeArgs({ ...base, policy: 'read-only' }, 'go', 'v1'))
+ .toEqual(openCodeArgs({ ...base, policy: 'workspace-write' }, 'go', 'v1'));
+ });
+});
+
+// harn:assume harness-declares-supported-thinking-levels ref=opencode-line-argv-regression
+describe('opencode line detection and per-line argv', () => {
+ const runSuccess = `
+if (process.argv[2] === 'run' && process.argv[3] !== '--help') {
+ const fs = require('node:fs');
+ fs.writeFileSync(RUN_LOG, JSON.stringify(process.argv.slice(2)));
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_line',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_line',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_line',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+`;
+
+ function lineFake(options: {
+ version?: string;
+ versionExit?: number;
+ help?: string;
+ versionLog?: string;
+ runLog?: string;
+ apiBody?: unknown;
+ apiExit?: number;
+ apiHang?: boolean;
+ apiLog?: string;
+ }): string {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-line-'));
+ dirs.push(dir);
+ const path = join(dir, 'fake-opencode');
+ const runLog = options.runLog ?? join(dir, 'run-argv.json');
+ const lines: string[] = [
+ '#!/usr/bin/env node',
+ 'const fs = require(\'node:fs\');',
+ `const RUN_LOG = ${JSON.stringify(runLog)};`,
+ ];
+ lines.push('const argv = process.argv.slice(2);');
+ lines.push('if (argv[0] === \'--version\') {');
+ if (options.versionLog !== undefined) {
+ lines.push(` fs.appendFileSync(${JSON.stringify(options.versionLog)}, 'version\\n');`);
+ }
+ if (options.versionExit !== undefined && options.versionExit !== 0) {
+ lines.push(` process.stderr.write('version boom\\n'); process.exit(${String(options.versionExit)});`);
+ } else {
+ lines.push(` console.log(${JSON.stringify(options.version ?? '1.18.30')}); process.exit(0);`);
+ }
+ lines.push('}');
+ lines.push('if (argv[0] === \'run\' && argv[1] === \'--help\') {');
+ if (options.help === undefined) {
+ lines.push(' process.exit(1);');
+ } else {
+ lines.push(` console.log(${JSON.stringify(options.help)}); process.exit(0);`);
+ }
+ lines.push('}');
+ lines.push('if (argv[0] === \'api\') {');
+ if (options.apiLog !== undefined) {
+ lines.push(` fs.appendFileSync(${JSON.stringify(options.apiLog)}, 'api\\n');`);
+ }
+ if (options.apiHang === true) {
+ lines.push(' setInterval(() => {}, 1000);');
+ } else if (options.apiExit !== undefined && options.apiExit !== 0) {
+ lines.push(` process.exit(${String(options.apiExit)});`);
+ } else if (options.apiBody !== undefined) {
+ const body = typeof options.apiBody === 'string'
+ ? options.apiBody
+ : JSON.stringify(options.apiBody);
+ lines.push(` console.log(${JSON.stringify(body)}); process.exit(0);`);
+ } else {
+ lines.push(' process.exit(1);');
+ }
+ lines.push('}');
+ lines.push(runSuccess);
+ lines.push('process.stderr.write(\'unexpected argv \' + JSON.stringify(argv) + \'\\n\'); process.exit(7);');
+ writeFileSync(path, lines.join('\n'));
+ chmodSync(path, 0o755);
+ return path;
+ }
+
+ async function deliverOnce(command: string, session: Parameters[0], payload = 'hi'): Promise {
+ const adapter = new OpenCodeAdapter(command);
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, payload)) events.push(event);
+ return events;
+ }
+
+ // Requirement: v1 Default omits the variant while fixed and custom values
+ // arrive normalized as `--variant`. Non-redundant: the only argv-shape proof for v1.
+ it('sends no variant by default and --variant for fixed and custom values on v1', async () => {
+ for (const version of ['1.18.30', '0.0.0-dev-202609102034']) {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-runlog-'));
+ dirs.push(dir);
+ const runLog = join(dir, 'run.json');
+ const command = lineFake({ version, runLog });
+ const adapter = new OpenCodeAdapter(command);
+ const plain = adapter.spawn({ cwd: process.cwd(), model: 'opencode/m' });
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(plain, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ type: 'run.completed', status: 'completed' });
+ expect(JSON.parse(readFileSync(runLog, 'utf8'))).not.toContain('--variant');
+
+ for (const thinking of ['low', ' HIGH ', 'Minimal'] as const) {
+ const session = adapter.spawn({ cwd: process.cwd(), model: 'opencode/m', thinking: thinking as 'low' });
+ const runEvents: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) runEvents.push(event);
+ expect(runEvents.at(-1)).toMatchObject({ type: 'run.completed', status: 'completed' });
+ const argv = JSON.parse(readFileSync(runLog, 'utf8')) as string[];
+ const expected = thinking.trim().toLowerCase() === 'high' ? 'high' : thinking.trim();
+ expect(argv).toContain('--variant');
+ expect(argv[argv.indexOf('--variant') + 1]).toBe(expected);
+ }
+ }
+ });
+
+ // Requirement: v2 carries the variant as a `#` suffix and never sends
+ // `--variant`, without touching the default-model endpoint when a model is set.
+ // Non-redundant: the only `#variant` argv proof.
+ it('emits --model with a #variant suffix on v2 and never sends --variant', async () => {
+ for (const version of ['opencode v2.0.3', 'opencode2 v0.0.0-dev-19272']) {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-runlog-'));
+ dirs.push(dir);
+ const runLog = join(dir, 'run.json');
+ const apiLog = join(dir, 'api.log');
+ const command = lineFake({ version, runLog, apiLog });
+ const adapter = new OpenCodeAdapter(command);
+ const session = adapter.spawn({ cwd: process.cwd(), model: 'opencode/m', thinking: ' HIGH ' });
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ type: 'run.completed', status: 'completed' });
+ const argv = JSON.parse(readFileSync(runLog, 'utf8')) as string[];
+ expect(argv).not.toContain('--variant');
+ expect(argv).toContain('--model');
+ expect(argv[argv.indexOf('--model') + 1]).toBe('opencode/m#high');
+ expect(existsSync(apiLog)).toBe(false);
+ }
+ });
+
+ // Requirement: v2 Default-model resolution uses `data.id` (not `modelID`)
+ // and never persists the resolved model. Non-redundant: the only id-selection proof.
+ it('resolves the v2 default model by id and preserves the stored session', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-runlog-'));
+ dirs.push(dir);
+ const runLog = join(dir, 'run.json');
+ const command = lineFake({
+ version: 'opencode v2.0.3',
+ runLog,
+ apiBody: { data: { id: 'deepseek-v4.1-flash', modelID: 'provider-side-name', providerID: 'opencode-go', variants: [{ id: 'high' }] } },
+ });
+ const adapter = new OpenCodeAdapter(command);
+ const session = adapter.spawn({ cwd: process.cwd(), thinking: 'high' });
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ type: 'run.completed', status: 'completed' });
+ const argv = JSON.parse(readFileSync(runLog, 'utf8')) as string[];
+ expect(argv[argv.indexOf('--model') + 1]).toBe('opencode-go/deepseek-v4.1-flash#high');
+ expect(session.model).toBeUndefined();
+ expect(session.thinking).toBe('high');
+ });
+
+ // Requirement: every unusable-default shape fails with the fixed
+ // explicit-model message, builds no argv, and preserves stored thinking.
+ // Non-redundant: the only fallback-path coverage.
+ it('fails the turn with a fixed message when the v2 default is unusable', async () => {
+ const cases: { name: string; apiBody?: unknown; apiExit?: number; apiHang?: boolean }[] = [
+ { name: 'null data', apiBody: { data: null } },
+ { name: 'non-zero exit', apiExit: 1 },
+ { name: 'hang', apiHang: true },
+ { name: 'non-json', apiBody: 'not json at all' },
+ { name: 'missing id', apiBody: { data: { providerID: 'p' } } },
+ { name: 'missing provider', apiBody: { data: { id: 'm' } } },
+ { name: 'numeric id', apiBody: { data: { id: 42, providerID: 'p' } } },
+ { name: 'bad id shape', apiBody: { data: { id: 'has space', providerID: 'p' } } },
+ { name: 'hash id', apiBody: { data: { id: 'a#b', providerID: 'p' } } },
+ { name: 'over length', apiBody: { data: { id: 'x'.repeat(300), providerID: 'p' } } },
+ ];
+ for (const failure of cases) {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-runlog-'));
+ dirs.push(dir);
+ const runLog = join(dir, 'run.json');
+ const command = lineFake({ version: 'opencode v2.0.3', runLog, apiBody: failure.apiBody, apiExit: failure.apiExit, apiHang: failure.apiHang });
+ const adapter = new OpenCodeAdapter(command);
+ const session = adapter.spawn({ cwd: process.cwd(), thinking: 'high' });
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) events.push(event);
+ const done = events.at(-1) as Extract;
+ expect(done.status, failure.name).toBe('failed');
+ expect(done.final_text, failure.name).toBe(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE);
+ expect(existsSync(runLog), failure.name).toBe(false);
+ expect(session.thinking, failure.name).toBe('high');
+ expect(session.model, failure.name).toBeUndefined();
+ }
+ }, 30_000);
+
+ // Requirement: the default resolves per turn (never cached, never stored)
+ // and the endpoint is skipped unless v2 needs it. Non-redundant: the only
+ // freshness and call-skipping proof.
+ it('resolves a fresh default per turn and never calls the endpoint without a v2 thinking need', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-count-'));
+ dirs.push(dir);
+ const apiCount = join(dir, 'api.count');
+ const runLog = join(dir, 'run.json');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') { console.log('opencode v2.0.3'); process.exit(0); }
+if (argv[0] === 'api') {
+ const n = fs.existsSync(${JSON.stringify(apiCount)}) ? Number(fs.readFileSync(${JSON.stringify(apiCount)}, 'utf8')) : 0;
+ fs.writeFileSync(${JSON.stringify(apiCount)}, String(n + 1));
+ const id = n === 0 ? 'first-model' : 'second-model';
+ console.log(JSON.stringify({data:{id, providerID:'opencode-go'}}));
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ const fs2 = require('node:fs');
+ fs2.writeFileSync(${JSON.stringify(runLog)}, JSON.stringify(argv));
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_multi',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_multi',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_multi',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const session = adapter.spawn({ cwd: process.cwd(), thinking: 'high' });
+ for await (const event of adapter.deliver(session, 'one')) {
+ if (event.type === 'run.completed') {
+ expect(event.status).toBe('completed');
+ }
+ }
+ expect(JSON.parse(readFileSync(runLog, 'utf8'))).toContain('opencode-go/first-model#high');
+ for await (const event of adapter.deliver(session, 'two')) {
+ if (event.type === 'run.completed') {
+ expect(event.status).toBe('completed');
+ }
+ }
+ expect(JSON.parse(readFileSync(runLog, 'utf8'))).toContain('opencode-go/second-model#high');
+ expect(session.model).toBeUndefined();
+
+ const plainDir = mkdtempSync(join(tmpdir(), 'codor-opencode-plain-'));
+ dirs.push(plainDir);
+ const plainApiLog = join(plainDir, 'api.log');
+ const plain = lineFake({ version: 'opencode v2.0.3', runLog: join(plainDir, 'run.json'), apiLog: plainApiLog });
+ const noThinking = await deliverOnce(plain, new OpenCodeAdapter(plain).spawn({ cwd: process.cwd() }));
+ expect(noThinking.at(-1)).toMatchObject({ status: 'completed' });
+ expect(existsSync(plainApiLog)).toBe(false);
+ const withModel = await deliverOnce(
+ plain,
+ new OpenCodeAdapter(plain).spawn({ cwd: process.cwd(), model: 'opencode/m', thinking: 'high' }),
+ );
+ expect(withModel.at(-1)).toMatchObject({ status: 'completed' });
+
+ const v1Dir = mkdtempSync(join(tmpdir(), 'codor-opencode-v1-'));
+ dirs.push(v1Dir);
+ const v1ApiLog = join(v1Dir, 'api.log');
+ const v1 = lineFake({ version: '1.18.30', runLog: join(v1Dir, 'run.json'), apiLog: v1ApiLog });
+ const v1Events = await deliverOnce(
+ v1,
+ new OpenCodeAdapter(v1).spawn({ cwd: process.cwd(), thinking: 'high' }),
+ );
+ expect(v1Events.at(-1)).toMatchObject({ status: 'completed' });
+ expect(existsSync(v1ApiLog)).toBe(false);
+ });
+
+ // Requirement: unforeseen banners resolve via `run --help`, and unknown
+ // failures carry a token or category but never raw probe output.
+ // Non-redundant: the only fallback and redaction proof.
+ it('detects the line from run --help fallback and fails unknown lines without probe output', async () => {
+ const helpV2 = 'Usage: opencode run [options]\n --model provider/model#variant\n --session ID\n';
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-runlog-'));
+ dirs.push(dir);
+ const runLog = join(dir, 'run.json');
+ const fallback = lineFake({ version: 'opencode v3.0.0', help: helpV2, runLog, apiBody: { data: { id: 'm', providerID: 'p' } } });
+ const fallbackEvents = await deliverOnce(
+ fallback,
+ new OpenCodeAdapter(fallback).spawn({ cwd: process.cwd(), model: 'p/m', thinking: 'high' }),
+ );
+ expect(fallbackEvents.at(-1)).toMatchObject({ status: 'completed' });
+ expect(JSON.parse(readFileSync(runLog, 'utf8'))).toContain('p/m#high');
+
+ const bothHelp = 'flags: --variant and #variant both present';
+ const both = lineFake({ version: 'opencode v3.0.0', help: bothHelp, runLog: join(dir, 'both.json') });
+ const bothEvents = await deliverOnce(
+ both,
+ new OpenCodeAdapter(both).spawn({ cwd: process.cwd(), thinking: 'high' }),
+ );
+ const bothDone = bothEvents.at(-1) as Extract;
+ expect(bothDone.status).toBe('failed');
+ expect(bothDone.final_text).toBe(unknownOpenCodeLineMessage('v3.0.0'));
+
+ const garbage = lineFake({ version: 'garbage-marker-xyz\nsecond line secret', runLog: join(dir, 'garbage.json') });
+ const garbageEvents = await deliverOnce(
+ garbage,
+ new OpenCodeAdapter(garbage).spawn({ cwd: process.cwd() }),
+ );
+ const garbageDone = garbageEvents.at(-1) as Extract;
+ expect(garbageDone.status).toBe('failed');
+ expect(garbageDone.final_text).toBe(unknownOpenCodeLineMessage());
+ expect(garbageDone.final_text).not.toContain('garbage-marker-xyz');
+
+ const versionFail = lineFake({ version: 'x', versionExit: 3, runLog: join(dir, 'fail.json') });
+ const failEvents = await deliverOnce(
+ versionFail,
+ new OpenCodeAdapter(versionFail).spawn({ cwd: process.cwd() }),
+ );
+ expect(failEvents.at(-1)).toMatchObject({ status: 'failed', final_text: unknownOpenCodeLineMessage() });
+ });
+
+ // Requirement: detection runs once per instance and covers the attach path
+ // through deliver. Non-redundant: the only probe-caching and attach proof.
+ it('probes once per instance and detects before argv on attach', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-probe-'));
+ dirs.push(dir);
+ const versionLog = join(dir, 'version.log');
+ const runLog = join(dir, 'run.json');
+ const command = lineFake({ version: '1.18.30', versionLog, runLog });
+ const adapter = new OpenCodeAdapter(command);
+ const first = adapter.spawn({ cwd: process.cwd(), thinking: 'high' });
+ for await (const event of adapter.deliver(first, 'one')) {
+ expect(event).toBeDefined();
+ }
+ const second = adapter.spawn({ cwd: process.cwd(), thinking: 'low' });
+ for await (const event of adapter.deliver(second, 'two')) {
+ expect(event).toBeDefined();
+ }
+ expect(readFileSync(versionLog, 'utf8').trim().split('\n')).toHaveLength(1);
+
+ const fresh = new OpenCodeAdapter(command);
+ const attached = fresh.attach('ses_attach');
+ attached.cwd = process.cwd();
+ attached.model = 'opencode/m';
+ attached.thinking = 'high';
+ const events: WireEvent[] = [];
+ for await (const event of fresh.deliver(attached, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ status: 'completed' });
+ expect(JSON.parse(readFileSync(runLog, 'utf8'))).toContain('--variant');
});
+ // Requirement: a delayed probe never freezes the shared event loop.
+ // Non-redundant: the only event-loop progress proof for detection.
+ it('keeps the event loop responsive while a probe is pending', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-slowprobe-'));
+ dirs.push(dir);
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') {
+ setTimeout(() => { console.log('1.18.30'); process.exit(0); }, 1000);
+ return;
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_slow',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_slow',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_slow',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const started = Date.now();
+ let tickAt = 0;
+ setTimeout(() => { tickAt = Date.now(); }, 100);
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(adapter.spawn({ cwd: process.cwd() }), 'hi')) {
+ events.push(event);
+ }
+ expect(events.at(-1)).toMatchObject({ type: 'run.completed', status: 'completed' });
+ expect(tickAt).toBeGreaterThan(0);
+ expect(tickAt - started).toBeLessThan(900);
+ });
+
+ // Requirement: a transient probe failure retries on the next turn instead of
+ // bricking the instance. Non-redundant: the only detection-retry proof.
+ it('retries detection after an unknown result instead of caching it', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-retry-'));
+ dirs.push(dir);
+ const countPath = join(dir, 'count');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') {
+ const n = fs.existsSync(${JSON.stringify(countPath)})
+ ? Number(fs.readFileSync(${JSON.stringify(countPath)}, 'utf8')) : 0;
+ fs.writeFileSync(${JSON.stringify(countPath)}, String(n + 1));
+ if (n === 0) process.exit(3);
+ console.log('1.18.30');
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_retry',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_retry',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_retry',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const first: WireEvent[] = [];
+ for await (const event of adapter.deliver(adapter.spawn({ cwd: process.cwd() }), 'one')) {
+ first.push(event);
+ }
+ expect(first.at(-1)).toMatchObject({ status: 'failed' });
+ const second: WireEvent[] = [];
+ for await (const event of adapter.deliver(adapter.spawn({ cwd: process.cwd() }), 'two')) {
+ second.push(event);
+ }
+ expect(second.at(-1)).toMatchObject({ status: 'completed' });
+ expect(Number(readFileSync(countPath, 'utf8'))).toBe(2);
+ });
+
+ // Requirement: the default-model probe runs with the session environment so
+ // it resolves the same default the turn runs under. Non-redundant: the only
+ // probe-environment proof.
+ it('carries the session environment into the v2 default-model probe', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-probeenv-'));
+ dirs.push(dir);
+ const marker = join(dir, 'env-marker');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') { console.log('opencode v2.0.3'); process.exit(0); }
+if (argv[0] === 'api') {
+ fs.writeFileSync(${JSON.stringify(marker)}, process.env.CODOR_TEST_DEFAULT_ENV ?? '');
+ console.log(JSON.stringify({data:{id:'m',providerID:'p'}}));
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_env',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_env',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_env',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const session = adapter.spawn({ cwd: process.cwd(), thinking: 'high' });
+ session.env = { CODOR_TEST_DEFAULT_ENV: 'sentinel' };
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ status: 'completed' });
+ expect(readFileSync(marker, 'utf8')).toBe('sentinel');
+ });
+ // Requirement: detection keys on the PATH that resolves the command, so a
+ // session overriding PATH re-probes instead of reusing another line.
+ // Non-redundant: the only per-environment cache proof.
+ it('re-probes when a session PATH differs instead of reusing another line', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-pathkey-'));
+ dirs.push(dir);
+ const countPath = join(dir, 'count');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') {
+ const n = fs.existsSync(${JSON.stringify(countPath)})
+ ? Number(fs.readFileSync(${JSON.stringify(countPath)}, 'utf8')) : 0;
+ fs.writeFileSync(${JSON.stringify(countPath)}, String(n + 1));
+ console.log('1.18.30');
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_path',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_path',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_path',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const first: WireEvent[] = [];
+ for await (const event of adapter.deliver(adapter.spawn({ cwd: process.cwd() }), 'one')) {
+ first.push(event);
+ }
+ expect(first.at(-1)).toMatchObject({ status: 'completed' });
+ const other = adapter.spawn({ cwd: process.cwd() });
+ other.env = { PATH: `/custom/bin${process.env.PATH === undefined ? '' : `:${process.env.PATH}`}` };
+ const second: WireEvent[] = [];
+ for await (const event of adapter.deliver(other, 'two')) second.push(event);
+ expect(second.at(-1)).toMatchObject({ status: 'completed' });
+ const third: WireEvent[] = [];
+ for await (const event of adapter.deliver(adapter.spawn({ cwd: process.cwd() }), 'three')) {
+ third.push(event);
+ }
+ expect(third.at(-1)).toMatchObject({ status: 'completed' });
+ expect(Number(readFileSync(countPath, 'utf8'))).toBe(2);
+ });
+
+ // Requirement: the version probe runs with the session cwd and environment
+ // so it resolves the same command the turn runs. Non-redundant: the only
+ // probe-context proof.
+ it('carries the session cwd and environment into the version probe', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-probectx-'));
+ dirs.push(dir);
+ const marker = join(dir, 'probe-ctx.json');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') {
+ fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({
+ cwd: process.cwd(), marker: process.env.CODOR_TEST_PROBE_ENV ?? '',
+ }));
+ console.log('1.18.30');
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_ctx',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_ctx',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_ctx',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const cwd = mkdtempSync(join(tmpdir(), 'codor-opencode-probecwd-'));
+ dirs.push(cwd);
+ const adapter = new OpenCodeAdapter(path);
+ const session = adapter.spawn({ cwd });
+ session.env = { CODOR_TEST_PROBE_ENV: 'probe-sentinel' };
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ status: 'completed' });
+ expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({
+ cwd: realpathSync(cwd),
+ marker: 'probe-sentinel',
+ });
+ });
+ // Requirement: the cache keys on executable identity, so the same PATH in a
+ // different cwd re-probes instead of sending one line's argv to the other.
+ // Non-redundant: the only cross-cwd identity proof (absolute fakes cannot
+ // exercise PATH resolution at all).
+ it('resolves each cwd to its own line when PATH selects different binaries', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'codor-opencode-identity-'));
+ dirs.push(root);
+ const v1Dir = join(root, 'v1');
+ const v2Dir = join(root, 'v2');
+ mkdirSync(v1Dir, { recursive: true });
+ mkdirSync(v2Dir, { recursive: true });
+ const v1Log = join(root, 'v1-argv.json');
+ const v2Log = join(root, 'v2-argv.json');
+ const runHandler = (log: string) => `
+if (argv[0] === 'run') {
+ fs.writeFileSync(${JSON.stringify(log)}, JSON.stringify(argv));
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_id',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_id',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_id',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`;
+ writeFileSync(join(v1Dir, 'fake-id'), `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') { console.log('1.18.30'); process.exit(0); }
+${runHandler(v1Log)}
+`);
+ writeFileSync(join(v2Dir, 'fake-id'), `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') { console.log('opencode v2.0.3'); process.exit(0); }
+${runHandler(v2Log)}
+`);
+ chmodSync(join(v1Dir, 'fake-id'), 0o755);
+ chmodSync(join(v2Dir, 'fake-id'), 0o755);
+ const adapter = new OpenCodeAdapter('fake-id');
+ const pathWithDot = `.${delimiter}${process.env.PATH ?? ''}`;
+ const first = adapter.spawn({ cwd: v1Dir, model: 'p/m', thinking: 'high' });
+ first.env = { PATH: pathWithDot };
+ const firstEvents: WireEvent[] = [];
+ for await (const event of adapter.deliver(first, 'one')) firstEvents.push(event);
+ expect(firstEvents.at(-1)).toMatchObject({ status: 'completed' });
+ expect(JSON.parse(readFileSync(v1Log, 'utf8'))).toContain('--variant');
+
+ const second = adapter.spawn({ cwd: v2Dir, model: 'p/m', thinking: 'high' });
+ second.env = { PATH: pathWithDot };
+ const secondEvents: WireEvent[] = [];
+ for await (const event of adapter.deliver(second, 'two')) secondEvents.push(event);
+ expect(secondEvents.at(-1)).toMatchObject({ status: 'completed' });
+ expect(JSON.parse(readFileSync(v2Log, 'utf8'))).toContain('p/m#high');
+ });
+ // Requirement: a blank version banner still takes the capability fallback
+ // instead of failing outright. Non-redundant: the only empty-output proof.
+ it('falls back to run --help when version output is blank', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-blank-'));
+ dirs.push(dir);
+ const runLog = join(dir, 'run.json');
+ const helpLog = join(dir, 'help.log');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') { process.exit(0); }
+if (argv[0] === 'run' && argv[1] === '--help') {
+ fs.writeFileSync(${JSON.stringify(helpLog)}, 'help');
+ console.log('Usage: opencode run [options]\\n --variant VALUE\\n --session ID\\n');
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ fs.writeFileSync(${JSON.stringify(runLog)}, JSON.stringify(argv));
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_blank',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_blank',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_blank',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const session = adapter.spawn({ cwd: process.cwd(), model: 'p/m', thinking: 'high' });
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, 'hi')) events.push(event);
+ expect(events.at(-1)).toMatchObject({ status: 'completed' });
+ expect(existsSync(helpLog)).toBe(true);
+ expect(JSON.parse(readFileSync(runLog, 'utf8'))).toContain('--variant');
+ });
+ // Requirement: the cache key tracks every input that resolves the command,
+ // mirroring cross-spawn (last case-insensitive PATH match and process
+ // PATHEXT on win32), with cwd only when it can change resolution.
+ // Non-redundant: the only key-semantics proof.
+ it('keys detection on the inputs that resolve the command', () => {
+ const absolute = '/opt/bin/opencode';
+ expect(openCodeDetectionKey(absolute, '/a', { PATH: '/bin' }))
+ .toBe(openCodeDetectionKey(absolute, '/b', { PATH: '/bin' }));
+ expect(openCodeDetectionKey(absolute, '/a', { PATH: '/bin' }))
+ .not.toBe(openCodeDetectionKey(absolute, '/a', { PATH: '/other' }));
+ expect(openCodeDetectionKey(absolute, '/a', { pAtH: '/bin' }))
+ .not.toBe(openCodeDetectionKey(absolute, '/a', { PATH: '/bin' }));
+ expect(openCodeDetectionKey(absolute, '/a', { pAtH: '/bin' }, 'win32'))
+ .toBe(openCodeDetectionKey(absolute, '/a', { PATH: '/bin' }, 'win32'));
+ // Last case-insensitive match wins on win32, mirroring path-key.
+ expect(openCodeDetectionKey(absolute, '/a', { PATH: '/a', pAtH: '/b' }, 'win32'))
+ .toBe(openCodeDetectionKey(absolute, '/a', { PATH: '/b' }, 'win32'));
+ // PATHEXT is resolved from process.env by the launcher, so session values
+ // do not enter the key on any platform.
+ expect(openCodeDetectionKey(absolute, '/a', {}, 'win32'))
+ .toBe(openCodeDetectionKey(absolute, '/a', { PATHEXT: '.EXE' }, 'win32'));
+ // A relative PATH entry makes cwd resolution-relevant.
+ expect(openCodeDetectionKey('opencode', '/a', { PATH: '/bin' }))
+ .toBe(openCodeDetectionKey('opencode', '/b', { PATH: '/bin' }));
+ expect(openCodeDetectionKey('opencode', '/a', { PATH: `tools/bin${delimiter}/bin` }))
+ .not.toBe(openCodeDetectionKey('opencode', '/b', { PATH: `tools/bin${delimiter}/bin` }));
+ // A relative command always depends on cwd.
+ expect(openCodeDetectionKey('./opencode', '/a', { PATH: '/bin' }))
+ .not.toBe(openCodeDetectionKey('./opencode', '/b', { PATH: '/bin' }));
+ // An absolute command never reads PATH, so not even a trailing colon
+ // (an empty entry) can make cwd matter for it.
+ expect(openCodeDetectionKey(absolute, '/a', { PATH: '/bin:' }))
+ .toBe(openCodeDetectionKey(absolute, '/b', { PATH: '/bin:' }));
+ // which searches the session cwd before PATH on Windows, so bare commands
+ // always carry cwd there; on POSIX `\` is an ordinary filename character.
+ expect(openCodeDetectionKey('opencode', '/a', { PATH: '/bin' }, 'win32'))
+ .not.toBe(openCodeDetectionKey('opencode', '/b', { PATH: '/bin' }, 'win32'));
+ expect(openCodeDetectionKey('C:\\Tools\\opencode', '/a', { PATH: 'C:\\bin' }, 'win32'))
+ .toBe(openCodeDetectionKey('C:\\Tools\\opencode', '/b', { PATH: 'C:\\bin' }, 'win32'));
+ expect(openCodeDetectionKey('//server/share/opencode.exe', '/a', { PATH: 'C:\\bin' }, 'win32'))
+ .toBe(openCodeDetectionKey('//server/share/opencode.exe', '/b', { PATH: 'C:\\bin' }, 'win32'));
+ expect(openCodeDetectionKey('\\\\server\\share\\opencode.exe', '/a', { PATH: 'C:\\bin' }, 'win32'))
+ .toBe(openCodeDetectionKey('\\\\server\\share\\opencode.exe', '/b', { PATH: 'C:\\bin' }, 'win32'));
+ // Incomplete UNC prefixes normalize to the current drive, so cwd stays in
+ // the key for them.
+ expect(openCodeDetectionKey('//server', '/a', { PATH: 'C:\\bin' }, 'win32'))
+ .not.toBe(openCodeDetectionKey('//server', '/b', { PATH: 'C:\\bin' }, 'win32'));
+ expect(openCodeDetectionKey('///opencode.exe', '/a', { PATH: 'C:\\bin' }, 'win32'))
+ .not.toBe(openCodeDetectionKey('///opencode.exe', '/b', { PATH: 'C:\\bin' }, 'win32'));
+ expect(openCodeDetectionKey('\\\\server', '/a', { PATH: 'C:\\bin' }, 'win32'))
+ .not.toBe(openCodeDetectionKey('\\\\server', '/b', { PATH: 'C:\\bin' }, 'win32'));
+ expect(openCodeDetectionKey('foo\\bar', '/a', { PATH: '/bin' }))
+ .toBe(openCodeDetectionKey('foo\\bar', '/b', { PATH: '/bin' }));
+ });
+
+ // Requirement: the bounded cache evicts least-recently-used entries, and a
+ // hit refreshes recency. Without eviction, 17 keys cost 17 probes but the
+ // 17th is never cached; without refresh, repeating the oldest evicts it.
+ // Here the repeat of K1 must stay a hit while K2 (evicted by K17) re-probes:
+ // 18 probes ending with K2. Non-redundant: the only eviction-order proof.
+ it('evicts least-recently-used entries and refreshes on hits', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-evict-'));
+ dirs.push(dir);
+ const countPath = join(dir, 'count');
+ const tagPath = join(dir, 'tags');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') {
+ const tag = String(process.env.CODOR_TEST_EVICT_TAG ?? '?');
+ fs.appendFileSync(${JSON.stringify(tagPath)}, tag + '\\n');
+ const n = fs.existsSync(${JSON.stringify(countPath)})
+ ? Number(fs.readFileSync(${JSON.stringify(countPath)}, 'utf8')) : 0;
+ fs.writeFileSync(${JSON.stringify(countPath)}, String(n + 1));
+ console.log('1.18.30');
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_evict',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_evict',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_evict',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path);
+ const systemPath = process.env.PATH ?? '';
+ const deliverKey = async (tag: string): Promise => {
+ const session = adapter.spawn({ cwd: process.cwd() });
+ session.env = { PATH: `/${tag}${delimiter}${systemPath}`, CODOR_TEST_EVICT_TAG: tag };
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, `turn-${tag}`)) events.push(event);
+ expect(events.at(-1)).toMatchObject({ status: 'completed' });
+ };
+ for (let index = 0; index < 16; index += 1) {
+ await deliverKey(`evict-${String(index)}`);
+ }
+ await deliverKey('evict-0');
+ await deliverKey('evict-16');
+ await deliverKey('evict-0');
+ await deliverKey('evict-1');
+ const tags = readFileSync(tagPath, 'utf8').trim().split('\n');
+ expect(tags).toHaveLength(18);
+ expect(tags.at(-1)).toBe('evict-1');
+ expect(Number(readFileSync(countPath, 'utf8'))).toBe(18);
+ });
+
+ // Requirement: a stale failed probe never deletes a newer cached entry for
+ // the same key. The predecessor waits on a signal file with a probe timeout
+ // far beyond the scenario, so the eviction and replacement strictly precede
+ // its failure: no timing window, no vacuous pass. Non-redundant: the only
+ // concurrent-settlement proof.
+ it('keeps a replaced entry when its evicted predecessor fails late', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-stale-'));
+ dirs.push(dir);
+ const countPath = join(dir, 'count');
+ const signalPath = join(dir, 'release');
+ const path = join(dir, 'fake-opencode');
+ writeFileSync(path, `#!/usr/bin/env node
+const fs = require('node:fs');
+const argv = process.argv.slice(2);
+if (argv[0] === '--version') {
+ const n = fs.existsSync(${JSON.stringify(countPath)})
+ ? Number(fs.readFileSync(${JSON.stringify(countPath)}, 'utf8')) : 0;
+ fs.writeFileSync(${JSON.stringify(countPath)}, String(n + 1));
+ if (process.env.CODOR_TEST_STALL === '1') {
+ const started = Date.now();
+ while (!fs.existsSync(${JSON.stringify(signalPath)}) && Date.now() - started < 55000) {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
+ }
+ process.exit(3);
+ }
+ console.log('1.18.30');
+ process.exit(0);
+}
+if (argv[0] === 'run') {
+ console.log(JSON.stringify({type:'step_start',sessionID:'ses_stale',part:{type:'step-start'}}));
+ console.log(JSON.stringify({type:'text',sessionID:'ses_stale',part:{type:'text',text:'ok',time:{start:1,end:2}}}));
+ console.log(JSON.stringify({type:'step_finish',sessionID:'ses_stale',part:{type:'step-finish',tokens:{input:1,output:1},cost:0}}));
+ process.exit(0);
+}
+process.exit(7);
+`);
+ chmodSync(path, 0o755);
+ const adapter = new OpenCodeAdapter(path, 60_000);
+ const systemPath = process.env.PATH ?? '';
+ const pathFor = (tag: string): string => `/${tag}${delimiter}${systemPath}`;
+ // The first probe waits on the signal file, then fails; 16 other keys
+ // evict its pending entry while it hangs.
+ const stalled = adapter.spawn({ cwd: process.cwd() });
+ stalled.env = { PATH: pathFor('stale'), CODOR_TEST_STALL: '1' };
+ const stalledEvents: WireEvent[] = [];
+ const stalledDone = (async () => {
+ for await (const event of adapter.deliver(stalled, 'stalled')) stalledEvents.push(event);
+ })();
+ // Barrier: the stalled probe must be the first version call, so poll the
+ // count file before inserting anything. Otherwise LRU order — and the
+ // final count — depends on spawn scheduling.
+ for (let waits = 0; waits < 200; waits += 1) {
+ if (existsSync(countPath) && readFileSync(countPath, 'utf8') === '1') break;
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ expect(readFileSync(countPath, 'utf8')).toBe('1');
+ for (let index = 0; index < 16; index += 1) {
+ const session = adapter.spawn({ cwd: process.cwd() });
+ session.env = { PATH: pathFor(`other-${String(index)}`) };
+ const events: WireEvent[] = [];
+ for await (const event of adapter.deliver(session, `other-${String(index)}`)) {
+ events.push(event);
+ }
+ expect(events.at(-1)).toMatchObject({ status: 'completed' });
+ }
+ // Re-probe the first key while its predecessor still hangs: a replacement.
+ const replacement = adapter.spawn({ cwd: process.cwd() });
+ replacement.env = { PATH: pathFor('stale') };
+ const replacementEvents: WireEvent[] = [];
+ for await (const event of adapter.deliver(replacement, 'replacement')) {
+ replacementEvents.push(event);
+ }
+ expect(replacementEvents.at(-1)).toMatchObject({ status: 'completed' });
+ // Release the predecessor only after the replacement is cached, and prove
+ // it was still pending: the stale failure must not delete the replacement,
+ // so one more turn on the key is a cache hit.
+ expect(stalledEvents).toHaveLength(0);
+ writeFileSync(signalPath, 'go');
+ await stalledDone;
+ expect(stalledEvents.at(-1)).toMatchObject({ status: 'failed' });
+ const repeat = adapter.spawn({ cwd: process.cwd() });
+ repeat.env = { PATH: pathFor('stale') };
+ const repeatEvents: WireEvent[] = [];
+ for await (const event of adapter.deliver(repeat, 'repeat')) repeatEvents.push(event);
+ expect(repeatEvents.at(-1)).toMatchObject({ status: 'completed' });
+ expect(Number(readFileSync(countPath, 'utf8'))).toBe(18);
+ }, 30_000);
});
+// harn:end harness-declares-supported-thinking-levels
diff --git a/packages/adapters/opencode/src/adapter.ts b/packages/adapters/opencode/src/adapter.ts
index eb0f32b3..0d40bfdc 100644
--- a/packages/adapters/opencode/src/adapter.ts
+++ b/packages/adapters/opencode/src/adapter.ts
@@ -12,7 +12,14 @@ import type {
ThinkingLevel,
WireEvent,
} from '@codor/protocol';
-import { PolicySchema } from '@codor/protocol';
+import {
+ AGENT_PRESET_MAX_MODEL_LENGTH,
+ AGENT_PRESET_MODEL_ID_REGEX,
+ KNOWN_THINKING_LEVELS,
+ normalizeThinkingLevel,
+ PolicySchema,
+ ThinkingLevelSchema,
+} from '@codor/protocol';
import { createTurnTranslator } from './translate.js';
@@ -20,16 +27,47 @@ const DISCOVER_QUERY =
'SELECT id FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC';
// harn:assume harness-declares-supported-thinking-levels ref=opencode-thinking-level-declaration
-// opencode --variant names are per-model variant presets, not a fixed effort
-// scale: `opencode models --verbose` shows models with `variants: {}`, models
-// without `medium`, and names like `minimal`, `xhigh`, `max`, `none`, and
-// `thinking`. An unknown --variant is accepted and silently ignored (exit 0),
-// so offering a fixed low/medium/high slider cannot tell the operator whether
-// the value will be used. The adapter therefore declares no effort control
-// until variants are exposed per model. See NOTES.md.
-function assertNoThinkingLevel(thinking: ThinkingLevel | undefined): void {
- if (thinking === undefined) return;
- throw new Error(`adapter 'opencode' does not support thinking levels`);
+/**
+ * Fixed thinking choices offered for OpenCode. The `--variant` names are
+ * per-model variant presets resolved by exact object key, so a bounded custom
+ * entry is also accepted (`thinking_custom`). Default omits the variant.
+ * OpenCode controls the effective behavior, including its native configuration.
+ */
+export const OPENCODE_THINKING_LEVELS = ['low', 'medium', 'high'] as const;
+
+export type OpenCodeLine = 'v1' | 'v2' | 'unknown';
+
+/** Fixed failure when the OpenCode line cannot be recognized. No probe output. */
+export function unknownOpenCodeLineMessage(token?: string): string {
+ return token === undefined
+ ? 'opencode line not recognized (probe failed)'
+ : `opencode line not recognized (${token})`;
+}
+
+/** Fixed failure when a v2 thinking turn has no usable default model. */
+export const OPENCODE_V2_EXPLICIT_MODEL_MESSAGE =
+ 'opencode v2 requires an explicit model to use a thinking value';
+
+function assertThinkingValue(value: string): string {
+ const normalized = normalizeThinkingLevel(value);
+ if (normalized === '' || !ThinkingLevelSchema.safeParse(normalized).success) {
+ throw new Error(
+ `unknown thinking level '${value}'; valid levels: ${KNOWN_THINKING_LEVELS.join(', ')}`,
+ );
+ }
+ return normalized;
+}
+
+function normalizedSessionThinking(thinking: ThinkingLevel | undefined): string | undefined {
+ if (thinking === undefined) return undefined;
+ const normalized = normalizeThinkingLevel(thinking);
+ if (normalized === '') return undefined;
+ if (!ThinkingLevelSchema.safeParse(normalized).success) {
+ throw new Error(
+ `unknown thinking level '${String(thinking)}'; valid levels: ${KNOWN_THINKING_LEVELS.join(', ')}`,
+ );
+ }
+ return normalized;
}
function invalidPolicy(policy: string): Error {
@@ -43,13 +81,118 @@ export function openCodeAutoApprove(policy: string | undefined): boolean {
return policy === 'full-access';
}
+/**
+ * Inputs that resolve the command, for the detection cache key. Mirrors
+ * cross-spawn's resolution: PATH from the merged spawn env (last
+ * case-insensitive match on win32), PATHEXT from process.env on win32, and
+ * cwd only when it can change resolution (a relative command, or a relative
+ * or empty PATH entry). Exported for tests.
+ */
+export function openCodeDetectionKey(
+ command: string,
+ cwd: string,
+ env: NodeJS.ProcessEnv,
+ platform: NodeJS.Platform = process.platform,
+): string {
+ const pathValue = resolvePathValue(env, platform);
+ // PATHEXT is deliberately absent: which resolves extensions against
+ // process.env.PATHEXT, which cannot vary between sessions of one daemon.
+ const key: Record = { path: pathValue };
+ if (cwdAffectsResolution(command, pathValue, platform)) key.cwd = cwd;
+ return JSON.stringify(key);
+}
+
+function resolvePathValue(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string {
+ if (platform !== 'win32') return env.PATH ?? '';
+ const matches = Object.keys(env).filter((name) => name.toUpperCase() === 'PATH');
+ const last = matches.at(-1);
+ return last === undefined ? '' : (env[last] ?? '');
+}
+
+function isAbsoluteFor(command: string, platform: NodeJS.Platform): boolean {
+ if (platform === 'win32') {
+ if (/^[a-zA-Z]:[\\/]/.test(command)) return true;
+ // A UNC path needs server and share: `//server` and `///x` normalize to
+ // the current drive and still resolve against cwd.
+ const rest = command.startsWith('\\\\') ? command.slice(2)
+ : command.startsWith('//') ? command.slice(2)
+ : null;
+ if (rest === null) return false;
+ const [server, share] = rest.split(/[\\/]/);
+ return server !== undefined && server !== '' && share !== undefined && share !== '';
+ }
+ return command.startsWith('/');
+}
+
+function cwdAffectsResolution(command: string, pathValue: string, platform: NodeJS.Platform): boolean {
+ // An absolute command never reads PATH, so cwd cannot matter for it. On
+ // Windows a bare command searches the session cwd before PATH; on POSIX a
+ // relative command with a separator resolves against cwd, and a bare one
+ // only through a relative or empty PATH entry.
+ if (isAbsoluteFor(command, platform)) return false;
+ if (platform === 'win32') return true;
+ if (command.includes('/')) return true;
+ return pathValue.split(':').some((entry) => entry === '' || !entry.startsWith('/'));
+}
+
+/** Successful detections cached per key; unknown results retry on the next turn. */
+const DETECTION_CACHE_BOUND = 16;
+
+function cacheDetection(
+ cache: Map>,
+ key: string,
+ pending: Promise<{ line: OpenCodeLine; token?: string }>,
+): void {
+ if (cache.size >= DETECTION_CACHE_BOUND) {
+ const oldest = cache.keys().next();
+ if (!oldest.done) cache.delete(oldest.value);
+ }
+ cache.set(key, pending);
+}
+function sanitizeVersionToken(raw: string): string | undefined {
+ const token = raw.trim().split(/\s+/)[0] ?? '';
+ const clean = token.replace(/[\0-\x1F\x7F]/g, '').slice(0, 32);
+ return clean === '' ? undefined : clean;
+}
+
+function detectLineFromVersionBanner(banner: string): { line: OpenCodeLine; token?: string } {
+ const trimmed = banner.trim();
+ if (/^opencode2? v(2\.\d+\.\d+|0\.0\.0-dev-\d+)\b/.test(trimmed)) return { line: 'v2' };
+ if (/^(1\.\d+\.\d+|0\.0\.0-dev-\d{12})$/.test(trimmed)) return { line: 'v1' };
+ const versionToken = /^opencode2?\s+(v?\S+)/.exec(trimmed)?.[1]
+ ?? /^(v?\d\S*|0\.0\.0-dev-\S+)/.exec(trimmed)?.[1];
+ const token = versionToken === undefined ? undefined : sanitizeVersionToken(versionToken);
+ return token === undefined ? { line: 'unknown' } : { line: 'unknown', token };
+}
+
// harn:assume canonical-spawn-controls-enforced ref=opencode-spawn-control-mapping
-export function openCodeArgs(session: Session, payload: string): string[] {
+export function openCodeArgs(
+ session: Session,
+ payload: string,
+ line: OpenCodeLine,
+ modelOverride?: string,
+): string[] {
const autoApprove = openCodeAutoApprove(session.policy);
+ const thinking = normalizedSessionThinking(session.thinking);
const args = ['run', '--format', 'json'];
- if (session.model !== undefined) args.push('--model', session.model);
- if (autoApprove) args.push('--auto');
- assertNoThinkingLevel(session.thinking);
+ const model = modelOverride ?? session.model;
+ if (line === 'unknown') {
+ throw new Error(unknownOpenCodeLineMessage());
+ }
+ if (line === 'v1') {
+ if (model !== undefined) args.push('--model', model);
+ if (autoApprove) args.push('--auto');
+ if (thinking !== undefined) args.push('--variant', thinking);
+ } else {
+ if (thinking !== undefined) {
+ if (model === undefined || model === '') throw new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE);
+ if (model.includes('#')) throw new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE);
+ args.push('--model', `${model}#${thinking}`);
+ } else if (model !== undefined) {
+ args.push('--model', model);
+ }
+ if (autoApprove) args.push('--auto');
+ }
if (session.session_ref !== undefined) args.push('--session', session.session_ref);
args.push(payload);
return args;
@@ -65,7 +208,9 @@ export class OpenCodeAdapter implements HarnessAdapter {
ask: false,
approvals: 'spawn-time',
extensions: false,
- thinking: false,
+ thinking: true,
+ thinking_levels: [...OPENCODE_THINKING_LEVELS],
+ thinking_custom: true,
// harn:assume live-inbox-capability-is-evidence-backed-v2 ref=opencode-live-inbox-capability
live_inbox: false,
// harn:end live-inbox-capability-is-evidence-backed-v2
@@ -81,23 +226,189 @@ export class OpenCodeAdapter implements HarnessAdapter {
} as const;
private readonly children = new WeakMap();
+ private readonly lineDetections = new Map>();
- constructor(private readonly command = 'opencode') {}
+ constructor(
+ private readonly command = 'opencode',
+ private readonly probeTimeoutMs = 5_000,
+ ) {}
spawn(opts: SpawnOpts): Session {
if (opts.policy !== undefined && !PolicySchema.safeParse(opts.policy).success) {
throw invalidPolicy(opts.policy);
}
- assertNoThinkingLevel(opts.thinking);
+ let thinking: ThinkingLevel | undefined;
+ if (opts.thinking !== undefined) {
+ const normalized = assertThinkingValue(opts.thinking);
+ thinking = normalized as ThinkingLevel;
+ }
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
+ ...(thinking !== undefined && { thinking }),
};
}
// harn:end canonical-spawn-controls-enforced
+ private detectLine(session: Session): Promise<{ line: OpenCodeLine; token?: string }> {
+ // Successful detections cache per instance under an LRU bound, keyed by
+ // PATH (last case-insensitive match on win32) plus cwd exactly when cwd
+ // can change resolution. An unknown result is never cached: a transient
+ // probe failure must not brick later turns.
+ const key = openCodeDetectionKey(this.command, session.cwd, { ...process.env, ...session.env });
+ const cached = this.lineDetections.get(key);
+ if (cached !== undefined) {
+ this.lineDetections.delete(key);
+ this.lineDetections.set(key, cached);
+ return cached;
+ }
+ const pending = this.probeLine(session).then((result) => {
+ // Delete only our own entry: a concurrent probe may have evicted and
+ // replaced it while this one was in flight.
+ if (result.line === 'unknown' && this.lineDetections.get(key) === pending) {
+ this.lineDetections.delete(key);
+ }
+ return result;
+ });
+ cacheDetection(this.lineDetections, key, pending);
+ return pending;
+ }
+
+ /** Fixed argv, no shell, hard timeout, capped output. Never blocks the event loop. */
+ private probeCommand(
+ args: string[],
+ session: Session,
+ ): Promise<{ status: number | null; stdout: string }> {
+ return new Promise((resolve) => {
+ const child = spawn(this.command, args, {
+ cwd: session.cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ // harn:assume adapter-children-inherit-session-env ref=opencode-probe-environment
+ env: { ...process.env, ...session.env },
+ // harn:end adapter-children-inherit-session-env
+ });
+ let stdout = '';
+ let settled = false;
+ const finish = (status: number | null): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ try { child.kill('SIGKILL'); } catch { /* already exited */ }
+ resolve({ status, stdout });
+ };
+ const timer = setTimeout(() => finish(null), this.probeTimeoutMs);
+ timer.unref?.();
+ child.stdout?.setEncoding('utf8');
+ child.stderr?.setEncoding('utf8');
+ child.stdout?.on('data', (chunk: string) => {
+ stdout = `${stdout}${chunk}`.slice(-1_000_000);
+ });
+ // Drain stderr so a chatty probe can never wedge on a full pipe buffer.
+ child.stderr?.on('data', () => undefined);
+ child.once('error', () => finish(null));
+ child.once('close', (code) => finish(code));
+ });
+ }
+
+ private async probeLine(session: Session): Promise<{ line: OpenCodeLine; token?: string }> {
+ const version = await this.probeCommand(['--version'], session);
+ if (version.status !== 0) return { line: 'unknown' };
+ const banner = version.stdout.split('\n')[0] ?? '';
+ // An empty banner still takes the capability fallback: a command that
+ // prints its version to stderr classifies via `run --help`.
+ const direct = detectLineFromVersionBanner(banner);
+ if (direct.line !== 'unknown') return direct;
+ if (direct.token !== undefined) {
+ // A version-shaped banner outside both families may still resolve via
+ // the capability fallback; otherwise the token identifies the failure.
+ const fallback = await this.detectLineFromHelp(session);
+ if (fallback.line !== 'unknown') return fallback;
+ return direct;
+ }
+ return this.detectLineFromHelp(session);
+ }
+
+ private async detectLineFromHelp(session: Session): Promise<{ line: OpenCodeLine; token?: string }> {
+ const help = await this.probeCommand(['run', '--help'], session);
+ if (help.status !== 0) return { line: 'unknown' };
+ const hasVariantFlag = help.stdout.includes('--variant');
+ const hasHashVariant = help.stdout.includes('#variant');
+ if (hasVariantFlag && !hasHashVariant) return { line: 'v1' };
+ if (hasHashVariant && !hasVariantFlag) return { line: 'v2' };
+ return { line: 'unknown' };
+ }
+
+ private resolveV2DefaultModel(session: Session): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(this.command, ['api', 'GET', '/api/model/default'], {
+ cwd: session.cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ // harn:assume adapter-children-inherit-session-env ref=opencode-default-model-environment
+ env: { ...process.env, ...session.env },
+ // harn:end adapter-children-inherit-session-env
+ });
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ const fail = (error: Error): void => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ try { child.kill('SIGKILL'); } catch { /* already exited */ }
+ reject(error);
+ };
+ const timer = setTimeout(() => {
+ fail(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ }, 5_000);
+ timer.unref?.();
+ child.stdout?.setEncoding('utf8');
+ child.stderr?.setEncoding('utf8');
+ child.stdout?.on('data', (chunk: string) => {
+ stdout = `${stdout}${chunk}`.slice(-1_000_000);
+ if (stdout.length >= 1_000_000) fail(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ });
+ child.stderr?.on('data', (chunk: string) => {
+ stderr = `${stderr}${chunk}`.slice(-8192);
+ });
+ child.once('error', () => fail(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE)));
+ child.once('close', (code) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (code !== 0) {
+ reject(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ return;
+ }
+ try {
+ const parsed = JSON.parse(stdout) as { data?: unknown };
+ const data = parsed.data as { providerID?: unknown; id?: unknown } | null | undefined;
+ if (typeof data !== 'object' || data === null) {
+ reject(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ return;
+ }
+ const { providerID, id } = data;
+ if (typeof providerID !== 'string' || providerID === ''
+ || typeof id !== 'string' || id === '') {
+ reject(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ return;
+ }
+ const reference = `${providerID}/${id}`;
+ if (reference.includes('#')
+ || reference.length > AGENT_PRESET_MAX_MODEL_LENGTH
+ || !AGENT_PRESET_MODEL_ID_REGEX.test(reference)) {
+ reject(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ return;
+ }
+ resolve(reference);
+ } catch {
+ reject(new Error(OPENCODE_V2_EXPLICIT_MODEL_MESSAGE));
+ }
+ });
+ });
+ }
+
// harn:assume adapters-own-their-model-catalog ref=opencode-model-discovery
/**
* opencode's models come from the operator's OWN configured providers, so no
@@ -132,7 +443,53 @@ export class OpenCodeAdapter implements HarnessAdapter {
payload: string,
hooks: AdapterTurnHooks = {},
): AsyncIterable {
- const args = openCodeArgs(session, payload);
+ const detected = await this.detectLine(session);
+ if (detected.line === 'unknown') {
+ const translator = createTurnTranslator();
+ yield* translator.end({
+ status: 'failed',
+ final_text: unknownOpenCodeLineMessage(detected.token),
+ });
+ return;
+ }
+ let thinking: string | undefined;
+ try {
+ thinking = normalizedSessionThinking(session.thinking);
+ } catch (error) {
+ const translator = createTurnTranslator();
+ yield* translator.end({
+ status: 'failed',
+ final_text: error instanceof Error ? error.message : String(error),
+ });
+ return;
+ }
+ let modelForTurn = session.model;
+ if (detected.line === 'v2' && thinking !== undefined && modelForTurn === undefined) {
+ try {
+ modelForTurn = await this.resolveV2DefaultModel(session);
+ } catch (error) {
+ const translator = createTurnTranslator();
+ yield* translator.end({
+ status: 'failed',
+ final_text: error instanceof Error ? error.message : String(error),
+ });
+ return;
+ }
+ }
+ let args: string[];
+ try {
+ const sessionForArgs: Session = thinking === undefined
+ ? { ...session, thinking: undefined }
+ : { ...session, thinking: thinking as ThinkingLevel };
+ args = openCodeArgs(sessionForArgs, payload, detected.line, modelForTurn);
+ } catch (error) {
+ const translator = createTurnTranslator();
+ yield* translator.end({
+ status: 'failed',
+ final_text: error instanceof Error ? error.message : String(error),
+ });
+ return;
+ }
const child = spawn(this.command, args, {
cwd: session.cwd,
diff --git a/packages/adapters/opencode/src/index.spec.ts b/packages/adapters/opencode/src/index.spec.ts
index 880d7752..136f34d6 100644
--- a/packages/adapters/opencode/src/index.spec.ts
+++ b/packages/adapters/opencode/src/index.spec.ts
@@ -14,7 +14,9 @@ describe('@codor/adapter-opencode barrel', () => {
ask: false,
approvals: 'spawn-time',
extensions: false,
- thinking: false,
+ thinking: true,
+ thinking_levels: ['low', 'medium', 'high'],
+ thinking_custom: true,
live_inbox: false,
// Only full-access is enforced; the other two defer to opencode's own rules.
policies: {
diff --git a/packages/protocol/src/adapter.ts b/packages/protocol/src/adapter.ts
index bcfcc6c3..6f07211d 100644
--- a/packages/protocol/src/adapter.ts
+++ b/packages/protocol/src/adapter.ts
@@ -10,13 +10,47 @@ export type Policy = z.infer;
// harn:assume harness-declares-supported-thinking-levels ref=adapter-thinking-level-contract
/** Legacy choices for thinking-capable third-party adapters without an exact declaration. */
export const DEFAULT_THINKING_LEVELS = ['low', 'medium', 'high'] as const;
-export const ThinkingLevelSchema = z.enum([
+/** Every recognized fixed level across all strict adapters. */
+export const KNOWN_THINKING_LEVELS = [
...DEFAULT_THINKING_LEVELS,
'xhigh',
'max',
'ultra',
'ultracode',
-]);
+] as const;
+/** Bound for an operator-supplied custom variant name (OpenCode exact key). */
+export const THINKING_CUSTOM_MAX_LENGTH = 64;
+
+/**
+ * Normalize one submitted thinking value. Trims outer whitespace; a trimmed
+ * value that case-insensitively equals a recognized level folds to that level
+ * (`" HIGH "` becomes `high`); every other name keeps its case and punctuation
+ * because OpenCode resolves variants by exact object key. Blank stays blank so
+ * callers can map it to Default (no value).
+ */
+export function normalizeThinkingLevel(value: string): string {
+ const trimmed = value.trim();
+ const lower = trimmed.toLowerCase();
+ if ((KNOWN_THINKING_LEVELS as readonly string[]).includes(lower)) return lower;
+ return trimmed;
+}
+
+const CustomThinkingValueSchema = z.string().regex(
+ // No whitespace inside, no `#` (the v2 model#variant separator), no control
+ // characters, no leading `-` (flag-injection shape), bounded length.
+ new RegExp(`^(?!-)[^\\s#\\x00-\\x1F\\x7F\\x80-\\x9F]{1,${String(THINKING_CUSTOM_MAX_LENGTH)}}$`),
+ 'custom thinking value has an invalid format',
+);
+
+/** The recognized fixed levels only. Declared `thinking_levels` must stay in this set. */
+export const KnownThinkingLevelSchema = z.enum(KNOWN_THINKING_LEVELS);
+export type KnownThinkingLevel = z.infer;
+
+/** Persisted and wire value: a recognized level or a bounded custom variant name. */
+export const ThinkingLevelSchema = z.preprocess(
+ (value) => (typeof value === 'string' ? normalizeThinkingLevel(value) : value),
+ z.union([z.enum(KNOWN_THINKING_LEVELS), CustomThinkingValueSchema]),
+);
export type ThinkingLevel = z.infer;
/** Harness-native session/rollout id — the resume token and identity anchor. */
@@ -120,6 +154,12 @@ export interface AdapterCapabilities {
thinking: boolean;
/** Exact accepted values. Absent preserves low/medium/high for older adapters. */
thinking_levels?: readonly ThinkingLevel[];
+ /**
+ * When true, the harness also accepts a bounded custom variant name outside
+ * the declared list (OpenCode exact-key variants). The registry gate enforces
+ * this; strict adapters keep their exact-list rejections.
+ */
+ thinking_custom?: boolean;
// harn:end harness-declares-supported-thinking-levels
// harn:assume harness-declares-what-a-policy-becomes ref=adapter-policy-capability
// What each canonical policy ACTUALLY becomes for this harness — the native mode it
diff --git a/packages/protocol/src/agent-presets.spec.ts b/packages/protocol/src/agent-presets.spec.ts
index 2fbc4ffc..1aa610d8 100644
--- a/packages/protocol/src/agent-presets.spec.ts
+++ b/packages/protocol/src/agent-presets.spec.ts
@@ -73,13 +73,15 @@ describe('individual agent preset schemas', () => {
}).success).toBe(false);
});
- it('rejects invalid identity and bounded model values', () => {
+ it('rejects invalid identity and bounded model values, but accepts custom thinking', () => {
expect(AgentPresetInputSchema.safeParse({ ...nativeInput, handle: 'switchboard' }).success)
.toBe(false);
expect(AgentPresetInputSchema.safeParse({ ...nativeInput, model: '--danger' }).success)
.toBe(false);
expect(AgentPresetInputSchema.safeParse({ ...nativeInput, label: '' }).success).toBe(false);
- expect(AgentPresetInputSchema.safeParse({ ...nativeInput, thinking: 'extreme' }).success)
+ expect(AgentPresetInputSchema.parse({ ...nativeInput, thinking: 'extreme' }).thinking)
+ .toBe('extreme');
+ expect(AgentPresetInputSchema.safeParse({ ...nativeInput, thinking: 'bad#value' }).success)
.toBe(false);
});
diff --git a/packages/protocol/src/schemas.spec.ts b/packages/protocol/src/schemas.spec.ts
index 3e48cbe0..c8a73b6d 100644
--- a/packages/protocol/src/schemas.spec.ts
+++ b/packages/protocol/src/schemas.spec.ts
@@ -157,8 +157,15 @@ describe('members', () => {
expect(parsed.thinking).toBeUndefined();
});
- it('rejects a thinking level the protocol does not define', () => {
- expect(() => MemberSchema.parse({ ...agent, thinking: 'extreme' })).toThrow();
+ it('accepts a bounded custom variant name and rejects malformed values', () => {
+ // Verifies the opencode custom-variant contract: an unlisted name is a
+ // valid persisted value; only malformed shapes are refused. Strict
+ // adapters keep their exact-list rejections at the registry gate.
+ expect(MemberSchema.parse({ ...agent, thinking: 'extreme' }).thinking).toBe('extreme');
+ expect(MemberSchema.parse({ ...agent, thinking: ' HIGH ' }).thinking).toBe('high');
+ for (const bad of ['has space', 'bad#value', '-lead', 'a\nb', 'x'.repeat(65)]) {
+ expect(MemberSchema.safeParse({ ...agent, thinking: bad }).success, bad).toBe(false);
+ }
});
it('validates a private nonnegative ACP cumulative usage cursor', () => {
@@ -591,12 +598,19 @@ describe('room config', () => {
).toBe(false);
// harn:end one-control-chooses-an-agent-everywhere
- // A starting agent takes the same thinking vocabulary a spawned one does.
+ // A starting agent takes the same thinking vocabulary a spawned one does:
+ // a custom variant name is valid, only malformed shapes are refused.
expect(
CreateRoomRequestSchema.safeParse({
...base,
starting_agent: { harness: 'claude-code', handle: 'codor', thinking: 'ludicrous' },
}).success,
+ ).toBe(true);
+ expect(
+ CreateRoomRequestSchema.safeParse({
+ ...base,
+ starting_agent: { harness: 'claude-code', handle: 'codor', thinking: 'bad#value' },
+ }).success,
).toBe(false);
expect(
CreateRoomRequestSchema.safeParse({
@@ -795,7 +809,13 @@ describe('spawn control vocabularies', () => {
const withThinking: SpawnOpts = { cwd: '/work', thinking };
expect(ThinkingLevelSchema.parse(withThinking.thinking)).toBe(thinking);
}
- expect(ThinkingLevelSchema.safeParse('extreme').success).toBe(false);
+ // Unlisted custom variant names are valid wire values; malformed shapes are not.
+ expect(ThinkingLevelSchema.parse('extreme')).toBe('extreme');
+ expect(ThinkingLevelSchema.parse(' HIGH ')).toBe('high');
+ expect(ThinkingLevelSchema.parse('Minimal')).toBe('Minimal');
+ for (const bad of ['has space', 'bad#value', '-lead', 'a\tb', '', 'x'.repeat(65)]) {
+ expect(ThinkingLevelSchema.safeParse(bad).success, bad).toBe(false);
+ }
});
// harn:end harness-declares-supported-thinking-levels
});
diff --git a/packages/switchboard/src/adapter-registry.spec.ts b/packages/switchboard/src/adapter-registry.spec.ts
index cf7e9f7c..7ddd4457 100644
--- a/packages/switchboard/src/adapter-registry.spec.ts
+++ b/packages/switchboard/src/adapter-registry.spec.ts
@@ -89,17 +89,18 @@ describe('adapter registry spawn controls', () => {
adapter.id,
adapter.capabilities.thinking,
adapter.capabilities.thinking_levels,
+ adapter.capabilities.thinking_custom ?? false,
])).toEqual([
- ['antigravity', false, undefined],
- ['acp', false, undefined],
- ['claude-code', true, ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']],
- ['codex', true, ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']],
- ['copilot', false, undefined],
- ['copilot-vscode', false, undefined],
- ['cursor', false, undefined],
- ['gemini', false, undefined],
- ['grok', true, ['low', 'medium', 'high']],
- ['opencode', false, undefined],
+ ['antigravity', false, undefined, false],
+ ['acp', false, undefined, false],
+ ['claude-code', true, ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'], false],
+ ['codex', true, ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], false],
+ ['copilot', false, undefined, false],
+ ['copilot-vscode', false, undefined, false],
+ ['cursor', false, undefined, false],
+ ['gemini', false, undefined, false],
+ ['grok', true, ['low', 'medium', 'high'], false],
+ ['opencode', true, ['low', 'medium', 'high'], true],
]);
});
@@ -110,13 +111,13 @@ describe('adapter registry spawn controls', () => {
);
expect(() => adapter!.spawn({
cwd: '/work',
- thinking: 'extreme' as 'high',
+ thinking: 'bad#value' as 'high',
})).toThrow('valid levels: low, medium, high, xhigh, max, ultra, ultracode');
});
it('rejects thinking before delegating to unsupported adapters', async () => {
const adapters = await loadAdapterRegistry();
- for (const id of ['antigravity', 'copilot', 'copilot-vscode', 'gemini', 'opencode']) {
+ for (const id of ['antigravity', 'copilot', 'copilot-vscode', 'gemini']) {
const adapter = adapters.find((candidate) => candidate.id === id)!;
expect(() => adapter.spawn({ cwd: '/work', thinking: 'high' })).toThrow(
`adapter '${id}' does not support thinking levels`,
@@ -143,6 +144,30 @@ describe('adapter registry spawn controls', () => {
"adapter 'legacy' does not support thinking level 'xhigh'; valid levels: low, medium, high",
);
});
+
+ // Requirement: gate and adapters agree on unnormalized input — the gate
+ // normalizes before the declared-list check and strict adapters normalize at
+ // spawn, so neither approves what the other refuses. Non-redundant: the only
+ // gate/adapter agreement proof.
+ it('accepts an unlisted custom value only for a thinking_custom adapter, normalized first', async () => {
+ const adapters = await loadAdapterRegistry();
+ const opencode = adapters.find((adapter) => adapter.id === 'opencode')!;
+ expect(() => opencode.spawn({ cwd: '/work', thinking: 'extreme' })).not.toThrow();
+ expect(() => opencode.spawn({ cwd: '/work', thinking: ' HIGH ' })).not.toThrow();
+ expect(() => opencode.spawn({ cwd: '/work', thinking: 'bad#value' as 'high' })).toThrow(
+ 'unknown thinking level',
+ );
+ for (const id of ['claude-code', 'codex', 'grok']) {
+ const adapter = adapters.find((candidate) => candidate.id === id)!;
+ expect(() => adapter.spawn({ cwd: '/work', thinking: 'extreme' as 'high' })).toThrow(
+ `adapter '${id}' does not support thinking level 'extreme'`,
+ );
+ }
+ for (const id of ['claude-code', 'codex', 'grok'] as const) {
+ const adapter = adapters.find((candidate) => candidate.id === id)!;
+ expect(adapter.spawn({ cwd: '/work', thinking: ' HIGH ' }).thinking).toBe('high');
+ }
+ });
});
// harn:end harness-declares-supported-thinking-levels
diff --git a/packages/switchboard/src/adapter-registry.ts b/packages/switchboard/src/adapter-registry.ts
index 3efe5337..9920bf20 100644
--- a/packages/switchboard/src/adapter-registry.ts
+++ b/packages/switchboard/src/adapter-registry.ts
@@ -19,6 +19,9 @@ import {
type AdapterCapabilities,
DEFAULT_THINKING_LEVELS,
type HarnessAdapter,
+ KNOWN_THINKING_LEVELS,
+ KnownThinkingLevelSchema,
+ normalizeThinkingLevel,
PolicySchema,
type SpawnOpts,
ThinkingLevelSchema,
@@ -123,10 +126,14 @@ export function resolveAdapterModuleSpecifier(specifier: string, baseDir: string
// harn:assume harness-declares-supported-thinking-levels ref=adapter-thinking-level-registry
function validThinkingLevels(capabilities: Partial): boolean {
const levels = (capabilities as { thinking_levels?: unknown }).thinking_levels;
+ const custom = (capabilities as { thinking_custom?: unknown }).thinking_custom;
+ if (custom !== undefined && typeof custom !== 'boolean') return false;
+ if (custom === true && capabilities.thinking !== true) return false;
if (levels === undefined) return true;
if (!capabilities.thinking || !Array.isArray(levels) || levels.length === 0) return false;
+ // Declared lists stay fixed levels; a custom name is carried by thinking_custom, not the list.
return new Set(levels).size === levels.length &&
- levels.every((level) => ThinkingLevelSchema.safeParse(level).success);
+ levels.every((level) => KnownThinkingLevelSchema.safeParse(level).success);
}
function validCapabilities(value: unknown): value is AdapterCapabilities {
@@ -158,7 +165,7 @@ function validPolicyMap(value: unknown): boolean {
// harn:end harness-declares-what-a-policy-becomes
const validPolicies = PolicySchema.options.join(', ');
-const validThinking = ThinkingLevelSchema.options.join(', ');
+const validThinking = KNOWN_THINKING_LEVELS.join(', ');
export function validateSpawnOptions(adapter: HarnessAdapter, opts: SpawnOpts): void {
if (opts.acp_launch !== undefined && adapter.id !== 'acp') {
@@ -167,17 +174,23 @@ export function validateSpawnOptions(adapter: HarnessAdapter, opts: SpawnOpts):
if (opts.policy !== undefined && !PolicySchema.safeParse(opts.policy).success) {
throw new Error(`unknown policy '${opts.policy}'; valid policies: ${validPolicies}`);
}
- if (opts.thinking !== undefined && !ThinkingLevelSchema.safeParse(opts.thinking).success) {
- throw new Error(`unknown thinking level '${String(opts.thinking)}'; valid levels: ${validThinking}`);
- }
- if (opts.thinking !== undefined && !adapter.capabilities.thinking) {
- throw new Error(`adapter '${adapter.id}' does not support thinking levels`);
- }
if (opts.thinking !== undefined) {
+ const normalized = typeof opts.thinking === 'string'
+ ? normalizeThinkingLevel(opts.thinking)
+ : opts.thinking;
+ if (normalized === '') {
+ throw new Error(`unknown thinking level '${String(opts.thinking)}'; valid levels: ${validThinking}`);
+ }
+ if (!ThinkingLevelSchema.safeParse(normalized).success) {
+ throw new Error(`unknown thinking level '${String(opts.thinking)}'; valid levels: ${validThinking}`);
+ }
+ if (!adapter.capabilities.thinking) {
+ throw new Error(`adapter '${adapter.id}' does not support thinking levels`);
+ }
const supported = adapter.capabilities.thinking_levels ?? DEFAULT_THINKING_LEVELS;
- if (!supported.includes(opts.thinking)) {
+ if (!supported.includes(normalized as never) && adapter.capabilities.thinking_custom !== true) {
throw new Error(
- `adapter '${adapter.id}' does not support thinking level '${opts.thinking}'; ` +
+ `adapter '${adapter.id}' does not support thinking level '${normalized}'; ` +
`valid levels: ${supported.join(', ')}`,
);
}
diff --git a/packages/web-next/src/room/AgentControls.tsx b/packages/web-next/src/room/AgentControls.tsx
index 91eb5b4d..afae32ff 100644
--- a/packages/web-next/src/room/AgentControls.tsx
+++ b/packages/web-next/src/room/AgentControls.tsx
@@ -12,7 +12,7 @@
*/
import { Ban, LoaderCircle, Lock, PencilLine, RefreshCw, Zap } from 'lucide-react';
import { useState } from 'react';
-import type { AgentPreset } from '@codor/protocol';
+import { THINKING_CUSTOM_MAX_LENGTH, type AgentPreset } from '@codor/protocol';
import { ThinkingSlider } from './ThinkingSlider.js';
import {
@@ -243,6 +243,10 @@ export function AgentControls(props: {
const isAcp = config.harness === 'acp' || config.harness.startsWith('acp:');
const models = adapter?.models ?? [];
const levels = thinkingLevelsFor(adapter);
+ const customThinking = adapter?.capabilities.thinking === true
+ && adapter.capabilities.thinking_custom === true;
+ const thinkingIsCustom = config.thinking !== ''
+ && !(levels as readonly string[]).includes(config.thinking);
const [modelQuery, setModelQuery] = useState('');
const set = (patch: Partial) => { props.onChange({ ...config, ...patch }); };
@@ -360,12 +364,31 @@ export function AgentControls(props: {
Not supported by this harness.
) : (
- { set({ thinking }); }}
- idPrefix={id}
- />
+ <>
+ { set({ thinking }); }}
+ idPrefix={id}
+ />
+ {customThinking && (
+ <>
+ { set({ thinking: e.target.value }); }}
+ onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault(); }}
+ placeholder="Custom variant"
+ aria-label="Custom thinking variant"
+ data-testid={`${id}-thinking-custom`}
+ maxLength={THINKING_CUSTOM_MAX_LENGTH}
+ pattern={`(?!-)[^\\s#\\x00-\\x1F\\x7F\\x80-\\x9F]{1,${String(THINKING_CUSTOM_MAX_LENGTH)}}`}
+ title="Exact variant name: up to 64 characters, no spaces, no #, not starting with -"
+ />
+ An exact variant name for this harness. A fixed pick replaces it; Default clears it.
+ >
+ )}
+ >
)}
>
diff --git a/packages/web-next/src/room/ThinkingSlider.tsx b/packages/web-next/src/room/ThinkingSlider.tsx
index 49a50ed5..98f05a44 100644
--- a/packages/web-next/src/room/ThinkingSlider.tsx
+++ b/packages/web-next/src/room/ThinkingSlider.tsx
@@ -22,12 +22,19 @@ export function ThinkingSlider(props: {
idPrefix: string;
disabled?: boolean;
}) {
- // Position 0 is always Default; the declared levels follow it.
- const stops: readonly string[] = ['', ...props.levels];
+ // Position 0 is always Default; the declared levels follow it. An unlisted
+ // custom value rides as an extra stop past the declared levels, so it reads
+ // as itself (never as Default) and moving back to 0 or to a fixed stop
+ // replaces it.
+ const base: readonly string[] = ['', ...props.levels];
+ const listedIndex = base.indexOf(props.value);
+ const isCustom = props.value !== '' && listedIndex < 0;
+ const stops: readonly string[] = isCustom ? [...base, props.value] : base;
const max = stops.length - 1;
- const index = Math.max(0, stops.indexOf(props.value));
+ const index = isCustom ? max : Math.max(0, listedIndex);
const pct = max === 0 ? 0 : index / max;
const label = (stop: string) => (stop === '' ? 'Default' : stop);
+ const shown = isCustom ? props.value : (stops[index] ?? '');
// Keep the visual thumb and tooltip inside the rail at both endpoints. The
// native range uses the same half-thumb inset rather than putting its center
// on the element's outer edge.
@@ -55,7 +62,7 @@ export function ThinkingSlider(props: {
style={{ left: position, transform: tipTransform }}
data-testid={`${props.idPrefix}-thinking-value`}
>
- {label(stops[index] ?? '')}
+ {isCustom ? shown : label(stops[index] ?? '')}
props.onChange(stops[Number(event.target.value)] ?? '')}
/>
diff --git a/packages/web-next/src/room/agent-spec.spec.ts b/packages/web-next/src/room/agent-spec.spec.ts
index 2f15d39b..3a7c99ae 100644
--- a/packages/web-next/src/room/agent-spec.spec.ts
+++ b/packages/web-next/src/room/agent-spec.spec.ts
@@ -371,6 +371,65 @@ describe('one rule decides an acceptable thinking level (Tier-1 #4)', () => {
expect(supportedThinking(undeclared, 'high')).toBe('high');
expect(supportedThinking(claude, '')).toBeUndefined();
});
+
+ // Requirement: custom values pass only for thinking_custom adapters, with
+ // normalization. Non-redundant: the only UI-side custom-gating proof.
+ it('passes a normalized custom value only for a thinking_custom adapter', () => {
+ const custom = {
+ id: 'opencode',
+ capabilities: { thinking: true, thinking_levels: ['low', 'medium', 'high'], thinking_custom: true },
+ } satisfies AdapterLike;
+ expect(supportedThinking(custom, 'extreme')).toBe('extreme');
+ expect(supportedThinking(custom, ' HIGH ')).toBe('high');
+ expect(supportedThinking(custom, 'Minimal')).toBe('Minimal');
+ expect(supportedThinking(custom, 'bad#value')).toBeUndefined();
+ expect(supportedThinking(custom, 'has space')).toBeUndefined();
+ expect(supportedThinking(claude, 'extreme')).toBeUndefined();
+ });
+
+ // Requirement: harness switches keep custom values only where accepted.
+ // Non-redundant: the only reconcileConfig custom proof.
+ it('keeps a custom value across harness reconciliation only where accepted', () => {
+ const custom = {
+ id: 'opencode',
+ capabilities: { thinking: true, thinking_levels: ['low', 'medium', 'high'], thinking_custom: true },
+ } satisfies AdapterLike;
+ expect(reconcileConfig(
+ { harness: 'claude-code', model: '', thinking: 'extreme', policy: '' },
+ 'opencode',
+ [claude, custom],
+ ).thinking).toBe('extreme');
+ expect(reconcileConfig(
+ { harness: 'opencode', model: '', thinking: 'extreme', policy: '' },
+ 'claude-code',
+ [claude, custom],
+ ).thinking).toBe('');
+ });
+
+ // Requirement: the custom value reaches the payload and survives model
+ // edits. Non-redundant: the only spawn-payload custom proof.
+ it('carries a custom value into the spawn payload and preserves it across model edits', () => {
+ const custom = {
+ id: 'opencode',
+ capabilities: { thinking: true, thinking_levels: ['low', 'medium', 'high'], thinking_custom: true },
+ models: ['opencode/a', 'opencode/b'],
+ } satisfies AdapterLike;
+ const spec = buildSpawnSpec({
+ config: { harness: 'opencode', model: 'opencode/a', thinking: 'extreme', policy: '' },
+ handle: 'scout', cwd: '/p', adapters: [custom], members: [],
+ });
+ expect(spec.thinking).toBe('extreme');
+ const retuned = buildSpawnSpec({
+ config: { harness: 'opencode', model: 'opencode/b', thinking: 'extreme', policy: '' },
+ handle: 'scout', cwd: '/p', adapters: [custom], members: [],
+ });
+ expect(retuned.thinking).toBe('extreme');
+ const strict = buildSpawnSpec({
+ config: { harness: 'claude-code', model: '', thinking: 'extreme', policy: '' },
+ handle: 'scout', cwd: '/p', adapters: [claude], members: [],
+ });
+ expect(strict.thinking).toBeUndefined();
+ });
});
describe('a pending spawn resolves only on its own evidence (Tier-1 #5)', () => {
diff --git a/packages/web-next/src/room/agent-spec.ts b/packages/web-next/src/room/agent-spec.ts
index dd6c2d0c..5a270173 100644
--- a/packages/web-next/src/room/agent-spec.ts
+++ b/packages/web-next/src/room/agent-spec.ts
@@ -10,7 +10,9 @@
*/
import {
DEFAULT_THINKING_LEVELS,
+ normalizeThinkingLevel,
PolicySchema,
+ ThinkingLevelSchema,
type AgentPreset,
type AgentPresetInput,
type Member,
@@ -43,6 +45,7 @@ export interface AdapterLike {
capabilities: {
thinking?: boolean;
thinking_levels?: readonly ThinkingLevel[];
+ thinking_custom?: boolean;
resume?: boolean;
/** What each policy ACTUALLY becomes on this harness. `null` means the harness
* does not distinguish it at all — the safety-critical case. */
@@ -100,14 +103,23 @@ export function thinkingLevelsFor(adapter: AdapterLike | undefined): readonly Th
*
* One place decides this, so spawn, channel-create and configure cannot disagree
* about whether a level is acceptable — which is how a level the harness rejects
- * gets submitted from one dialog but not another.
+ * gets submitted from one dialog but not another. Values normalize first:
+ * outer whitespace trims, a recognized name folds case, blank becomes Default,
+ * and a bounded custom name passes only for a `thinking_custom` adapter.
*/
export function supportedThinking(
adapter: AdapterLike | undefined,
value: string,
): ThinkingLevel | undefined {
+ if (value.trim() === '') return undefined;
+ const normalized = normalizeThinkingLevel(value);
const levels = thinkingLevelsFor(adapter);
- return levels.includes(value as ThinkingLevel) ? (value as ThinkingLevel) : undefined;
+ if (levels.includes(normalized as ThinkingLevel)) return normalized as ThinkingLevel;
+ if (adapter?.capabilities.thinking === true && adapter.capabilities.thinking_custom === true
+ && ThinkingLevelSchema.safeParse(normalized).success) {
+ return normalized as ThinkingLevel;
+ }
+ return undefined;
}
const isAbsolute = (cwd: string | undefined): cwd is string =>
@@ -449,8 +461,19 @@ export function acpLaunchFromConfig(config: AgentConfig): {
export function reconcileConfig(config: AgentConfig, nextHarness: string, adapters: readonly AdapterLike[]): AgentConfig {
if (nextHarness === config.harness) return config;
const adapter = adapters.find((candidate) => candidate.id === nextHarness);
- const levels = thinkingLevelsFor(adapter);
- const thinking = levels.includes(config.thinking as ThinkingLevel) ? config.thinking : '';
+ const raw = config.thinking.trim();
+ let thinking = '';
+ if (raw !== '') {
+ const normalized = normalizeThinkingLevel(raw);
+ const levels = thinkingLevelsFor(adapter);
+ if (levels.includes(normalized as ThinkingLevel)) {
+ thinking = normalized;
+ } else if (adapter?.capabilities.thinking === true
+ && adapter.capabilities.thinking_custom === true
+ && ThinkingLevelSchema.safeParse(normalized).success) {
+ thinking = normalized;
+ }
+ }
return { ...config, harness: nextHarness, model: '', thinking };
}
diff --git a/packages/web-next/tests/harness.mjs b/packages/web-next/tests/harness.mjs
index 889866af..856cd43a 100644
--- a/packages/web-next/tests/harness.mjs
+++ b/packages/web-next/tests/harness.mjs
@@ -76,6 +76,7 @@ const thinky = Object.assign(new FakeAdapter('thinky', {
resume: false,
thinking: true,
thinking_levels: ['low', 'medium', 'high', 'xhigh'],
+ thinking_custom: true,
policies: {
'read-only': null,
'workspace-write': null,
diff --git a/packages/web-next/tests/room10-dialogs.e2e.spec.ts b/packages/web-next/tests/room10-dialogs.e2e.spec.ts
index cefac52e..01279aa2 100644
--- a/packages/web-next/tests/room10-dialogs.e2e.spec.ts
+++ b/packages/web-next/tests/room10-dialogs.e2e.spec.ts
@@ -488,6 +488,64 @@ test.describe('v2 controls', () => {
await expect(toggle).toBeChecked();
await expect(dialog.getByTestId('spawn-folder-.hidden-project')).toBeVisible();
});
+
+ test('a custom thinking variant applies on Enter without submitting and survives configure', async ({ page }, testInfo) => {
+ await openRoom(page);
+ const dialog = await openSpawn(page);
+ // The fixture harness without thinking offers no custom entry.
+ await expect(dialog.getByTestId('spawn-thinking-custom')).toHaveCount(0);
+ await dialog.getByTestId('spawn-harness-thinky').click();
+ const custom = dialog.getByTestId('spawn-thinking-custom');
+ await expect(custom).toBeVisible();
+ // Keyboard reaches it.
+ await custom.focus();
+ await expect(custom).toBeFocused();
+ await custom.fill('extreme');
+ // Enter applies without submitting the host form.
+ await custom.press('Enter');
+ await expect(page.getByTestId('spawn-dialog')).toBeVisible();
+ await expect(dialog.getByTestId('spawn-thinking-value')).toHaveText('extreme');
+ // A fixed pick replaces the custom value; Default clears it.
+ await dialog.getByTestId('spawn-thinking-range').fill('1');
+ await expect(dialog.getByTestId('spawn-thinking-value')).toHaveText('low');
+ await expect(custom).toHaveValue('');
+ await custom.fill('extreme');
+ await expect(custom).toHaveValue('extreme');
+ await dialog.getByTestId('spawn-thinking-range').fill('0');
+ await expect(dialog.getByTestId('spawn-thinking-value')).toHaveText('Default');
+ await expect(custom).toHaveValue('');
+
+ // Persist a custom value, then prove configure reopens with its text visible.
+ await custom.fill('extreme');
+ const handle = `customthink${String(testInfo.workerIndex)}${String(testInfo.retry)}`;
+ await dialog.getByTestId('spawn-handle').fill(handle);
+ await dialog.getByTestId('spawn-go').click();
+ await expect(page.getByTestId(`member-${handle}`)).toBeVisible({ timeout: 15_000 });
+
+ await page.getByTestId(`member-${handle}-menu`).click();
+ await page.getByRole('menuitem', { name: 'Configure…' }).click();
+ const configure = page.getByTestId('configure-dialog');
+ await expect(configure).toBeVisible();
+ await expect(configure.getByTestId('configure-thinking-value')).toHaveText('extreme');
+ await expect(configure.getByTestId('configure-thinking-custom')).toHaveValue('extreme');
+ await configure.getByTestId('configure-close').click();
+ });
+
+ test('a malformed custom variant is refused before it can be submitted', async ({ page }) => {
+ // Native validation blocks submit, so a value the protocol would reject
+ // never reaches the payload as a silent Default.
+ await openRoom(page);
+ const dialog = await openSpawn(page);
+ await dialog.getByTestId('spawn-harness-thinky').click();
+ const custom = dialog.getByTestId('spawn-thinking-custom');
+ await custom.fill('bad#value');
+ await expect(custom).toHaveJSProperty('validity.valid', false);
+ await expect(custom).toHaveJSProperty('validity.patternMismatch', true);
+ await dialog.getByTestId('spawn-handle').fill('badvariantprobe');
+ await dialog.getByTestId('spawn-go').click();
+ await expect(page.getByTestId('spawn-dialog')).toBeVisible();
+ await expect(page.getByTestId('member-badvariantprobe')).toHaveCount(0);
+ });
});
test.describe('all three dialogs share one control', () => {