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