diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporter.java b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporter.java
index ca2d1f017..8592a4a03 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporter.java
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporter.java
@@ -25,12 +25,23 @@
/**
* Exports {@link EntryData} list into OpenCode's native SQLite format.
*
- *
OpenCode stores sessions in {@code opencode.db} with tables managed by drizzle
- * migrations: {@code project}, {@code session}, {@code message}, and {@code part}.
- * This exporter creates records matching the real schema so that OpenCode can
- * resume the conversation via {@code resumeSessionId} in ACP {@code session/new}.
+ * OpenCode stores sessions in {@code opencode.db} with drizzle-managed tables. This
+ * exporter detects the installed schema generation and writes rows only in a shape that
+ * generation actually reads, so {@code session/resume} can pick the conversation up:
*
- * Key schema details (OpenCode 1.2.x):
+ * OpenCode 2.x ({@code session_v2} table present):
+ *
+ * - {@code session_v2} requires {@code project_id}, {@code slug}, {@code directory},
+ * {@code version}, {@code time_created}, {@code time_updated}
+ * - Messages live in {@code session_message} ({@code type}/{@code seq} columns, parts
+ * embedded as a {@code content} array inside {@code data}); the legacy
+ * {@code message}/{@code part} tables are not read for new sessions
+ * - {@code event_sequence} must carry the last used {@code seq} for the session
+ * - Session lookup by id does not scope by project; the {@code directory} column must
+ * match the cwd the ACP client sends with the resume request
+ *
+ *
+ * OpenCode 1.2.x (legacy {@code session} table present):
*
* - {@code session} requires {@code project_id} (FK→project), {@code slug}, {@code version}
* - {@code message} requires {@code time_updated}
@@ -38,6 +49,10 @@
* - IDs use prefixed format: {@code ses_}, {@code msg_}, {@code prt_}
* - {@code project.id} = SHA-1 hex of the worktree path
*
+ *
+ * When neither schema is present the database has not been initialized by OpenCode yet;
+ * the export is skipped rather than fabricating tables, because a v2 process migrating a
+ * database later chokes on unexpected table shapes.
*/
public final class OpenCodeClientExporter {
@@ -117,6 +132,14 @@ public static String exportSession(
String sessionId = generateId("ses");
try (Connection conn = openSqlite(dbPath)) {
+ if (tableExists(conn, "session_v2")) {
+ return exportSessionV2(conn, sessionId, entries, projectDir);
+ }
+ if (!tableExists(conn, "session")) {
+ LOG.warn("OpenCode database at " + dbPath + " has no initialized schema; skipping raw session export");
+ return null;
+ }
+
ensureTables(conn);
long now = System.currentTimeMillis();
@@ -499,6 +522,389 @@ private static void insertSession(
}
}
+ // ── OpenCode 2.x export (session_v2 schema) ──────────────────────────────
+
+ /**
+ * Returns {@code true} when the database contains the given table. Used to detect
+ * which OpenCode schema generation owns the database before writing any rows.
+ */
+ static boolean tableExists(@NotNull Connection conn, @NotNull String table) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")) {
+ ps.setString(1, table);
+ try (ResultSet rs = ps.executeQuery()) {
+ return rs.next();
+ }
+ }
+ }
+
+ /**
+ * Writes the conversation against the OpenCode 2.x schema: one {@code session_v2} row,
+ * {@code session_message} rows with parts embedded in a {@code content} array, and an
+ * {@code event_sequence} watermark matching the last used {@code seq}. Project rows are
+ * never created here; v2 resolves and persists projects itself on startup.
+ */
+ @Nullable
+ private static String exportSessionV2(
+ @NotNull Connection conn,
+ @NotNull String sessionId,
+ @NotNull List entries,
+ @NotNull String projectDir) throws SQLException {
+
+ long now = System.currentTimeMillis();
+ boolean previousAutoCommit = conn.getAutoCommit();
+ conn.setAutoCommit(false);
+ try {
+ String projectId = resolveProjectIdV2(conn, projectDir);
+ insertSessionV2(conn, sessionId, projectId, projectDir, latestSessionVersionV2(conn), now);
+
+ int messageCount = processEntriesV2(conn, sessionId, entries, now);
+ upsertEventSequence(conn, sessionId, Math.max(messageCount - 1, 0));
+
+ conn.commit();
+ LOG.info("Exported session to OpenCode 2.x storage: " + sessionId
+ + " (project=" + projectId + ", messages=" + messageCount + ")");
+ return sessionId;
+ } catch (SQLException | RuntimeException e) {
+ conn.rollback();
+ throw e;
+ } finally {
+ conn.setAutoCommit(previousAutoCommit);
+ }
+ }
+
+ /**
+ * Finds the project id v2 associates with this directory: the row whose worktree is the
+ * enclosing git repository root, else the row for the directory itself, else the literal
+ * {@code global} id v2 assigns to sessions with no resolvable project.
+ */
+ @NotNull
+ static String resolveProjectIdV2(@NotNull Connection conn, @NotNull String projectDir) throws SQLException {
+ Path gitRoot = findGitRoot(projectDir);
+ if (gitRoot != null) {
+ String id = findProjectByWorktree(conn, gitRoot.toString());
+ if (id != null) return id;
+ }
+ String id = findProjectByWorktree(conn, Path.of(projectDir).toString());
+ return id != null ? id : "global";
+ }
+
+ /**
+ * Walks up from {@code projectDir} looking for a {@code .git} entry, mirroring how v2
+ * resolves a directory to its enclosing repository. Returns {@code null} outside any repo.
+ */
+ @Nullable
+ static Path findGitRoot(@NotNull String projectDir) {
+ Path dir = Path.of(projectDir).toAbsolutePath().normalize();
+ while (dir != null) {
+ if (Files.exists(dir.resolve(".git"))) return dir;
+ dir = dir.getParent();
+ }
+ return null;
+ }
+
+ @Nullable
+ private static String findProjectByWorktree(@NotNull Connection conn, @NotNull String worktree) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement("SELECT id FROM project WHERE worktree = ?")) {
+ ps.setString(1, worktree.replace('\\', '/'));
+ try (ResultSet rs = ps.executeQuery()) {
+ return rs.next() ? rs.getString(1) : null;
+ }
+ }
+ }
+
+ /**
+ * Copies the version string from the most recent v2 session so exported rows are
+ * indistinguishable from native ones; v2 treats the value as informational only.
+ */
+ @NotNull
+ private static String latestSessionVersionV2(@NotNull Connection conn) throws SQLException {
+ try (Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT version FROM session_v2 ORDER BY time_created DESC LIMIT 1")) {
+ String version = rs.next() ? rs.getString(1) : null;
+ return version != null ? version : "2.0.0";
+ }
+ }
+
+ /**
+ * Inserts the {@code session_v2} row. {@code directory} is normalized to forward slashes
+ * because v2 compares it against its own normalized cwd form on every lookup, and
+ * {@code path} is the session directory relative to the git root (empty at the root
+ * itself), matching how v2 populates the column.
+ */
+ private static void insertSessionV2(
+ @NotNull Connection conn,
+ @NotNull String sessionId,
+ @NotNull String projectId,
+ @NotNull String projectDir,
+ @NotNull String version,
+ long now) throws SQLException {
+ Path gitRoot = findGitRoot(projectDir);
+ String relativePath = "";
+ if (gitRoot != null) {
+ String relative = gitRoot.toAbsolutePath().normalize()
+ .relativize(Path.of(projectDir).toAbsolutePath().normalize()).toString().replace('\\', '/');
+ if (!relative.isEmpty()) relativePath = relative;
+ }
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO session_v2 (id, project_id, slug, directory, path, title, version, "
+ + "time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")) {
+ ps.setString(1, sessionId);
+ ps.setString(2, projectId);
+ ps.setString(3, generateSlug());
+ ps.setString(4, projectDir.replace('\\', '/'));
+ ps.setString(5, relativePath);
+ ps.setString(6, IMPORTED_TITLE);
+ ps.setString(7, version);
+ ps.setLong(8, now);
+ ps.setLong(9, now);
+ ps.executeUpdate();
+ }
+ }
+
+ /**
+ * v2 message writer. Same user/assistant turn pairing as the legacy path, but each
+ * message is a single {@code session_message} row whose {@code data} carries the role
+ * payload with parts embedded in a {@code content} array. {@code seq} starts at 0 and
+ * increments once per row, matching v2's own v1-to-v2 migration.
+ */
+ private static int processEntriesV2(
+ @NotNull Connection conn,
+ @NotNull String sessionId,
+ @NotNull List entries,
+ long startTime) throws SQLException {
+
+ long prevTime = startTime;
+ int seq = 0;
+ int messageCount = 0;
+
+ List pendingContent = null;
+ String pendingAgent = null;
+ String pendingModel = null;
+
+ for (EntryData entry : entries) {
+ switch (entry) {
+ case EntryData.Prompt prompt -> {
+ if (pendingContent != null && !pendingContent.isEmpty()) {
+ prevTime++;
+ applyForeignAgentPrefix(pendingContent, pendingAgent);
+ insertSessionMessage(conn, sessionId, "assistant", seq++,
+ buildV2AssistantData(pendingAgent, pendingModel, pendingContent, prevTime), prevTime);
+ pendingContent = null;
+ pendingAgent = null;
+ pendingModel = null;
+ messageCount++;
+ }
+ prevTime++;
+ insertSessionMessage(conn, sessionId, "user", seq++,
+ buildV2UserData(prompt.getText(), prevTime), prevTime);
+ messageCount++;
+ }
+ case EntryData.Text text -> {
+ JsonObject content = new JsonObject();
+ content.addProperty("type", "text");
+ content.addProperty("text", text.getRaw());
+ if (pendingContent == null) {
+ pendingContent = new ArrayList<>();
+ pendingAgent = text.getAgent();
+ pendingModel = text.getModel();
+ }
+ pendingContent.add(content);
+ }
+ case EntryData.Thinking thinking -> {
+ if (pendingContent == null) {
+ pendingContent = new ArrayList<>();
+ pendingAgent = thinking.getAgent();
+ pendingModel = thinking.getModel();
+ }
+ pendingContent.add(buildV2ReasoningContent(thinking.getRaw(), prevTime));
+ }
+ case EntryData.ToolCall toolCall -> {
+ if (pendingContent == null) {
+ pendingContent = new ArrayList<>();
+ pendingAgent = toolCall.getAgent();
+ pendingModel = toolCall.getModel();
+ }
+ pendingContent.add(buildV2ToolContent(toolCall, prevTime));
+ }
+ case EntryData.SubAgent subAgent -> {
+ JsonObject content = buildSubAgentPart(subAgent);
+ if (pendingContent == null) {
+ pendingContent = new ArrayList<>();
+ pendingAgent = subAgent.getAgent();
+ pendingModel = subAgent.getModel();
+ }
+ pendingContent.add(content);
+ }
+ default -> {
+ // Skip: Status, TurnStats, ContextFiles, SessionSeparator
+ }
+ }
+ }
+
+ if (pendingContent != null && !pendingContent.isEmpty()) {
+ prevTime++;
+ applyForeignAgentPrefix(pendingContent, pendingAgent);
+ insertSessionMessage(conn, sessionId, "assistant", seq++,
+ buildV2AssistantData(pendingAgent, pendingModel, pendingContent, prevTime), prevTime);
+ messageCount++;
+ }
+
+ return messageCount;
+ }
+
+ private static void insertSessionMessage(
+ @NotNull Connection conn,
+ @NotNull String sessionId,
+ @NotNull String type,
+ int seq,
+ @NotNull JsonObject data,
+ long time) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) "
+ + "VALUES (?, ?, ?, ?, ?, ?, ?)")) {
+ ps.setString(1, generateId("msg"));
+ ps.setString(2, sessionId);
+ ps.setString(3, type);
+ ps.setInt(4, seq);
+ ps.setLong(5, time);
+ ps.setLong(6, time);
+ ps.setString(7, GSON.toJson(data));
+ ps.executeUpdate();
+ }
+ }
+
+ /**
+ * Records the last used {@code seq} for the session. v2 reads this watermark to know
+ * which events a session has already consumed, so it must not lag behind the rows we
+ * just wrote.
+ */
+ private static void upsertEventSequence(@NotNull Connection conn, @NotNull String sessionId, int lastSeq) throws SQLException {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES (?, ?, NULL) "
+ + "ON CONFLICT(aggregate_id) DO UPDATE SET seq = excluded.seq, owner_id = NULL")) {
+ ps.setString(1, sessionId);
+ ps.setInt(2, lastSeq);
+ ps.executeUpdate();
+ }
+ }
+
+ /** v2 user payload: plain text plus empty attachment arrays, mirroring native rows. */
+ @NotNull
+ static JsonObject buildV2UserData(@NotNull String text, long time) {
+ JsonObject data = new JsonObject();
+ data.addProperty("text", text);
+ data.add("files", new com.google.gson.JsonArray());
+ data.add("agents", new com.google.gson.JsonArray());
+ JsonObject timeObj = new JsonObject();
+ timeObj.addProperty("created", time);
+ data.add("time", timeObj);
+ return data;
+ }
+
+ /** v2 assistant payload: model info and token counters at message level, parts inside {@code content}. */
+ @NotNull
+ static JsonObject buildV2AssistantData(@Nullable String agent, @Nullable String model, @NotNull List content, long time) {
+ String modelId = (model != null && !model.isEmpty()) ? model : "imported";
+ String agentName = (agent != null && !agent.isEmpty()) ? agent : "build";
+
+ JsonObject data = new JsonObject();
+ data.addProperty("agent", agentName);
+ JsonObject modelObj = new JsonObject();
+ modelObj.addProperty("providerID", "imported");
+ modelObj.addProperty("id", modelId);
+ modelObj.addProperty("variant", "default");
+ data.add("model", modelObj);
+ com.google.gson.JsonArray contentArray = new com.google.gson.JsonArray();
+ content.forEach(contentArray::add);
+ data.add("content", contentArray);
+ data.addProperty("cost", 0);
+ JsonObject cache = new JsonObject();
+ cache.addProperty("read", 0);
+ cache.addProperty("write", 0);
+ JsonObject tokens = new JsonObject();
+ tokens.addProperty(FIELD_INPUT, 0);
+ tokens.addProperty("output", 0);
+ tokens.addProperty("reasoning", 0);
+ tokens.add("cache", cache);
+ data.add("tokens", tokens);
+ JsonObject timeObj = new JsonObject();
+ timeObj.addProperty("created", time);
+ timeObj.addProperty("completed", time);
+ data.add("time", timeObj);
+ return data;
+ }
+
+ /** v2 reasoning entry; timestamps use the {@code created}/{@code completed} pair. */
+ @NotNull
+ static JsonObject buildV2ReasoningContent(@NotNull String text, long time) {
+ JsonObject content = new JsonObject();
+ content.addProperty("type", "reasoning");
+ content.addProperty("text", text);
+ JsonObject timeObj = new JsonObject();
+ timeObj.addProperty("created", time);
+ timeObj.addProperty("completed", time);
+ content.add("time", timeObj);
+ return content;
+ }
+
+ /**
+ * v2 tool entry. A completed call carries its output as a text entry inside
+ * {@code state.content}; an unfinished one is recorded as interrupted, the same shape
+ * v2's own migration produces for calls it cannot replay.
+ */
+ @NotNull
+ static JsonObject buildV2ToolContent(@NotNull EntryData.ToolCall toolCall, long time) {
+ JsonObject result = new JsonObject();
+ result.addProperty("type", "tool");
+ result.addProperty("id", toolCall.getEntryId());
+ result.addProperty("name", ExportUtils.exportMcpToolName(toolCall, ExportUtils.OPENCODE_MCP_PREFIX));
+
+ JsonObject state = new JsonObject();
+ state.add(FIELD_INPUT, parseToolInput(toolCall.getArguments()));
+ if (toolCall.getResult() != null) {
+ state.addProperty("status", "completed");
+ com.google.gson.JsonArray outputContent = new com.google.gson.JsonArray();
+ JsonObject textEntry = new JsonObject();
+ textEntry.addProperty("type", "text");
+ textEntry.addProperty("text", toolCall.getResult());
+ outputContent.add(textEntry);
+ state.add("content", outputContent);
+ } else {
+ state.addProperty("status", "error");
+ JsonObject error = new JsonObject();
+ error.addProperty("type", "tool.interrupted");
+ error.addProperty("message", "Tool execution was interrupted before export");
+ state.add("error", error);
+ }
+ result.add("state", state);
+
+ JsonObject timeObj = new JsonObject();
+ timeObj.addProperty("created", time);
+ if (toolCall.getResult() != null) timeObj.addProperty("completed", time);
+ result.add("time", timeObj);
+ return result;
+ }
+
+ /**
+ * Parses tool call arguments into the JSON object v2 expects. Non-object or unparsable
+ * payloads are wrapped in a {@code raw} key so the value still decodes as an object.
+ */
+ @NotNull
+ static JsonObject parseToolInput(@Nullable String argsStr) {
+ if (argsStr != null && !argsStr.isBlank()) {
+ try {
+ JsonElement parsed = com.google.gson.JsonParser.parseString(argsStr);
+ if (parsed.isJsonObject()) return parsed.getAsJsonObject();
+ } catch (Exception ignored) {
+ // fall through to the raw wrapper below
+ }
+ }
+ JsonObject wrapper = new JsonObject();
+ if (argsStr != null && !argsStr.isBlank()) wrapper.addProperty("raw", argsStr);
+ return wrapper;
+ }
+
// ── Message insertion ─────────────────────────────────────────────────────
/**
@@ -665,25 +1071,7 @@ private static JsonObject buildToolInvocationPart(@NotNull EntryData.ToolCall to
stateObj.addProperty("status", completed ? "completed" : "running");
// OpenCode Zod schema requires input: z.record(z.string(), z.any()) — must be a JSON object.
- String argsStr = toolCall.getArguments();
- if (argsStr != null && !argsStr.isBlank()) {
- try {
- JsonElement parsed = com.google.gson.JsonParser.parseString(argsStr);
- if (parsed.isJsonObject()) {
- stateObj.add(FIELD_INPUT, parsed);
- } else {
- JsonObject wrapper = new JsonObject();
- wrapper.addProperty("raw", argsStr);
- stateObj.add(FIELD_INPUT, wrapper);
- }
- } catch (Exception e) {
- JsonObject wrapper = new JsonObject();
- wrapper.addProperty("raw", argsStr);
- stateObj.add(FIELD_INPUT, wrapper);
- }
- } else {
- stateObj.add(FIELD_INPUT, new JsonObject());
- }
+ stateObj.add(FIELD_INPUT, parseToolInput(toolCall.getArguments()));
if (completed) {
stateObj.addProperty("output", toolCall.getResult());
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeClientRoundTripTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeClientRoundTripTest.java
index 345c31c2d..f908fd967 100644
--- a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeClientRoundTripTest.java
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeClientRoundTripTest.java
@@ -241,13 +241,16 @@ void exportEmptyMessagesReturnsNull() {
}
@Test
- void exportToNonExistentDbCreatesIt() {
+ void exportToNewDirectoryWithInitializedDbSucceeds() throws Exception {
+ // An empty database is treated as "not initialized by OpenCode" and skipped, so the
+ // legacy schema must exist already (as it does after OpenCode 1.x has started once).
Path freshDb = tempDir.resolve("fresh/opencode.db");
- // Need at least one non-prompt entry so stripping the trailing prompt leaves content
+ Files.createDirectories(freshDb.getParent());
+ createTables(freshDb);
String sessionId = OpenCodeClientExporter.exportSession(
List.of(promptEntry("hi"), textEntry("Hello!")), freshDb, PROJECT_DIR);
- assertNotNull(sessionId, "Exporter should create the DB and succeed");
- assertTrue(Files.exists(freshDb), "Database file should have been created");
+ assertNotNull(sessionId, "Exporter should succeed against an initialized legacy DB");
+ assertTrue(Files.exists(freshDb), "Database file should exist");
}
// ── Round-trip tests ────────────────────────────────────────────
@@ -606,7 +609,7 @@ private static Connection connect(Path dbPath) throws SQLException {
* Creates tables matching the real OpenCode schema (drizzle-managed).
* This ensures tests validate against production constraints.
*/
- private static void createTables(Path dbPath) throws SQLException {
+ static void createTables(Path dbPath) throws SQLException {
try (Connection conn = connect(dbPath);
Statement stmt = conn.createStatement()) {
stmt.execute("""
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeZodValidationTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeZodValidationTest.java
index 156eb1b65..11336bc25 100644
--- a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeZodValidationTest.java
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/OpenCodeZodValidationTest.java
@@ -77,6 +77,13 @@ static void ensureNodeAndZod() throws IOException, InterruptedException {
}
}
+ /** Creates an OpenCode 1.x database; the exporter skips databases OpenCode never initialized. */
+ private Path legacyDb() throws SQLException {
+ Path dbPath = tempDir.resolve("opencode.db");
+ OpenCodeClientRoundTripTest.createTables(dbPath);
+ return dbPath;
+ }
+
@Test
void exportedSessionPassesZodValidation() throws Exception {
List entries = List.of(
@@ -88,7 +95,7 @@ void exportedSessionPassesZodValidation() throws Exception {
assistantText("You're welcome!")
);
- Path dbPath = tempDir.resolve("opencode.db");
+ Path dbPath = legacyDb();
String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, PROJECT_DIR);
assertTrue(sessionId != null && !sessionId.isEmpty(), "Export should succeed");
@@ -110,7 +117,7 @@ void exportedSubagentPartPassesZodValidation() throws Exception {
subAgent("explore", "Exploring codebase", "Found 3 files")
);
- Path dbPath = tempDir.resolve("opencode.db");
+ Path dbPath = legacyDb();
String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, PROJECT_DIR);
assertTrue(sessionId != null && !sessionId.isEmpty());
@@ -133,7 +140,7 @@ void exportedRunningToolPartPassesZodValidation() throws Exception {
tc
);
- Path dbPath = tempDir.resolve("opencode.db");
+ Path dbPath = legacyDb();
String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, PROJECT_DIR);
assertTrue(sessionId != null && !sessionId.isEmpty());
@@ -162,7 +169,7 @@ void exportedToolPartWithEmptyArgsPassesZodValidation() throws Exception {
toolCall("read_file", "", "file content")
);
- Path dbPath = tempDir.resolve("opencode.db");
+ Path dbPath = legacyDb();
String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, PROJECT_DIR);
assertNotNull(sessionId);
@@ -184,7 +191,7 @@ void exportedToolPartWithNullArgsPassesZodValidation() throws Exception {
toolCall("search_text", null, "results")
);
- Path dbPath = tempDir.resolve("opencode.db");
+ Path dbPath = legacyDb();
String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, PROJECT_DIR);
assertNotNull(sessionId);
@@ -212,7 +219,7 @@ void consecutiveAssistantMessagesAreMergedDuringExport() throws Exception {
assistantText("You're welcome!", "build", "claude-sonnet-4.6")
);
- Path dbPath = tempDir.resolve("opencode.db");
+ Path dbPath = legacyDb();
String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, PROJECT_DIR);
assertNotNull(sessionId, "Export should succeed");
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporterTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporterTest.java
index b35aa22c5..7c9262715 100644
--- a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporterTest.java
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/session/exporters/OpenCodeClientExporterTest.java
@@ -1,12 +1,20 @@
package com.github.catatafishen.agentbridge.session.exporters;
import com.github.catatafishen.agentbridge.bridge.EntryData;
+import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.security.MessageDigest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
@@ -16,6 +24,7 @@
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -550,6 +559,336 @@ void applyForeignAgentPrefix_noTextPart_prependsNotePart() {
assertEquals("tool", parts.get(1).get("type").getAsString());
}
+ // ── OpenCode 2.x payload builders ────────────────────────────────────────
+
+ @Test
+ void buildV2UserData_hasTextAttachmentsAndTime() {
+ JsonObject data = OpenCodeClientExporter.buildV2UserData("hello", 1000L);
+ assertEquals("hello", data.get("text").getAsString());
+ assertEquals(0, data.getAsJsonArray("files").size());
+ assertEquals(0, data.getAsJsonArray("agents").size());
+ assertEquals(1000L, data.getAsJsonObject("time").get("created").getAsLong());
+ assertFalse(data.has("role"));
+ assertFalse(data.has("agent"));
+ assertFalse(data.has("model"));
+ }
+
+ @Test
+ void buildV2AssistantData_hasModelContentCostTokens() {
+ JsonObject textEntry = new JsonObject();
+ textEntry.addProperty("type", "text");
+ textEntry.addProperty("text", "answer");
+ JsonObject data = OpenCodeClientExporter.buildV2AssistantData("coder", "gpt-4", List.of(textEntry), 2000L);
+ assertEquals("coder", data.get("agent").getAsString());
+ JsonObject model = data.getAsJsonObject("model");
+ assertEquals("imported", model.get("providerID").getAsString());
+ assertEquals("gpt-4", model.get("id").getAsString());
+ assertEquals("default", model.get("variant").getAsString());
+ assertEquals(1, data.getAsJsonArray("content").size());
+ assertEquals(0, data.get("cost").getAsInt());
+ JsonObject tokens = data.getAsJsonObject("tokens");
+ assertEquals(0, tokens.get("input").getAsInt());
+ assertEquals(0, tokens.getAsJsonObject("cache").get("read").getAsInt());
+ assertEquals(2000L, data.getAsJsonObject("time").get("created").getAsLong());
+ assertEquals(2000L, data.getAsJsonObject("time").get("completed").getAsLong());
+ assertFalse(data.has("role"));
+ assertFalse(data.has("path"));
+ assertFalse(data.has("parentID"));
+ }
+
+ @Test
+ void buildV2AssistantData_nullAgentAndModel_useDefaults() {
+ JsonObject data = OpenCodeClientExporter.buildV2AssistantData(null, null, List.of(), 1L);
+ assertEquals("build", data.get("agent").getAsString());
+ assertEquals("imported", data.getAsJsonObject("model").get("id").getAsString());
+ }
+
+ @Test
+ void buildV2ReasoningContent_usesCreatedCompletedTimePair() {
+ JsonObject content = OpenCodeClientExporter.buildV2ReasoningContent("thinking", 3000L);
+ assertEquals("reasoning", content.get("type").getAsString());
+ assertEquals("thinking", content.get("text").getAsString());
+ assertEquals(3000L, content.getAsJsonObject("time").get("created").getAsLong());
+ assertEquals(3000L, content.getAsJsonObject("time").get("completed").getAsLong());
+ assertFalse(content.getAsJsonObject("time").has("start"));
+ }
+
+ @Test
+ void buildV2ToolContent_completedCall_outputInsideStateContent() {
+ EntryData.ToolCall tc = new EntryData.ToolCall("read_file", "{\"path\":\"a.txt\"}");
+ tc.setResult("file contents");
+ JsonObject content = OpenCodeClientExporter.buildV2ToolContent(tc, 5000L);
+ assertEquals("tool", content.get("type").getAsString());
+ assertEquals("read_file", content.get("name").getAsString());
+ JsonObject state = content.getAsJsonObject("state");
+ assertEquals("completed", state.get("status").getAsString());
+ assertEquals("a.txt", state.getAsJsonObject("input").get("path").getAsString());
+ assertEquals("file contents", state.getAsJsonArray("content").get(0).getAsJsonObject().get("text").getAsString());
+ assertEquals(5000L, content.getAsJsonObject("time").get("created").getAsLong());
+ assertEquals(5000L, content.getAsJsonObject("time").get("completed").getAsLong());
+ assertFalse(state.has("output"));
+ }
+
+ @Test
+ void buildV2ToolContent_unfinishedCall_recordedAsInterrupted() {
+ EntryData.ToolCall tc = new EntryData.ToolCall("bash");
+ JsonObject content = OpenCodeClientExporter.buildV2ToolContent(tc, 3000L);
+ JsonObject state = content.getAsJsonObject("state");
+ assertEquals("error", state.get("status").getAsString());
+ assertEquals("tool.interrupted", state.getAsJsonObject("error").get("type").getAsString());
+ assertFalse(state.has("content"));
+ assertFalse(content.getAsJsonObject("time").has("completed"));
+ }
+
+ // ── parseToolInput ───────────────────────────────────────────────────────
+
+ @Test
+ void parseToolInput_jsonObject_returnedAsIs() {
+ JsonObject input = OpenCodeClientExporter.parseToolInput("{\"path\":\"a.txt\"}");
+ assertEquals("a.txt", input.get("path").getAsString());
+ }
+
+ @Test
+ void parseToolInput_nonJson_wrappedInRaw() {
+ JsonObject input = OpenCodeClientExporter.parseToolInput("not valid json");
+ assertEquals("not valid json", input.get("raw").getAsString());
+ }
+
+ @Test
+ void parseToolInput_nullAndBlank_emptyObject() {
+ assertEquals(0, OpenCodeClientExporter.parseToolInput(null).size());
+ assertEquals(0, OpenCodeClientExporter.parseToolInput(" ").size());
+ }
+
+ // ── findGitRoot ──────────────────────────────────────────────────────────
+
+ @Test
+ void findGitRoot_walksUpToDotGit() throws Exception {
+ Path root = Files.createTempDirectory("oc-gitroot-test");
+ try {
+ Files.createDirectories(root.resolve(".git"));
+ Path nested = Files.createDirectories(root.resolve("sub").resolve("dir"));
+ assertEquals(root.toAbsolutePath().normalize(), OpenCodeClientExporter.findGitRoot(nested.toString()).toAbsolutePath().normalize());
+ } finally {
+ deleteRecursively(root);
+ }
+ }
+
+ // ── exportSession against real SQLite databases ──────────────────────────
+
+ @Test
+ void exportSession_v2Schema_writesSessionV2AndSessionMessages() throws Exception {
+ Path dir = Files.createTempDirectory("oc-v2-export-test");
+ Path dbPath = dir.resolve("opencode.db");
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath)) {
+ createV2Schema(conn);
+ insertProjectRow(conn, "proj_test", dir.toString());
+ }
+
+ List entries = new ArrayList<>();
+ entries.add(new EntryData.Prompt("first question"));
+ entries.add(new EntryData.Text("first answer"));
+ EntryData.ToolCall tc = new EntryData.ToolCall("read_file", "{\"path\":\"a.txt\"}");
+ tc.setResult("contents");
+ entries.add(tc);
+ entries.add(new EntryData.Text("second answer"));
+
+ String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, dir.toString(), 0);
+
+ assertNotNull(sessionId);
+ assertTrue(sessionId.startsWith("ses_"));
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+ Statement stmt = conn.createStatement()) {
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT project_id, directory, version, title FROM session_v2 WHERE id = '" + sessionId + "'")) {
+ assertTrue(rs.next());
+ assertEquals("proj_test", rs.getString(1));
+ assertEquals(dir.toString().replace('\\', '/'), rs.getString(2));
+ assertEquals("2.0.0", rs.getString(3));
+ assertEquals("AgentBridge Session", rs.getString(4));
+ }
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT type, seq, data FROM session_message WHERE session_id = '" + sessionId + "' ORDER BY seq")) {
+ assertTrue(rs.next());
+ assertEquals("user", rs.getString(1));
+ assertEquals(0, rs.getInt(2));
+ JsonObject userData = JsonParser.parseString(rs.getString(3)).getAsJsonObject();
+ assertEquals("first question", userData.get("text").getAsString());
+ assertTrue(rs.next());
+ assertEquals("assistant", rs.getString(1));
+ assertEquals(1, rs.getInt(2));
+ JsonObject assistantData = JsonParser.parseString(rs.getString(3)).getAsJsonObject();
+ JsonArray content = assistantData.getAsJsonArray("content");
+ assertEquals(3, content.size(), "text, tool, and trailing text share one assistant message");
+ assertEquals("text", content.get(0).getAsJsonObject().get("type").getAsString());
+ assertEquals("first answer", content.get(0).getAsJsonObject().get("text").getAsString());
+ JsonObject toolEntry = content.get(1).getAsJsonObject();
+ assertEquals("tool", toolEntry.get("type").getAsString());
+ assertEquals("a.txt", toolEntry.getAsJsonObject("state").getAsJsonObject("input").get("path").getAsString());
+ assertEquals("text", content.get(2).getAsJsonObject().get("type").getAsString());
+ assertEquals("second answer", content.get(2).getAsJsonObject().get("text").getAsString());
+ assertFalse(rs.next());
+ }
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT seq FROM event_sequence WHERE aggregate_id = '" + sessionId + "'")) {
+ assertTrue(rs.next());
+ assertEquals(1, rs.getInt(1));
+ }
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'message', 'part')")) {
+ assertEquals(0, rs.getInt(1), "legacy tables must not be created against a v2 database");
+ }
+ }
+ }
+
+ @Test
+ void exportSession_v2Schema_pathIsSubdirRelativeToGitRoot() throws Exception {
+ Path root = Files.createTempDirectory("oc-v2-path-test");
+ Path dbPath = root.resolve("opencode.db");
+ Path sub = Files.createDirectories(root.resolve("coreapp"));
+ try {
+ Files.createDirectories(root.resolve(".git"));
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath)) {
+ createV2Schema(conn);
+ insertProjectRow(conn, "proj_git", root.toString());
+ }
+
+ List entries = new ArrayList<>();
+ entries.add(new EntryData.Prompt("q"));
+ entries.add(new EntryData.Text("a"));
+
+ String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, sub.toString(), 0);
+
+ assertNotNull(sessionId);
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+ Statement stmt = conn.createStatement()) {
+ try (ResultSet rs = stmt.executeQuery(
+ "SELECT path, project_id, directory FROM session_v2 WHERE id = '" + sessionId + "'")) {
+ assertTrue(rs.next());
+ assertEquals("coreapp", rs.getString(1), "path must be the subdir relative to the git root, not the up-walk");
+ assertEquals("proj_git", rs.getString(2), "project must resolve to the git root worktree");
+ assertEquals(sub.toString().replace('\\', '/'), rs.getString(3).replace('\\', '/'));
+ }
+ }
+ } finally {
+ deleteRecursively(root);
+ }
+ }
+
+ @Test
+ void exportSession_v2Schema_noProjectRow_fallsBackToGlobal() throws Exception {
+ Path dir = Files.createTempDirectory("oc-v2-global-test");
+ Path dbPath = dir.resolve("opencode.db");
+ try {
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath)) {
+ createV2Schema(conn);
+ }
+
+ List entries = new ArrayList<>();
+ entries.add(new EntryData.Prompt("q"));
+ entries.add(new EntryData.Text("a"));
+
+ String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, dir.toString(), 0);
+
+ assertNotNull(sessionId);
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+ Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT project_id FROM session_v2 WHERE id = '" + sessionId + "'")) {
+ assertTrue(rs.next());
+ assertEquals("global", rs.getString(1), "no matching project row must fall back to the global id");
+ }
+ } finally {
+ deleteRecursively(dir);
+ }
+ }
+
+ @Test
+ void exportSession_legacySchema_stillUsesLegacyTables() throws Exception {
+ Path dir = Files.createTempDirectory("oc-v1-export-test");
+ Path dbPath = dir.resolve("opencode.db");
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+ Statement stmt = conn.createStatement()) {
+ stmt.execute("CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, "
+ + "slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, "
+ + "share_url TEXT, summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, "
+ + "summary_diffs TEXT, revert TEXT, permission TEXT, time_created INTEGER NOT NULL, "
+ + "time_updated INTEGER NOT NULL, time_compacting INTEGER, time_archived INTEGER, workspace_id TEXT)");
+ }
+
+ List entries = new ArrayList<>();
+ entries.add(new EntryData.Prompt("q"));
+ entries.add(new EntryData.Text("a"));
+
+ String sessionId = OpenCodeClientExporter.exportSession(entries, dbPath, dir.toString(), 0);
+
+ assertNotNull(sessionId);
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+ Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT count(*) FROM session WHERE id = '" + sessionId + "'")) {
+ assertEquals(1, rs.getInt(1), "legacy path must keep writing the legacy session table");
+ }
+ }
+
+ @Test
+ void exportSession_uninitializedDatabase_skipsExport() throws Exception {
+ Path dir = Files.createTempDirectory("oc-fresh-export-test");
+ Path dbPath = dir.resolve("opencode.db");
+
+ String sessionId = OpenCodeClientExporter.exportSession(
+ List.of(new EntryData.Prompt("q"), new EntryData.Text("a")), dbPath, dir.toString(), 0);
+
+ assertNull(sessionId);
+ try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+ Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery(
+ "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'session_v2')")) {
+ assertEquals(0, rs.getInt(1), "no schema may be fabricated into an uninitialized database");
+ }
+ }
+
+ private static void createV2Schema(Connection conn) throws Exception {
+ try (Statement stmt = conn.createStatement()) {
+ stmt.execute("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, "
+ + "time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, sandboxes TEXT NOT NULL)");
+ stmt.execute("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, "
+ + "workspace_id TEXT, parent_id TEXT, fork_session_id TEXT, fork_boundary TEXT, slug TEXT NOT NULL, "
+ + "directory TEXT NOT NULL, path TEXT, title TEXT, version TEXT NOT NULL, share_url TEXT, "
+ + "summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, summary_diffs TEXT, "
+ + "metadata TEXT, cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL, "
+ + "tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL, "
+ + "tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL, "
+ + "revert TEXT, permission TEXT, agent TEXT, model TEXT, time_created INTEGER NOT NULL, "
+ + "time_updated INTEGER NOT NULL, time_compacting INTEGER, time_archived INTEGER)");
+ stmt.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, "
+ + "type TEXT NOT NULL, seq INTEGER NOT NULL, time_created INTEGER NOT NULL, "
+ + "time_updated INTEGER NOT NULL, data TEXT NOT NULL)");
+ stmt.execute("CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER NOT NULL, owner_id TEXT)");
+ }
+ }
+
+ private static void insertProjectRow(Connection conn, String id, String worktree) throws Exception {
+ try (java.sql.PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO project (id, worktree, vcs, time_created, time_updated, sandboxes) VALUES (?, ?, NULL, 0, 0, '[]')")) {
+ ps.setString(1, id);
+ ps.setString(2, worktree.replace('\\', '/'));
+ ps.executeUpdate();
+ }
+ }
+
+ private static void deleteRecursively(Path root) {
+ try (var walk = Files.walk(root)) {
+ walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
+ try {
+ Files.delete(p);
+ } catch (Exception ignored) {
+ // best-effort cleanup of a temp directory
+ }
+ });
+ } catch (Exception ignored) {
+ // best-effort cleanup of a temp directory
+ }
+ }
+
// ── Reflection helpers ───────────────────────────────────────────────────
private static String invokeSha1Hex(String input) throws Exception {