diff --git a/docs/internal/session-format-sources.md b/docs/internal/session-format-sources.md index cd7be3e23e..94d08b6414 100644 --- a/docs/internal/session-format-sources.md +++ b/docs/internal/session-format-sources.md @@ -886,6 +886,28 @@ add an archived or maintained mirror without replacing the original identity. ## OpenCode (`opencode`) +**Projection detail check (2026-09-12):** Rechecked the pinned +[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/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 [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..4819d0d71c 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 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 9a3d695e13..b3deb30f70 100644 --- a/internal/parser/opencode_v2.go +++ b/internal/parser/opencode_v2.go @@ -186,6 +186,9 @@ type openCodeV2Content struct { Content []struct { Type string `json:"type"` Text string `json:"text"` + Name string `json:"name"` + MIME string `json:"mime"` + URI string `json:"uri"` } `json:"content"` Error struct { Message string `json:"message"` @@ -251,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") @@ -297,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), @@ -312,16 +319,46 @@ 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 == "text" { - texts = append(texts, content.Text) + 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": + appendText(content.Text) + case "file": + 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. + 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") + } + 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" @@ -330,13 +367,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 e80999c7f7..59805cee8b 100644 --- a/internal/parser/opencode_v2_test.go +++ b/internal/parser/opencode_v2_test.go @@ -491,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") + }) + } +}