From d0dc92deeb69bd2f60294228b11ff331f2e59cfc Mon Sep 17 00:00:00 2001 From: BambinoSK Date: Wed, 21 Oct 2026 12:21:08 +0200 Subject: [PATCH] fix(opencode): write session export into the v2 storage schema OpenCode 2.x reads sessions from session_v2 and session_message, not the legacy session, message, and part tables. The legacy-shaped writes were invisible to session/resume and broke the v1 to v2 migration on every start, because that migration selects a path column the exporter's hardcoded 1.2.x schema never created. The exporter now detects the schema generation before writing. Against session_v2 it writes one session row, session_message rows with parts embedded in a content array, and the event_sequence watermark, mirroring the shapes OpenCode's own migration produces. The whole export runs in a single transaction so a failure cannot leave a partial session row, the directory column is normalized to forward slashes to match the form v2 compares against on lookup, and path is stored as the session directory relative to the git root. The project is resolved from the git root worktree with the global fallback instead of fabricating project rows, and an uninitialized database skips the raw export entirely rather than creating tables OpenCode does not expect. --- .../exporters/OpenCodeClientExporter.java | 436 +++++++++++++++++- .../exporters/OpenCodeClientExporterTest.java | 339 ++++++++++++++ 2 files changed, 751 insertions(+), 24 deletions(-) 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):

+ * + * + *

OpenCode 1.2.x (legacy {@code session} table present):

* + * + *

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/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 {