Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AgentSourceCore/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./cursor-source-turn.js";
export * from "./claude-code-source-turn.js";
export * from "./openclaw-source-turn.js";
export * from "./opencode-source-turn.js";
export * from "./opencode-v2-source.js";
export * from "./hermes-source-turn.js";
export * from "./deepseek-source-turn.js";
export * from "./deepseek-session-files.js";
Expand Down
141 changes: 141 additions & 0 deletions AgentSourceCore/src/opencode-v2-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { OpencodeSession } from "./opencode-source-turn.js";

/**
* OpenCode V2 renamed its conversation store: `session`/`message`/`part` became
* `session_v2`/`session_message`, message parts moved inline into `data.content`,
* and the assistant `parentID` link was dropped. These mappers project V2 rows onto
* the V1-shaped view the shared turn parser already consumes, so both schemas share
* one turn pipeline. Rows are fetched by the caller; only the projection lives here.
*/

/** One `session_v2` row. */
export interface OpencodeV2SessionRow {
id: string;
parentId: string | null;
directory: string | null;
agent?: string | null;
/** Raw JSON column; `{ messageID }` marks the revert boundary like V1. */
revert?: string | null;
}

/** One `session_message` row. `type` carries the role (`user`, `assistant`, ...). */
export interface OpencodeV2MessageRow {
id: string;
type: string;
seq: number;
/** Parsed `data` JSON. */
data: unknown;
}

export function toOpencodeV2Session(row: OpencodeV2SessionRow): OpencodeSession {
return {
id: String(row.id),
parentId: row.parentId == null ? null : String(row.parentId),
directory: row.directory == null ? null : String(row.directory),
agent: typeof row.agent === "string" ? row.agent : null,
revertMessageId: opencodeV2RevertMessageId(row.revert)
};
}

/**
* Maps one session's `session_message` rows onto the message view the shared turn
* parser expects. V2 has no assistant `parentID`, so turns are grouped sequentially:
* every assistant row belongs to the latest preceding user row, which reproduces the
* V1 parent buckets from `seq` order. Rows that are neither user nor assistant are
* bookkeeping (`idle`, `model-switched`, `system`, `synthetic`) and are dropped.
*/
export function toOpencodeV2MessageView(
rows: readonly OpencodeV2MessageRow[]
): Array<{ id: string; data: Record<string, unknown> | undefined }> {
const view: Array<{ id: string; data: Record<string, unknown> | undefined }> = [];
let parentId = "";
for (const row of rows) {
const data = isRecord(row.data) ? row.data : {};
if (row.type === "user") {
parentId = row.id;
view.push({
id: row.id,
data: {
role: "user",
...(typeof data.agent === "string" ? { agent: data.agent } : {}),
time: isRecord(data.time) ? data.time : undefined
}
});
continue;
}
if (row.type !== "assistant" || !parentId) continue;
const time = isRecord(data.time) ? data.time : {};
const completed = typeof time.completed === "number";
view.push({
id: row.id,
data: {
role: "assistant",
parentID: parentId,
...(typeof data.agent === "string" ? { agent: data.agent } : {}),
...(data.summary === true ? { summary: true } : {}),
...(isRecord(data.error) ? { error: data.error } : {}),
// V2 has no `finish`; a persisted completion timestamp is the stop signal.
...(completed ? { finish: "stop" } : {}),
time
}
});
}
return view;
}

/**
* Maps one `session_message` row onto the part view. V2 inlines message content in
* `data.content`; user text lives directly in `data.text`. Reasoning parts have no V1
* counterpart and are dropped.
*/
export function toOpencodeV2MessageParts(
row: OpencodeV2MessageRow
): Array<{ id: string; data: Record<string, unknown> | undefined }> {
const data = isRecord(row.data) ? row.data : {};
if (row.type === "user") {
const text = typeof data.text === "string" ? data.text : "";
return text ? [{ id: row.id + ":text", data: { type: "text", text } }] : [];
}
if (row.type !== "assistant") return [];
const content = Array.isArray(data.content) ? data.content : [];
const parts: Array<{ id: string; data: Record<string, unknown> | undefined }> = [];
for (const [index, item] of content.entries()) {
if (!isRecord(item)) continue;
if (item.type === "text" && typeof item.text === "string") {
parts.push({ id: row.id + ":" + index, data: { type: "text", text: item.text } });
} else if (item.type === "tool") {
parts.push({
id: row.id + ":" + index,
data: {
type: "tool",
callID: typeof item.id === "string" ? item.id : undefined,
tool: typeof item.name === "string" ? item.name : "tool",
...(isRecord(item.state) ? { state: item.state } : {}),
...(isRecord(item.metadata) ? { metadata: item.metadata } : {})
}
});
} else if (item.type === "compaction") {
parts.push({ id: row.id + ":" + index, data: { type: "compaction" } });
}
}
return parts;
}

