import type { ChatEvent } from '@@/api/chat/types'; import { logger } from '@@/api/chat/logger'; import { ensureServer } from '@@/api/chat/opencode/server-manager'; import { getConnection } from '@@/api/chat/opencode/client'; import { createEventMapper } from '@@/api/chat/opencode/event-mapper'; import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state'; // The OpenCode analog of send-claude-code.ts's streaming path. Drives a turn against a warm // `opencode serve` over HTTP + SSE, mapping events to the shared ChatEvent contract. type OpenCodeStreamingParams = { userId: number; email: string; username: string; prompt: string; sessionKey: string; cwd?: string; // the session's working dir: tags it (metadata.officer.cwd) + told to the model (system prompt) model?: string; role?: string; resumeSessionId?: string; onEvent: (event: ChatEvent) => void; }; type OpenCodeStreamingHandle = { kill: () => void; }; /** Split an OpenCode model id (`providerID/modelID`, e.g. `opencode/claude-opus-4-8`). */ function splitModel(model: string): { providerID: string; modelID: string } { const slash = model.indexOf('/'); if (slash <= 0) return { providerID: 'opencode', modelID: model }; return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }; } // OpenCode can't set a real per-session cwd (every session runs in the fixed server's dir), so we tell // the model its working directory via an appended, non-visible system prompt. Sent on every turn. function officerSystemPrompt(cwd: string): string { return [ `Session working directory: ${cwd}`, '', `For this session, treat \`${cwd}\` as your current working directory. Resolve relative paths, globs, and file references against it, and run shell commands from inside it (\`cd\` into it, or use absolute paths beneath it). When the user says "here", "this directory", or gives a relative path, they mean a location within \`${cwd}\`.`, '', `Unless the user explicitly directs you elsewhere, keep your work focused on \`${cwd}\`, its files, and its subdirectories.`, ].join('\n'); } export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise { logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model }); const { baseUrl } = await ensureServer(); const conn = getConnection(baseUrl); // Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is // itself the OpenCode session id (`ses_…`); otherwise create a new one. let opencodeSessionId = getOpenCodeSession(params.sessionKey) ?? (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? params.resumeSessionId; if (!opencodeSessionId) { // Tag the session with its logical cwd so it can be listed in the right place (OpenCode has no // per-session directory — every session runs in the fixed server's cwd). opencodeSessionId = await conn.createSession(params.cwd ? { officer: { cwd: params.cwd } } : undefined); } setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId; let unsub = () => {}; const mapper = createEventMapper((event: ChatEvent) => { params.onEvent(event); if (event.type === 'result' || event.type === 'error') unsub(); }); unsub = conn.subscribe(sessionId, mapper); const { providerID, modelID } = splitModel(params.model ?? ''); const system = params.cwd ? officerSystemPrompt(params.cwd) : undefined; // Fire the turn; assistant tokens + tool calls stream back over the SSE subscription above. conn.postMessage(sessionId, providerID, modelID, params.prompt, system).catch((err) => { logger.error('OpenCode postMessage failed', { sessionKey: params.sessionKey, error: String(err) }); params.onEvent({ type: 'error', message: 'Failed to send message to OpenCode' }); unsub(); }); return { kill: () => { void conn.abort(sessionId); unsub(); }, }; }