diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index 229dd1e9..4c7c82b1 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -78,31 +78,45 @@ export async function loadOpenCodeSession(sessionId: string): Promise (a.time?.created ?? 0) - (b.time?.created ?? 0)); + + for (const m of ordered) { + // `type` is the current build, `info.role` the older one. See OpenCodeStoredMessage. + const role = m.type ?? m.info?.role; + const parts = m.content ?? m.parts ?? []; if (role === 'user') { - const text = parts - .filter((p) => p.type === 'text') - .map((p) => p.text ?? '') - .join('') - .trim(); + // The current build hands a user message's text over directly; the older one wraps it in parts. + const text = ( + m.text ?? + parts + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .join('') + ).trim(); if (text) messages.push({ role: 'user', text }); continue; } if (role === 'assistant') { - if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? ''; + if (!modelId) modelId = m.model?.id ?? m.model?.modelID ?? m.info?.model?.modelID ?? m.info?.modelID ?? ''; for (const p of parts) { if (p.type === 'text' && p.text) { messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text }); - } else if (p.type === 'tool' && p.callID) { + } else if (p.type === 'tool') { + const callId = p.callID ?? p.id; + if (!callId) continue; messages.push({ role: 'tool', - toolName: p.tool ?? 'tool', + toolName: p.name ?? p.tool ?? 'tool', toolInput: p.state?.input ?? {}, - toolCallId: p.callID, + toolCallId: callId, output: p.state?.output, isError: Boolean(p.state?.error), }); @@ -112,6 +126,11 @@ export async function loadOpenCodeSession(sessionId: string): Promise { - const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`); - if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`); - return (await res.json()) as OpenCodeStoredMessage[]; + const res = await fetch(`${this.baseUrl}/api/session/${sessionId}/message`); + if (!res.ok) throw new Error(`opencode GET /api/session/${sessionId}/message → ${res.status}`); + const body = (await res.json()) as { data?: OpenCodeStoredMessage[] } | OpenCodeStoredMessage[]; + return Array.isArray(body) ? body : (body.data ?? []); } async deleteSession(sessionId: string): Promise { @@ -89,7 +101,12 @@ class ServerConnection { export type OpenCodeSessionInfo = { id: string; title?: string; - time?: { created?: number; updated?: number }; + time?: { + created?: number; + updated?: number; + }; + /** The model this conversation is on. Present before any assistant turn has landed. */ + model?: { id?: string; modelID?: string; providerID?: string }; /** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */ directory?: string | null; }; @@ -102,11 +119,36 @@ export type OpenCodeStoredPart = { text?: string; tool?: string; callID?: string; - state?: { input?: Record; output?: string; error?: string }; + state?: { input?: Record; output?: string; error?: string; status?: string }; + /** Current build names a tool part here; the older one used `tool`. */ + name?: string; + /** Current build's id for a tool call, where the older one used `callID`. */ + id?: string; }; +/** + * Two shapes, because two builds. + * + * The older one nests everything: `{ info: { role }, parts: [...] }`. The current one flattens it — + * `{ type: 'user' | 'assistant', text, content: [...] }` — and puts a user message's text directly on + * the message rather than in a part. Both are accepted rather than pinned to today's build: this reader + * is the only thing standing between a working transcript and a chat that looks erased, and a host on a + * different opencode should degrade to fewer features, never to an empty conversation. + */ export type OpenCodeStoredMessage = { + /** Wire order is newest-first, so the reader sorts on this. */ + time?: { created?: number }; + /** Current build: the role, flattened. */ + type?: string; + /** Current build: a user message's text, with no part wrapping it. */ + text?: string; + /** Current build: the assistant's parts. */ + content?: OpenCodeStoredPart[]; + /** Current build: `{ id, providerID }`. */ + model?: { id?: string; modelID?: string; providerID?: string }; + /** Older build. */ info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } }; + /** Older build. */ parts?: OpenCodeStoredPart[]; }; diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index ef26aaca..d2da0cf3 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -161,6 +161,33 @@ function permissionRequest(raw: unknown): { sessionID: string; requestID: string return { sessionID, requestID }; } +/* + Name the conversation from its opening message. + + OpenCode titles a session from its contents — eventually, and better than anything we would write. It + is not happening on this surface: sessions stay at `New session - ` through a finished turn + and past it, so the owner's session list is a column of "(untitled)" with nothing to tell one from + another. `isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI. + + So: the first line of the first prompt, which is what the Claude side shows and what a person would + have picked. Fire-and-forget on session CREATE only — a resumed session keeps whatever it has, and if + OpenCode ever does title one, it is writing after us and wins. +*/ +async function nameSession(config: ServeConfig, sessionID: string, prompt: string, cwd: string): Promise { + const title = prompt.split('\n')[0]?.trim().slice(0, 60); + if (!title) return; + try { + await serveJson(config, `/session/${sessionID}`, { + method: 'PATCH', + body: JSON.stringify({ title }), + cwd, + }); + } catch (err) { + // A conversation that works and is badly named is not worth failing a turn over. + console.error('[opencode] could not name the session', err instanceof Error ? err.message : err); + } +} + function handleServeEvent(raw: unknown, config?: ServeConfig): void { const ask = permissionRequest(raw); if (ask && config) { @@ -292,6 +319,7 @@ export async function runOpenCodeTurnOnServe( }); if (!created?.id) throw new Error('session create returned no id'); openCodeSessionId = created.id; + void nameSession(config, openCodeSessionId, params.prompt, cwd); } if (params.model) {