function opencodeV2RevertMessageId(value: unknown): string | null {
const parsed = typeof value === "string" ? parseLoose(value) : value;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const messageId = (parsed as { messageID?: unknown }).messageID;
return typeof messageId === "string" && messageId ? messageId : null;
}

function parseLoose(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
157 changes: 157 additions & 0 deletions AgentSourceCore/src/tests/opencode-v2-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import {
readOpencodeSourceTurn,
toOpencodeV2MessageParts,
toOpencodeV2MessageView,
toOpencodeV2Session,
type OpencodeSource,
type OpencodeV2MessageRow
} from "../index.js";

describe("OpenCode V2 row mappers", () => {
it("maps a session_v2 row onto the shared session shape", () => {
expect(toOpencodeV2Session({
id: "ses_1",
parentId: null,
directory: "/work/project",
agent: "build",
revert: "{\"messageID\":\"msg_9\"}"
})).toEqual({
id: "ses_1",
parentId: null,
directory: "/work/project",
agent: "build",
revertMessageId: "msg_9"
});
expect(toOpencodeV2Session({ id: "ses_2", parentId: "ses_1", directory: null }).revertMessageId).toBeNull();
});

it("groups assistants under the latest preceding user row and drops bookkeeping rows", () => {
const rows: OpencodeV2MessageRow[] = [
{ id: "a0", type: "assistant", seq: 0, data: { agent: "build", time: { created: 0 }, content: [] } },
{ id: "m1", type: "model-switched", seq: 1, data: {} },
{ id: "u1", type: "user", seq: 2, data: { text: "first", time: { created: 1 } } },
{ id: "a1", type: "assistant", seq: 3, data: { agent: "build", time: { created: 2, completed: 3 }, content: [] } },
{ id: "i1", type: "idle", seq: 4, data: {} },
{ id: "a2", type: "assistant", seq: 5, data: { agent: "build", time: { created: 4, completed: 5 }, content: [] } },
{ id: "u2", type: "user", seq: 6, data: { text: "second", time: { created: 6 } } },
{ id: "a3", type: "assistant", seq: 7, data: { agent: "build", time: { created: 7, completed: 8 }, content: [] } }
];
const view = toOpencodeV2MessageView(rows);
expect(view.map((row) => row.id)).toEqual(["u1", "a1", "a2", "u2", "a3"]);
expect(view[1].data).toMatchObject({ role: "assistant", parentID: "u1", agent: "build", finish: "stop" });
expect(view[4].data).toMatchObject({ role: "assistant", parentID: "u2" });
expect(view[1].data?.finish).toBe("stop");
});

it("marks streaming assistants without a completion timestamp as unfinished", () => {
const view = toOpencodeV2MessageView([
{ id: "u1", type: "user", seq: 1, data: { text: "q", time: { created: 1 } } },
{ id: "a1", type: "assistant", seq: 2, data: { agent: "build", time: { created: 2 }, content: [] } }
]);
expect(view[1].data?.finish).toBeUndefined();
});

it("synthesizes a text part for user rows and maps inline content for assistant rows", () => {
expect(toOpencodeV2MessageParts({ id: "u1", type: "user", seq: 1, data: { text: "hello" } }))
.toEqual([{ id: "u1:text", data: { type: "text", text: "hello" } }]);
expect(toOpencodeV2MessageParts({ id: "u2", type: "user", seq: 2, data: {} })).toEqual([]);
const parts = toOpencodeV2MessageParts({
id: "a1",
type: "assistant",
seq: 3,
data: {
content: [
{ type: "reasoning", text: "internal" },
{ type: "text", text: "done" },
{ type: "tool", id: "call_1", name: "read", state: { status: "completed", input: { path: "/x" }, output: "ok" } },
{ type: "tool", name: "unnamed" },
{ type: "compaction" }
]
}
});
expect(parts).toEqual([
{ id: "a1:1", data: { type: "text", text: "done" } },
{
id: "a1:2",
data: {
type: "tool",
callID: "call_1",
tool: "read",
state: { status: "completed", input: { path: "/x" }, output: "ok" }
}
},
{ id: "a1:3", data: { type: "tool", callID: undefined, tool: "unnamed" } },
{ id: "a1:4", data: { type: "compaction" } }
]);
});

it("feeds the shared turn parser: a completed V2 turn captures with paired tools", async () => {
const source = createSource({
main: [
userRow("u1", "Inspect the configuration and report the result.", 1_000),
assistantRow("a1", "build", 2_000, 3_000, [{ type: "tool", id: "call_1", name: "read", state: { status: "completed", input: { path: "config" }, output: "Configuration verified." } }]),
assistantRow("a2", "build", 4_000, 5_000, [{ type: "text", text: "The configuration was verified." }])
]
});
const result = await readOpencodeSourceTurn(source, { conversationId: "main", turnId: "u1" });
expect(result.turn).toBeDefined();
expect(result.turn?.query).toBe("Inspect the configuration and report the result.");
expect(result.turn?.answer).toBe("The configuration was verified.");
expect(result.turn?.profileId).toBe("build");
expect(result.turn?.toolCalls).toHaveLength(1);
expect(result.turn?.toolCalls[0]).toMatchObject({ name: "read", status: "completed" });
expect(result.turn?.completionEvidence).toBe("assistant_completed:a2");
});

it("feeds the shared turn parser: an unfinished last assistant is left for a later attempt", async () => {
const source = createSource({
main: [
userRow("u1", "still running", 1_000),
assistantRow("a1", "build", 2_000, undefined, [{ type: "text", text: "partial" }])
]
});
const result = await readOpencodeSourceTurn(source, { conversationId: "main", turnId: "u1" });
expect(result.turn).toBeNull();
expect(result.reason).toBe("turn_incomplete");
});
});

function userRow(id: string, text: string, created: number): OpencodeV2MessageRow {
return { id, type: "user", seq: created, data: { text, time: { created } } };
}

function assistantRow(
id: string,
agent: string,
created: number,
completed: number | undefined,
content: unknown[]
): OpencodeV2MessageRow {
return {
id,
type: "assistant",
seq: created,
data: {
agent,
time: completed === undefined ? { created } : { created, completed },
content
}
};
}

function createSource(sessions: Record<string, OpencodeV2MessageRow[]>): OpencodeSource {
const rowsByMessage = new Map<string, OpencodeV2MessageRow>();
for (const rows of Object.values(sessions)) {
for (const row of rows) rowsByMessage.set(row.id, row);
}
return {
sessions: () => Object.keys(sessions).map((id) =>
toOpencodeV2Session({ id, parentId: null, directory: "/work/project", agent: "build" })),
messages: (sessionId) => toOpencodeV2MessageView(sessions[sessionId] ?? []),
parts: (messageId) => {
const row = rowsByMessage.get(messageId);
return row ? toOpencodeV2MessageParts(row) : [];
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
import { DatabaseSync } from "node:sqlite";
import {
readOpencodeSessions,
toOpencodeV2MessageParts,
toOpencodeV2MessageView,
toOpencodeV2Session,
type OpencodeSession,
type OpencodeSource,
type OpencodeV2MessageRow,
type OpencodeV2SessionRow,
type RawSourceMessage
} from "@memmy/agent-source-core";

Expand Down Expand Up @@ -41,7 +46,14 @@ export async function* streamOpencodeDatabase(path: string, signal?: AbortSignal

/** A database without the conversation tables is not a conversation store. */
function createSource(db: DatabaseSync): OpencodeSource | null {
if (!hasTable(db, "session") || !hasTable(db, "message") || !hasTable(db, "part")) return null;
if (hasTable(db, "session") && hasTable(db, "message") && hasTable(db, "part")) {
return createOpencodeV1Source(db);
}
// OpenCode V2 renamed the conversation tables and inlined message parts.
return createOpencodeV2Source(db);
}

function createOpencodeV1Source(db: DatabaseSync): OpencodeSource {
const columns = tableColumns(db, "session");
const sessions = db.prepare(`SELECT id, parent_id AS parentId, directory${columns.has("agent") ? ", agent" : ""}${columns.has("revert") ? ", revert" : ""} FROM session ORDER BY time_created ASC, id ASC`);
const messages = db.prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC");
Expand All @@ -55,6 +67,31 @@ function createSource(db: DatabaseSync): OpencodeSource | null {
};
}

/** Projects OpenCode V2 (`session_v2`/`session_message`) rows through the shared V2 mappers. */
function createOpencodeV2Source(db: DatabaseSync): OpencodeSource | null {
if (!hasTable(db, "session_v2") || !hasTable(db, "session_message")) return null;
const sessions = db.prepare("SELECT id, parent_id AS parentId, directory, agent, revert FROM session_v2 ORDER BY time_created ASC, id ASC");
const messages = db.prepare("SELECT id, type, seq, data FROM session_message WHERE session_id = ? ORDER BY seq ASC, id ASC");
const messageById = db.prepare("SELECT id, type, seq, data FROM session_message WHERE id = ?");
const toRow = (row: { id: string; type: string; seq: number; data: string }): OpencodeV2MessageRow => ({
id: row.id,
type: row.type,
seq: Number(row.seq),
data: parseJson(row.data)
});
return {
sessions: () => (sessions.all() as unknown as Array<Record<string, unknown>>)
.map((row) => toOpencodeV2Session(row as unknown as OpencodeV2SessionRow)),
messages: (sessionId) => toOpencodeV2MessageView(
(messages.all(sessionId) as unknown as Array<{ id: string; type: string; seq: number; data: string }>).map(toRow)
),
parts: (messageId) => {
const row = messageById.get(messageId) as unknown as { id: string; type: string; seq: number; data: string } | undefined;
return row ? toOpencodeV2MessageParts(toRow(row)) : [];
}
};
}

function toOpencodeSession(row: Record<string, unknown>): OpencodeSession {
return {
id: String(row.id),
Expand Down
Loading
Loading