diff --git a/src/servers/api/chat/list-models.ts b/src/servers/api/chat/list-models.ts index e8e1f092..8d2b1a8b 100644 --- a/src/servers/api/chat/list-models.ts +++ b/src/servers/api/chat/list-models.ts @@ -3,9 +3,33 @@ import { getOpenCodeServerUrl } from './opencode/sidecar-server'; // The Claude harness runs the `claude` CLI, so its tiers are a fixed set. 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/haiku', name: 'haiku', provider: 'claude-code', contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true }, + { + 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/haiku', + name: 'haiku', + provider: 'claude-code', + contextWindow: 200000, + maxTokens: 8192, + reasoning: false, + images: true, + }, ]; // Cache the OpenCode catalog; it's stable for a session. @@ -42,7 +66,11 @@ async function listOpenCodeModels(): Promise { contextWindow: 200000, maxTokens: 8192, 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, }); } } diff --git a/src/servers/api/chat/session-manager.ts b/src/servers/api/chat/session-manager.ts index af4d4531..c697b332 100644 --- a/src/servers/api/chat/session-manager.ts +++ b/src/servers/api/chat/session-manager.ts @@ -1,5 +1,6 @@ import type { UserSession } from './types'; import { logger } from './logger'; +import { clearOpenCodeSession } from './opencode/state'; class SessionManager { private sessions = new Map(); @@ -104,6 +105,12 @@ class SessionManager { 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); } diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 4bdca57a..683d472f 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -438,6 +438,16 @@ async function handleOpenCodeChat( const onMessage = createMessageHandler(sessionId, model); 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({ userId, email, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index 43f46a09..9e57182b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -31,8 +31,13 @@ type ModelSelectorProps = { isConnected: boolean; isGenerating: 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({ @@ -44,8 +49,6 @@ export function ModelSelector({ isConnected, isGenerating, hasStarted, - thinkingLevel, - onThinkingChange, }: ModelSelectorProps) { const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[]; @@ -117,14 +120,16 @@ export function ModelSelector({ ))} )} + {/* 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. */}
- {availableModels.find((m) => m.id === displayModel)?.reasoning && ( - onThinkingChange(thinkingLevel === 'high' ? 'off' : 'high')} - disabled={isGenerating} - /> - )}
{providerModels.length > 0 ? (