an opencode chat you can reopen: it has a name, its messages, and its model
three complaints, one root cause and two small ones. THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message` and this build answers that with `[]` — 200, well-formed, and wrong. the same session returns 4 messages on `/api/session/<id>/message`. an empty transcript is indistinguishable from a new conversation, so reopening a chat said "send a message to start" and every message the owner had sent looked deleted. that also explains the third complaint. the model was read from the assistant turns, so no messages meant no model, so the detail reported a bare `opencode`, which the composer cannot match — and it fell back to claude code and sonnet. it now also reads the model off the session record, which is populated before any assistant turn lands. the message shape moved with the endpoint: `type` where it was `info.role`, `content` where it was `parts`, and a user message's text sits on the message rather than in a part. both shapes are accepted rather than pinned to today's build — this reader is the only thing between a working transcript and a chat that looks erased, and a host on a different opencode should lose features, never its history. AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing downstream re-sorted, so the answer sat above the question that produced it. sorted here, because this is the only place that still has timestamps. NAMING: opencode is meant to title a session from its contents and does not, on this surface — sessions stay at `New session - <timestamp>` through a finished turn and past it, which is why the list was a column of "(untitled)". `isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI. so the sidecar names a NEW session from the first line of its first prompt, which is what the claude side shows. a resumed session keeps what it has, and if opencode ever does title one it writes after us and wins. verified live on the session that prompted this: 4 messages, in order, model opencode/kimi-k3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -78,31 +78,45 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
|
||||
const messages: ClaudeChatMessage[] = [];
|
||||
let modelId = '';
|
||||
|
||||
for (const m of stored) {
|
||||
const role = m.info?.role;
|
||||
const parts = m.parts ?? [];
|
||||
/*
|
||||
OLDEST FIRST. The endpoint returns newest first, and nothing downstream re-sorts — so the
|
||||
conversation rendered backwards: the answer above the question that produced it, and the opening
|
||||
message at the bottom. Sorting here rather than at the call site because this is the only place
|
||||
that knows the wire order, and the display shape it returns has no timestamps left to sort by.
|
||||
*/
|
||||
const ordered = [...stored].sort((a, b) => (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<ClaudeSess
|
||||
}
|
||||
}
|
||||
|
||||
// The session record carries the model too, and it is the only source for a conversation whose
|
||||
// assistant turns have not landed yet. Without it the detail reports a bare `opencode`, the composer
|
||||
// cannot match that to anything, and reopening the chat silently switches the harness back to Claude.
|
||||
if (!modelId) modelId = info?.model?.id ?? info?.model?.modelID ?? '';
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
model: modelId ? `opencode/${modelId}` : 'opencode',
|
||||
|
||||
@@ -47,10 +47,22 @@ class ServerConnection {
|
||||
return (await res.json()) as OpenCodeSessionInfo;
|
||||
}
|
||||
|
||||
/*
|
||||
`/api/session/…/message`, NOT `/session/…/message`.
|
||||
|
||||
Both paths exist and only one still works. The legacy one answers 200 with an empty array for a
|
||||
session that plainly has messages — verified 2026-08-27 against a session showing 4 on the `/api`
|
||||
path and 0 on the other. An empty transcript is indistinguishable from a new conversation, so
|
||||
reopening an OpenCode chat showed "Send a message to start" and every message the owner had sent was
|
||||
apparently gone. It also emptied the model, which is what made the composer fall back to Claude.
|
||||
|
||||
Every other read this client does is fine on either path; this is the only one that moved.
|
||||
*/
|
||||
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
|
||||
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<boolean> {
|
||||
@@ -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<string, unknown>; output?: string; error?: string };
|
||||
state?: { input?: Record<string, unknown>; 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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 - <timestamp>` 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<void> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user