From bb94b24824b2588c793822ca5ca70c911b0995a2 Mon Sep 17 00:00:00 2001 From: Yaner Date: Mon, 7 Sep 2026 22:22:10 +0800 Subject: [PATCH 1/4] fix(parser): retain OpenCode v2 result attachments and stop reasons Tool result files were omitted from imported OpenCode v2 conversations. Retain their names or MIME types as attachment labels, and preserve the assistant finish value in parsed messages. Use the existing v2 reader so released beta schemas and upgrade history keep their current behavior. Advance the data version so existing imports can recover missing result labels. --- docs/internal/session-format-sources.md | 13 +++++++++++++ internal/db/db.go | 4 +++- internal/parser/opencode_v2.go | 14 ++++++++++++-- internal/parser/opencode_v2_test.go | 17 +++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/internal/session-format-sources.md b/docs/internal/session-format-sources.md index cd7be3e23e..0d5b23711c 100644 --- a/docs/internal/session-format-sources.md +++ b/docs/internal/session-format-sources.md @@ -886,6 +886,19 @@ add an archived or maintained mirror without replacing the original identity. ## OpenCode (`opencode`) +**Projection detail check (2026-09-12):** Rechecked the pinned +[assistant schema](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/schema/src/session-message.ts), +[tool content schema](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/schema/src/llm.ts), +and +[message updater](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/core/src/session/message-updater.ts). +Step completion stores the assistant's `finish` value, which Agentsview now +retains as the parsed stop reason. Tool results can contain file items with a +URI, MIME type, and optional name. These retain attachment labels in result +text, using the MIME type when no name is present, without expanding the URI. +Isolated parser fixtures verify both mappings. These details extend the +existing v2 reader; session discovery and upgrade behavior are unchanged. +Data version 108 makes existing imports eligible to recover the result labels. + **V2 projection check (2026-09-08):** Cloned upstream at `dff8fbc149fb7492e4f07b713ac31ea70d9a541c` and checked the [SQL schema](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/core/src/session/sql.ts), diff --git a/internal/db/db.go b/internal/db/db.go index cb1c060959..fac15a52fc 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -487,7 +487,9 @@ CREATE INDEX IF NOT EXISTS idx_provider_freshness_updated_at // Subagent tool calls from Other to Task so delegation renders as a task call // and leaves the Other analytics bucket; subagent transcripts themselves are // new sources and need no re-parse.) -const dataVersion = 107 +// (108: OpenCode v2 tool results retain file attachment labels. Existing +// sessions need re-parsing to recover labels omitted from stored results.) +const dataVersion = 108 const tokenCoverageRepairStatsKey = "token_coverage_repair_v1" diff --git a/internal/parser/opencode_v2.go b/internal/parser/opencode_v2.go index 9a3d695e13..c67bb5d49e 100644 --- a/internal/parser/opencode_v2.go +++ b/internal/parser/opencode_v2.go @@ -155,6 +155,7 @@ type openCodeV2Message struct { ShellID string `json:"shellID"` Exit int `json:"exit"` Status string `json:"status"` + Finish string `json:"finish"` Files []struct { Name string `json:"name"` MIME string `json:"mime"` @@ -186,6 +187,8 @@ type openCodeV2Content struct { Content []struct { Type string `json:"type"` Text string `json:"text"` + Name string `json:"name"` + MIME string `json:"mime"` } `json:"content"` Error struct { Message string `json:"message"` @@ -236,7 +239,7 @@ func loadOpenCodeV2Messages(db *sql.DB, sessionID, cwd string) ([]ParsedMessage, pm.Content += attachment } case "assistant": - pm.Role = RoleAssistant + pm.Role, pm.StopReason = RoleAssistant, data.Finish var texts []string for _, item := range data.Content { switch item.Type { @@ -313,8 +316,15 @@ func openCodeV2ToolCall(item openCodeV2Content, cwd string) ParsedToolCall { if item.State.Status == "completed" || item.State.Status == "error" { var texts []string for _, content := range item.State.Content { - if content.Type == "text" { + switch content.Type { + case "text": texts = append(texts, content.Text) + case "file": + name := content.Name + if name == "" { + name = content.MIME + } + texts = append(texts, "[Attachment: "+name+"]") } } // The v2 read tool returns text files as structured UTF-8 attachments. diff --git a/internal/parser/opencode_v2_test.go b/internal/parser/opencode_v2_test.go index e80999c7f7..ac1ccd7082 100644 --- a/internal/parser/opencode_v2_test.go +++ b/internal/parser/opencode_v2_test.go @@ -256,6 +256,7 @@ func TestOpenCodeV2MixedDatabase(t *testing.T) { func TestOpenCodeV2ToolStates(t *testing.T) { for _, tc := range []struct{ name, tool, state, status, text string }{ {"structured", "custom", `{"status":"completed","input":{},"structured":{"content":[1,2],"exit":"other"},"content":[{"type":"text","text":"Done"}]}`, "completed", "Done"}, + {"file output", "custom", `{"status":"completed","input":{},"structured":{},"content":[{"type":"text","text":"Created plot"},{"type":"file","uri":"data:image/png;base64,AAAA","mime":"image/png","name":"plot.png"},{"type":"file","uri":"data:application/pdf;base64,AAAA","mime":"application/pdf"}]}`, "completed", "Created plot\n[Attachment: plot.png]\n[Attachment: application/pdf]"}, {"pending", "bash", `{"status":"pending","input":"{\"command\":"}`, "", ""}, {"streaming", "shell", `{"status":"streaming","input":"{\"command\":"}`, "", ""}, {"running", "bash", `{"status":"running","input":{"command":"query"},"structured":{},"content":[]}`, "", ""}, @@ -345,6 +346,22 @@ func TestOpenCodeV2MessageKinds(t *testing.T) { } } +func TestOpenCodeV2StopReason(t *testing.T) { + path, seed, writer := newTestDB(t) + seed.AddProject("project-a", "/workspace/project-a") + seed.AddSession("ses_a", "project-a", "", "", 1700000000000, 1700000001000) + _, err := writer.Exec(openCodeV2TestSchema) + require.NoError(t, err) + _, err = writer.Exec(`INSERT INTO session_message VALUES ('msg_a', 'ses_a', 'assistant', 1, 1700000000000, 1700000001000, + '{"finish":"length","content":[{"type":"text","id":"txt_a","text":"Partial reply"}]}')`) + require.NoError(t, err) + _, msgs, err := parseOpenCodeDBSession(path, "ses_a", "host-a") + require.NoError(t, err) + require.Len(t, msgs, 1) + assert.Equal(t, "Partial reply", msgs[0].Content) + assert.Equal(t, "length", msgs[0].StopReason) +} + func TestOpenCodeV2CrossPathHistory(t *testing.T) { // Reproduced with 1.18.25: the v2 API accepts an existing CLI session // and appends projections without converting its older message/part rows. From 8a9ff30731862c599c3d5b7265a79a87bfa4503f Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sat, 12 Sep 2026 15:11:14 -0400 Subject: [PATCH 2/4] fix(parser): remove unused OpenCode stop-reason extraction OpenCode does not persist parsed stop reasons or consume them for session termination status. Remove the unused extraction and its parser-only test so the change covers only tool-result attachment labels. --- docs/internal/session-format-sources.md | 14 ++++++-------- internal/parser/opencode_v2.go | 3 +-- internal/parser/opencode_v2_test.go | 16 ---------------- 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/docs/internal/session-format-sources.md b/docs/internal/session-format-sources.md index 0d5b23711c..a3fd25046b 100644 --- a/docs/internal/session-format-sources.md +++ b/docs/internal/session-format-sources.md @@ -887,17 +887,15 @@ add an archived or maintained mirror without replacing the original identity. ## OpenCode (`opencode`) **Projection detail check (2026-09-12):** Rechecked the pinned -[assistant schema](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/schema/src/session-message.ts), [tool content schema](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/schema/src/llm.ts), and [message updater](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/core/src/session/message-updater.ts). -Step completion stores the assistant's `finish` value, which Agentsview now -retains as the parsed stop reason. Tool results can contain file items with a -URI, MIME type, and optional name. These retain attachment labels in result -text, using the MIME type when no name is present, without expanding the URI. -Isolated parser fixtures verify both mappings. These details extend the -existing v2 reader; session discovery and upgrade behavior are unchanged. -Data version 108 makes existing imports eligible to recover the result labels. +Tool results can contain file items with a URI, MIME type, and optional name. +These retain attachment labels in result text, using the MIME type when no name +is present, without expanding the URI. Isolated parser fixtures verify named and +unnamed file results. This extends the existing v2 reader; session discovery and +upgrade behavior are unchanged. Data version 108 makes existing imports eligible +to recover the result labels. **V2 projection check (2026-09-08):** Cloned upstream at `dff8fbc149fb7492e4f07b713ac31ea70d9a541c` and checked the diff --git a/internal/parser/opencode_v2.go b/internal/parser/opencode_v2.go index c67bb5d49e..5188661841 100644 --- a/internal/parser/opencode_v2.go +++ b/internal/parser/opencode_v2.go @@ -155,7 +155,6 @@ type openCodeV2Message struct { ShellID string `json:"shellID"` Exit int `json:"exit"` Status string `json:"status"` - Finish string `json:"finish"` Files []struct { Name string `json:"name"` MIME string `json:"mime"` @@ -239,7 +238,7 @@ func loadOpenCodeV2Messages(db *sql.DB, sessionID, cwd string) ([]ParsedMessage, pm.Content += attachment } case "assistant": - pm.Role, pm.StopReason = RoleAssistant, data.Finish + pm.Role = RoleAssistant var texts []string for _, item := range data.Content { switch item.Type { diff --git a/internal/parser/opencode_v2_test.go b/internal/parser/opencode_v2_test.go index ac1ccd7082..076e682100 100644 --- a/internal/parser/opencode_v2_test.go +++ b/internal/parser/opencode_v2_test.go @@ -346,22 +346,6 @@ func TestOpenCodeV2MessageKinds(t *testing.T) { } } -func TestOpenCodeV2StopReason(t *testing.T) { - path, seed, writer := newTestDB(t) - seed.AddProject("project-a", "/workspace/project-a") - seed.AddSession("ses_a", "project-a", "", "", 1700000000000, 1700000001000) - _, err := writer.Exec(openCodeV2TestSchema) - require.NoError(t, err) - _, err = writer.Exec(`INSERT INTO session_message VALUES ('msg_a', 'ses_a', 'assistant', 1, 1700000000000, 1700000001000, - '{"finish":"length","content":[{"type":"text","id":"txt_a","text":"Partial reply"}]}')`) - require.NoError(t, err) - _, msgs, err := parseOpenCodeDBSession(path, "ses_a", "host-a") - require.NoError(t, err) - require.Len(t, msgs, 1) - assert.Equal(t, "Partial reply", msgs[0].Content) - assert.Equal(t, "length", msgs[0].StopReason) -} - func TestOpenCodeV2CrossPathHistory(t *testing.T) { // Reproduced with 1.18.25: the v2 API accepts an existing CLI session // and appends projections without converting its older message/part rows. From b3e3871de919f60915f6636560a54017a80d1864 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sat, 12 Sep 2026 15:47:19 -0400 Subject: [PATCH 3/4] fix(parser): preserve OpenCode tool-result file payloads Retain the embedded files returned by OpenCode tools instead of reducing results to attachment labels. Store image results in the shared format so the configured image policy controls retention. Keep PDFs, other file payloads, and external references in the archived result JSON. Preserve surrounding text and failed-tool output in order, while leaving text-only results in their existing format. The unshipped data-version bump now recovers file payloads for existing imports. --- docs/internal/session-format-sources.md | 27 +++-- internal/db/db.go | 4 +- internal/parser/opencode_v2.go | 57 ++++++++-- internal/parser/opencode_v2_test.go | 58 +++++++++- .../testdata/opencode_v2/tool_files.json | 77 +++++++++++++ internal/sync/opencode_tool_files_test.go | 106 ++++++++++++++++++ 6 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 internal/parser/testdata/opencode_v2/tool_files.json create mode 100644 internal/sync/opencode_tool_files_test.go diff --git a/docs/internal/session-format-sources.md b/docs/internal/session-format-sources.md index a3fd25046b..94d08b6414 100644 --- a/docs/internal/session-format-sources.md +++ b/docs/internal/session-format-sources.md @@ -887,15 +887,26 @@ add an archived or maintained mirror without replacing the original identity. ## OpenCode (`opencode`) **Projection detail check (2026-09-12):** Rechecked the pinned -[tool content schema](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/schema/src/llm.ts), +[beta read tool](https://github.com/anomalyco/opencode/blob/d461154a8d2b24c4ad24a89b589069cf08ab168c/packages/core/src/tool/plugin/read.ts#L169), +[tool content schema](https://github.com/anomalyco/opencode/blob/d461154a8d2b24c4ad24a89b589069cf08ab168c/packages/schema/src/tool.ts#L73), and -[message updater](https://github.com/anomalyco/opencode/blob/dff8fbc149fb7492e4f07b713ac31ea70d9a541c/packages/core/src/session/message-updater.ts). -Tool results can contain file items with a URI, MIME type, and optional name. -These retain attachment labels in result text, using the MIME type when no name -is present, without expanding the URI. Isolated parser fixtures verify named and -unnamed file results. This extends the existing v2 reader; session discovery and -upgrade behavior are unchanged. Data version 108 makes existing imports eligible -to recover the result labels. +[message updater](https://github.com/anomalyco/opencode/blob/d461154a8d2b24c4ad24a89b589069cf08ab168c/packages/core/src/session/message-updater.ts#L322). +The read tool puts image and PDF bytes in base64 data URIs, alongside their MIME +type and filename. Tool success copies that content into the projection; tool +errors may also retain content. Agentsview stores file-bearing results as +ordered JSON blocks. Inline images use `input_image`/`image_url` so the existing +image keep/drop policy owns their only payload copy. PDFs and other files keep +their `file` records, including the full URI, MIME type, and optional name, in +raw result JSON. Remote and filesystem URIs remain references and are not +fetched. Text-only results retain their existing plain-text format. + +`testdata/opencode_v2/tool_files.json` is a synthetic fixture shaped from these +producer sources, with a valid one-pixel PNG and a one-page PDF; it is not a +captured CLI conversation. Parser tests cover successful and failed results. +Normal sync tests use the captured beta database schema and check archived +payloads, image keep/drop behavior, unchanged PDF/text files and references, and +an unchanged second sync. Data version 108 makes existing imports eligible to +recover omitted file payloads. This adds retention, not a PDF previewer. **V2 projection check (2026-09-08):** Cloned upstream at `dff8fbc149fb7492e4f07b713ac31ea70d9a541c` and checked the diff --git a/internal/db/db.go b/internal/db/db.go index fac15a52fc..4819d0d71c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -487,8 +487,8 @@ CREATE INDEX IF NOT EXISTS idx_provider_freshness_updated_at // Subagent tool calls from Other to Task so delegation renders as a task call // and leaves the Other analytics bucket; subagent transcripts themselves are // new sources and need no re-parse.) -// (108: OpenCode v2 tool results retain file attachment labels. Existing -// sessions need re-parsing to recover labels omitted from stored results.) +// (108: OpenCode v2 tool results retain embedded file payloads. Existing +// sessions need re-parsing to recover files omitted from stored results.) const dataVersion = 108 const tokenCoverageRepairStatsKey = "token_coverage_repair_v1" diff --git a/internal/parser/opencode_v2.go b/internal/parser/opencode_v2.go index 5188661841..0a651c42b1 100644 --- a/internal/parser/opencode_v2.go +++ b/internal/parser/opencode_v2.go @@ -188,6 +188,7 @@ type openCodeV2Content struct { Text string `json:"text"` Name string `json:"name"` MIME string `json:"mime"` + URI string `json:"uri"` } `json:"content"` Error struct { Message string `json:"message"` @@ -253,7 +254,11 @@ func loadOpenCodeV2Messages(db *sql.DB, sessionID, cwd string) ([]ParsedMessage, } case "tool": pm.HasToolUse = true - pm.ToolCalls = append(pm.ToolCalls, openCodeV2ToolCall(item, cwd)) + call, err := openCodeV2ToolCall(item, cwd) + if err != nil { + return nil, true, "", fmt.Errorf("decoding opencode v2 tool %s: %w", item.ID, err) + } + pm.ToolCalls = append(pm.ToolCalls, call) } } pm.Content = strings.Join(texts, "\n") @@ -299,7 +304,7 @@ func loadOpenCodeV2Messages(db *sql.DB, sessionID, cwd string) ([]ParsedMessage, return parsed, present, fmt.Sprintf("opencode-v2:%x", hash.Sum(nil)), rows.Err() } -func openCodeV2ToolCall(item openCodeV2Content, cwd string) ParsedToolCall { +func openCodeV2ToolCall(item openCodeV2Content, cwd string) (ParsedToolCall, error) { call := ParsedToolCall{ ToolUseID: item.ID, ToolName: item.Name, Category: NormalizeToolCategory(item.Name), InputJSON: string(item.State.Input), @@ -314,23 +319,45 @@ func openCodeV2ToolCall(item openCodeV2Content, cwd string) ParsedToolCall { } if item.State.Status == "completed" || item.State.Status == "error" { var texts []string + var blocks []map[string]string + hasFiles := false + for _, content := range item.State.Content { + if content.Type == "file" { + hasFiles = true + break + } + } + appendText := func(text string) { + if hasFiles { + blocks = append(blocks, map[string]string{"type": "text", "text": text}) + } else { + texts = append(texts, text) + } + } for _, content := range item.State.Content { switch content.Type { case "text": - texts = append(texts, content.Text) + appendText(content.Text) case "file": - name := content.Name - if name == "" { - name = content.MIME + block := map[string]string{"type": "file", "uri": content.URI, "mime": content.MIME} + if content.Name != "" { + block["name"] = content.Name + } + // Use the shared image representation so storage's keep/drop + // policy owns the payload. Other files retain the producer URI, + // including inline PDFs; external references are never fetched. + if strings.HasPrefix(strings.ToLower(content.URI), "data:image/") { + block["type"], block["image_url"] = "input_image", content.URI + delete(block, "uri") } - texts = append(texts, "[Attachment: "+name+"]") + blocks = append(blocks, block) } } // The v2 read tool returns text files as structured UTF-8 attachments. structured := string(item.State.Structured) if item.Name == "read" && gjson.Get(structured, "encoding").Str == "utf8" { if content := gjson.Get(structured, "content").Str; content != "" { - texts = append(texts, content) + appendText(content) } } status := "completed" @@ -339,13 +366,21 @@ func openCodeV2ToolCall(item openCodeV2Content, cwd string) ParsedToolCall { if item.State.Status == "error" || shellFailed { status = "errored" if item.State.Error.Message != "" { - texts = append(texts, item.State.Error.Message) + appendText(item.State.Error.Message) + } + } + content := strings.Join(texts, "\n") + if hasFiles { + encoded, err := json.Marshal(blocks, json.Deterministic(true)) + if err != nil { + return call, err } + content = string(encoded) } call.ResultEvents = []ParsedToolResultEvent{{ - ToolUseID: item.ID, Status: status, Content: strings.Join(texts, "\n"), + ToolUseID: item.ID, Status: status, Content: content, Timestamp: millisToTime(item.Time.Completed), }} } - return call + return call, nil } diff --git a/internal/parser/opencode_v2_test.go b/internal/parser/opencode_v2_test.go index 076e682100..59805cee8b 100644 --- a/internal/parser/opencode_v2_test.go +++ b/internal/parser/opencode_v2_test.go @@ -256,7 +256,6 @@ func TestOpenCodeV2MixedDatabase(t *testing.T) { func TestOpenCodeV2ToolStates(t *testing.T) { for _, tc := range []struct{ name, tool, state, status, text string }{ {"structured", "custom", `{"status":"completed","input":{},"structured":{"content":[1,2],"exit":"other"},"content":[{"type":"text","text":"Done"}]}`, "completed", "Done"}, - {"file output", "custom", `{"status":"completed","input":{},"structured":{},"content":[{"type":"text","text":"Created plot"},{"type":"file","uri":"data:image/png;base64,AAAA","mime":"image/png","name":"plot.png"},{"type":"file","uri":"data:application/pdf;base64,AAAA","mime":"application/pdf"}]}`, "completed", "Created plot\n[Attachment: plot.png]\n[Attachment: application/pdf]"}, {"pending", "bash", `{"status":"pending","input":"{\"command\":"}`, "", ""}, {"streaming", "shell", `{"status":"streaming","input":"{\"command\":"}`, "", ""}, {"running", "bash", `{"status":"running","input":{"command":"query"},"structured":{},"content":[]}`, "", ""}, @@ -492,3 +491,60 @@ func TestOpenCodeV2CapturedAttachmentCompaction(t *testing.T) { assert.True(t, msgs[3].IsCompactBoundary) assert.Contains(t, msgs[3].Content, "The user shared a file `input.txt` containing three lines") } + +func TestOpenCodeV2ToolFiles(t *testing.T) { + // The file records follow the beta read tool's toModelContent output. + raw, err := os.ReadFile("testdata/opencode_v2/tool_files.json") + require.NoError(t, err) + var source struct { + Content []struct { + State struct { + Content []map[string]string `json:"content"` + } `json:"state"` + } `json:"content"` + } + require.NoError(t, json.Unmarshal(raw, &source)) + for _, status := range []string{"completed", "error"} { + t.Run(status, func(t *testing.T) { + path, seed, writer := newTestDB(t) + seed.AddProject("project-a", "/workspace/project-a") + seed.AddSession("ses_files", "project-a", "", "Files", 1700000000000, 1700000002000) + _, err := writer.Exec(openCodeV2TestSchema) + require.NoError(t, err) + data := string(raw) + if status == "error" { + data = strings.ReplaceAll(data, `"status": "completed"`, `"status": "error", "error": {"message": "Read failed"}`) + } + _, err = writer.Exec(`INSERT INTO session_message VALUES ('msg_files', 'ses_files', 'assistant', 1, 1700000000000, 1700000002000, ?)`, data) + require.NoError(t, err) + _, messages, err := parseOpenCodeDBSession(path, "ses_files", "host-a") + require.NoError(t, err) + require.Len(t, messages, 1) + require.Len(t, messages[0].ToolCalls, 2) + for i, call := range messages[0].ToolCalls { + require.Len(t, call.ResultEvents, 1) + var blocks []map[string]string + require.NoError(t, json.Unmarshal([]byte(call.ResultEvents[0].Content), &blocks)) + want := source.Content[i].State.Content + if status == "error" { + require.Len(t, blocks, len(want)+1) + assert.Equal(t, map[string]string{"type": "text", "text": "Read failed"}, blocks[len(want)]) + assert.Equal(t, "errored", call.ResultEvents[0].Status) + } else { + require.Len(t, blocks, len(want)) + assert.Equal(t, "completed", call.ResultEvents[0].Status) + } + if i == 0 { + assert.Equal(t, want[0], blocks[0]) + assert.Equal(t, "input_image", blocks[1]["type"]) + assert.Equal(t, want[1]["uri"], blocks[1]["image_url"]) + assert.Equal(t, "plot.png", blocks[1]["name"]) + assert.NotContains(t, blocks[1], "uri", "image policy must own the only payload copy") + assert.Equal(t, want[2], blocks[2]) + } else { + assert.Equal(t, want, blocks[:len(want)], "PDF, text payload, and external reference survive unchanged") + } + } + }) + } +} diff --git a/internal/parser/testdata/opencode_v2/tool_files.json b/internal/parser/testdata/opencode_v2/tool_files.json new file mode 100644 index 0000000000..e997e3a031 --- /dev/null +++ b/internal/parser/testdata/opencode_v2/tool_files.json @@ -0,0 +1,77 @@ +{ + "model": { + "id": "example-model", + "providerID": "example" + }, + "content": [ + { + "type": "tool", + "id": "call_image", + "name": "read", + "state": { + "status": "completed", + "input": { + "path": "plot.png" + }, + "content": [ + { + "type": "text", + "text": "Image read successfully" + }, + { + "type": "file", + "uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "mime": "image/png", + "name": "plot.png" + }, + { + "type": "text", + "text": "After the image" + } + ] + }, + "time": { + "created": 1700000000000, + "completed": 1700000001000 + } + }, + { + "type": "tool", + "id": "call_files", + "name": "read", + "state": { + "status": "completed", + "input": { + "path": "report.pdf" + }, + "content": [ + { + "type": "text", + "text": "PDF read successfully" + }, + { + "type": "file", + "uri": "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA3MiA3Ml0gPj4KZW5kb2JqCnhyZWYKMCA0CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA0IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgoxODQKJSVFT0YK", + "mime": "application/pdf", + "name": "report.pdf" + }, + { + "type": "file", + "uri": "data:text/plain;base64,YWxwaGEK", + "mime": "text/plain" + }, + { + "type": "file", + "uri": "https://example.com/archive.zip", + "mime": "application/zip", + "name": "archive.zip" + } + ] + }, + "time": { + "created": 1700000001000, + "completed": 1700000002000 + } + } + ] +} diff --git a/internal/sync/opencode_tool_files_test.go b/internal/sync/opencode_tool_files_test.go new file mode 100644 index 0000000000..e807c54c80 --- /dev/null +++ b/internal/sync/opencode_tool_files_test.go @@ -0,0 +1,106 @@ +package sync_test + +import ( + "database/sql" + "encoding/base64" + "encoding/json/v2" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/parser" + syncengine "go.kenn.io/agentsview/internal/sync" +) + +func TestOpenCodeV2ToolFilesSurviveSync(t *testing.T) { + schema, err := os.ReadFile("../parser/testdata/opencode_v2/beta.sql") + require.NoError(t, err) + raw, err := os.ReadFile("../parser/testdata/opencode_v2/tool_files.json") + require.NoError(t, err) + var source struct { + Content []struct { + State struct { + Content []map[string]string `json:"content"` + } `json:"state"` + } `json:"content"` + } + require.NoError(t, json.Unmarshal(raw, &source)) + imageURI := source.Content[0].State.Content[1]["uri"] + + for _, policy := range []config.ToolResultImages{config.ToolResultImagesKeep, config.ToolResultImagesDrop} { + t.Run(string(policy), func(t *testing.T) { + root := t.TempDir() + writer, err := sql.Open("sqlite3", filepath.Join(root, "opencode.db")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, writer.Close()) }) + _, err = writer.Exec(string(schema)) + require.NoError(t, err) + _, err = writer.Exec(`INSERT INTO session_v2 + (id, project_id, slug, directory, version, time_created, time_updated, time_idle) + SELECT 'ses_files', id, 'tool-files', '/workspace/project-a', '0.0.0-beta-19381', + 1700000000000, 1700000002000, 1700000002000 FROM project LIMIT 1`) + require.NoError(t, err) + _, err = writer.Exec(`INSERT INTO session_message VALUES + ('msg_files', 'ses_files', 'assistant', 1, 1700000000000, 1700000002000, ?)`, string(raw)) + require.NoError(t, err) + + database := dbtest.OpenTestDB(t) + database.SetToolResultImages(policy) + engine := syncengine.NewEngine(database, syncengine.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentOpenCode: {root}}, + Machine: "local", Ephemeral: true, ToolResultImages: policy, + DisableFilesystemProjectDiscovery: true, + }) + t.Cleanup(engine.Close) + stats := engine.SyncAll(t.Context(), nil) + require.False(t, stats.Aborted) + require.Equal(t, 4, stats.Synced) + messages, err := database.GetAllMessages(t.Context(), "opencode:ses_files") + require.NoError(t, err) + require.Len(t, messages, 1) + require.Len(t, messages[0].ToolCalls, 2) + for i, call := range messages[0].ToolCalls { + require.Len(t, call.ResultEvents, 1) + var stored string + require.NoError(t, database.Reader().QueryRowContext(t.Context(), + `SELECT content FROM tool_result_events WHERE session_id = ? AND tool_use_id = ?`, + "opencode:ses_files", call.ToolUseID).Scan(&stored)) + assert.Equal(t, stored, call.ResultEvents[0].Content) + assert.Equal(t, stored, call.ResultContent, "summary reads the same retained result") + var blocks []map[string]any + require.NoError(t, json.Unmarshal([]byte(stored), &blocks)) + if i == 0 { + require.Len(t, blocks, 3) + assert.Equal(t, "Image read successfully", blocks[0]["text"]) + assert.Equal(t, "After the image", blocks[2]["text"]) + if policy == config.ToolResultImagesKeep { + assert.Equal(t, "input_image", blocks[1]["type"]) + assert.Equal(t, imageURI, blocks[1]["image_url"]) + } else { + assert.Equal(t, "agentsview_image", blocks[1]["type"]) + assert.Equal(t, float64(68), blocks[1]["byte_size"]) + assert.Equal(t, "plot.png", blocks[1]["name"]) + assert.NotContains(t, stored, imageURI) + } + } else { + var files []map[string]string + require.NoError(t, json.Unmarshal([]byte(stored), &files)) + assert.Equal(t, source.Content[1].State.Content, files, "image policy must retain PDFs and other file records") + pdf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(files[1]["uri"], "data:application/pdf;base64,")) + require.NoError(t, err) + assert.Len(t, pdf, 327) + assert.True(t, strings.HasPrefix(string(pdf), "%PDF-1.4\n")) + text, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(files[2]["uri"], "data:text/plain;base64,")) + require.NoError(t, err) + assert.Equal(t, "alpha\n", string(text)) + } + } + assert.Zero(t, engine.SyncAll(t.Context(), nil).Synced, "unchanged payloads do not cause resync churn") + }) + } +} From d5c14bc1714f99675cf8569c28d455499b384a26 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sat, 12 Sep 2026 15:56:32 -0400 Subject: [PATCH 4/4] perf(parser): avoid copying OpenCode file payloads for image detection Compare only the data URI prefix when classifying tool-result images. Lowercasing the entire URI allocated a payload-sized string even though only its first eleven characters affect classification. --- internal/parser/opencode_v2.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/parser/opencode_v2.go b/internal/parser/opencode_v2.go index 0a651c42b1..b3deb30f70 100644 --- a/internal/parser/opencode_v2.go +++ b/internal/parser/opencode_v2.go @@ -346,7 +346,8 @@ func openCodeV2ToolCall(item openCodeV2Content, cwd string) (ParsedToolCall, err // Use the shared image representation so storage's keep/drop // policy owns the payload. Other files retain the producer URI, // including inline PDFs; external references are never fetched. - if strings.HasPrefix(strings.ToLower(content.URI), "data:image/") { + const imagePrefix = "data:image/" + if len(content.URI) >= len(imagePrefix) && strings.EqualFold(content.URI[:len(imagePrefix)], imagePrefix) { block["type"], block["image_url"] = "input_image", content.URI delete(block, "uri") }