finish phase 0 of the opencode parity list
Three defects and one honest removal. Each was reproduced before being changed, as the doc asks. B4 — images were offered and silently discarded. Every OpenCode model advertised `images: true`, the composer gates on that flag, the bubble rendered the attachment, and `handleOpenCodeChat`'s message type has no `images` field, so it never left officer. Flipped to false: 61 OpenCode models now decline, the three Claude ones still accept. Plumbing them through OpenCodeRunParams stays Phase 4; advertising a capability that does not exist is the part worth fixing today. B5 — every OpenCode turn overwrote the previous turn's subscription handle without detaching it, so the old session-scoped listener stayed attached and delivery doubled, tripled, and so on for any termination that is not result/error/stopped. Deliberately NOT the Claude guard: Claude keeps one persistent session and skips re-subscribing, while OpenCode spawns a fresh `opencode run` per turn, so a new subscription each time is correct — detaching the old one is what was missing. B6 — the sessionKey → `ses_…` map had no writer of deletions, so it grew for the process lifetime and a reused key resumed a stale OpenCode session. Cleared in `deleteSession` only, never in `releaseSession`: releasing means "let go, leave it running", and a returning browser must find the same `ses_…` again. Phase 0 item 1 — the thinking toggle is removed rather than fixed. `thinking` is accepted on the wire and forwarded by neither channel, so the control changed its own label and nothing else. Out of scope for both harnesses by decision. The inert plumbing beneath it is left for a follow-up that touches the socket contract; the props stay accepted-and-unread so no call site had to change. Phase 0 is complete: B1, B2, B3 landed earlier; B4, B5, B6 and the selector here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,33 @@ import { getOpenCodeServerUrl } from './opencode/sidecar-server';
|
|||||||
|
|
||||||
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
||||||
const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
||||||
{ id: 'claude-code/opus', name: 'opus', provider: 'claude-code', contextWindow: 200000, maxTokens: 16000, reasoning: true, images: true },
|
{
|
||||||
{ id: 'claude-code/sonnet', name: 'sonnet', provider: 'claude-code', contextWindow: 200000, maxTokens: 16000, reasoning: true, images: true },
|
id: 'claude-code/opus',
|
||||||
{ id: 'claude-code/haiku', name: 'haiku', provider: 'claude-code', contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true },
|
name: 'opus',
|
||||||
|
provider: 'claude-code',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 16000,
|
||||||
|
reasoning: true,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-code/sonnet',
|
||||||
|
name: 'sonnet',
|
||||||
|
provider: 'claude-code',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 16000,
|
||||||
|
reasoning: true,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-code/haiku',
|
||||||
|
name: 'haiku',
|
||||||
|
provider: 'claude-code',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
reasoning: false,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cache the OpenCode catalog; it's stable for a session.
|
// Cache the OpenCode catalog; it's stable for a session.
|
||||||
@@ -42,7 +66,11 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
|||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
images: true,
|
// False because nothing carries them: `handleOpenCodeChat`'s message type has no `images`
|
||||||
|
// field, so an attached image is rendered in the bubble, never sent, and silently dropped.
|
||||||
|
// The composer gates on this flag, so advertising `true` offered a capability that did not
|
||||||
|
// exist. Flip it back when images are plumbed through OpenCodeRunParams (parity doc, Phase 4).
|
||||||
|
images: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { UserSession } from './types';
|
import type { UserSession } from './types';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { clearOpenCodeSession } from './opencode/state';
|
||||||
|
|
||||||
class SessionManager {
|
class SessionManager {
|
||||||
private sessions = new Map<string, UserSession>();
|
private sessions = new Map<string, UserSession>();
|
||||||
@@ -104,6 +105,12 @@ class SessionManager {
|
|||||||
session._claudeKill();
|
session._claudeKill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The OpenCode sessionKey → `ses_…` map is keyed on our session id and nothing else cleared it, so
|
||||||
|
// it grew for the process lifetime and a REUSED key silently resumed a stale OpenCode session.
|
||||||
|
// Only on deleteSession, never on releaseSession: releasing is "let go, leave it running", and a
|
||||||
|
// returning browser has to be able to find the same `ses_…` again.
|
||||||
|
clearOpenCodeSession(sessionId);
|
||||||
|
|
||||||
this.forget(session);
|
this.forget(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,16 @@ async function handleOpenCodeChat(
|
|||||||
const onMessage = createMessageHandler(sessionId, model);
|
const onMessage = createMessageHandler(sessionId, model);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Drop the PREVIOUS turn's listener before opening the next one.
|
||||||
|
//
|
||||||
|
// This deliberately does not mirror the Claude guard above. Claude keeps one persistent session and
|
||||||
|
// skips re-subscribing; OpenCode runs a fresh `opencode run` subprocess per turn, so a new
|
||||||
|
// subscription each time is correct. What was wrong is that the old handle was overwritten without
|
||||||
|
// being detached, leaving the previous session-scoped listener attached — so every turn after the
|
||||||
|
// first delivered doubled, tripled, and so on, for any termination that is not result/error/stopped.
|
||||||
|
session._sidecarUnsub?.();
|
||||||
|
session._sidecarUnsub = undefined;
|
||||||
|
|
||||||
const handle = await sendOpenCodeStreaming({
|
const handle = await sendOpenCodeStreaming({
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ type ModelSelectorProps = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
hasStarted: boolean;
|
hasStarted: boolean;
|
||||||
thinkingLevel: string | null;
|
/**
|
||||||
onThinkingChange: (level: string | null) => void;
|
* Still accepted so no call site has to change, and deliberately unread: the control they drove was
|
||||||
|
* removed because nothing forwarded `thinking` to either harness. Delete both when the rest of the
|
||||||
|
* plumbing goes.
|
||||||
|
*/
|
||||||
|
thinkingLevel?: string | null;
|
||||||
|
onThinkingChange?: (level: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
@@ -44,8 +49,6 @@ export function ModelSelector({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
hasStarted,
|
hasStarted,
|
||||||
thinkingLevel,
|
|
||||||
onThinkingChange,
|
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||||
|
|
||||||
@@ -117,14 +120,16 @@ export function ModelSelector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The thinking toggle used to render here. It is gone because it did nothing: `thinking` is
|
||||||
|
accepted on the wire (types.ts, websocket.ts) and forwarded by NEITHER channel — not
|
||||||
|
send-claude-code.ts, not send-opencode.ts — so pressing it changed the label and nothing else.
|
||||||
|
A control that lies is worse than an absent one.
|
||||||
|
|
||||||
|
Out of scope for both harnesses by decision (parity doc, 2026-08-10), so this is a removal
|
||||||
|
rather than a stopgap. The plumbing under it (`thinkingLevel` through useChat /
|
||||||
|
useEmbeddableChat, `ThinkingLevel` in types.ts) is still threaded and still inert; deleting it
|
||||||
|
is a separate change that touches the socket contract. */}
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-2">
|
<div className="flex min-w-0 shrink-0 items-center gap-2">
|
||||||
{availableModels.find((m) => m.id === displayModel)?.reasoning && (
|
|
||||||
<ThinkingToggle
|
|
||||||
enabled={thinkingLevel === 'high'}
|
|
||||||
onToggle={() => onThinkingChange(thinkingLevel === 'high' ? 'off' : 'high')}
|
|
||||||
disabled={isGenerating}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 text-xs text-muted-foreground">
|
<div className="min-w-0 text-xs text-muted-foreground">
|
||||||
{providerModels.length > 0 ? (
|
{providerModels.length > 0 ? (
|
||||||
<Select
|
<Select
|
||||||
@@ -151,25 +156,3 @@ export function ModelSelector({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Thinking Toggle ──
|
|
||||||
|
|
||||||
type ThinkingToggleProps = {
|
|
||||||
enabled: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThinkingToggle = ({ enabled, onToggle, disabled }: ThinkingToggleProps) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
disabled={disabled}
|
|
||||||
title={enabled ? 'Thinking enabled (click to disable)' : 'Thinking disabled (click to enable)'}
|
|
||||||
className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
||||||
enabled ? 'bg-info/15 text-info' : 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{enabled ? 'think' : 'no think'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user