From 5b31b4d3d69942212927fa914dc939550c4977a2 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 11:29:23 -0400 Subject: [PATCH 01/16] fix(sync): offload tool-result images at ingest --- .../agentsview-PR-TARGET-1718-3-PROOF.md | 39 ++ cmd/agentsview/main.go | 1 + cmd/agentsview/sync_worker.go | 2 + docs/commands.md | 4 + docs/configuration.md | 6 +- docs/data.md | 9 + docs/session-api.md | 4 + frontend/e2e/settings-archive-content.spec.ts | 18 +- frontend/messages/en.json | 1 + frontend/messages/fr.json | 1 + frontend/messages/ja.json | 1 + frontend/messages/ko.json | 1 + frontend/messages/zh-CN.json | 1 + frontend/messages/zh-TW.json | 1 + .../settingsResponseToolResultImages.ts | 1 + .../settingsUpdateRequestToolResultImages.ts | 1 + .../settings/ArchiveContentSettings.svelte | 3 +- .../settings/ArchiveContentSettings.test.ts | 17 +- frontend/src/lib/stores/settings.svelte.ts | 12 +- internal/config/config_test.go | 9 +- internal/config/tool_result_images.go | 13 +- internal/db/db.go | 1 + internal/db/messages.go | 57 ++- internal/db/session_batch.go | 2 +- internal/db/strip_images.go | 24 + internal/db/tool_result_images.go | 80 ++-- internal/db/tool_result_images_test.go | 253 +++++++++++ internal/server/export.go | 15 +- internal/server/huma_routes_settings.go | 5 +- internal/server/server_test.go | 25 +- internal/server/settings.go | 8 +- internal/sync/codex_staging.go | 9 +- internal/sync/engine.go | 20 +- internal/sync/tool_result_images_test.go | 413 +++++++++++------- 34 files changed, 790 insertions(+), 267 deletions(-) create mode 100644 .claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md diff --git a/.claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md b/.claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md new file mode 100644 index 0000000000..716cec4bc4 --- /dev/null +++ b/.claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md @@ -0,0 +1,39 @@ +# Offload ingestion proof + +Base is 6bef76f40bbf3cfb195c5e2c57629280a9dd841b. Head is the staged worktree. Go commands ran with CGO_ENABLED=1. Commands run from the repository root unless they are frontend npm commands, which run from frontend. No commit, push, PR, or comment was made. + +| Surface | Command run | Observed result | Base/head | +| --- | --- | --- | --- | +| reproduction, full ingest | `go test -overlay D:/Repos/.claude/pr-sweep/offload-base/overlay.json -tags "fts5,kit_posthog_disabled" ./internal/sync -run TestToolResultImagesOffloadFullIngest -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Base failed at the stored reference assertion, `--- FAIL: TestToolResultImagesOffloadFullIngest`. Head passed the full-ingest PNG and asset assertions in `go.kenn.io/agentsview/internal/sync` with `0.976s`. | Base: inline image remained on 6bef76f40. Head: stored reference and one 68-byte PNG asset. | +| dispatcher | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, `go.kenn.io/agentsview/internal/db` and `4.267s`. Tests cover keep, drop, offload, unknown policy, malformed and unsupported images, missing destination, and failed asset writes. | Head | +| DB routes | `go test -tags "fts5,kit_posthog_disabled" ./internal/config ./internal/db ./internal/sync -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Full DB package completed in `189.414s`. Final focused DB tests completed in `4.267s`, including insert, incremental, replacement, content replacement, batch, atomic batch, late results, and linked results. | Head | +| staged and resync routes | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, `go.kenn.io/agentsview/internal/sync` and `0.976s`. Staged events, copied orphan/trash sessions, read-only replacements, and fail-open stored-row rewrites passed. | Head | +| preservation and negative space | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, `go.kenn.io/agentsview/internal/db` and `4.267s`. Tests cover blocked lengths, omission, raw replay identity, keep/drop, malformed data, unsupported media, and inline fallback. | Head | +| contract-isolation | `rg -n 'ToolResultImagesDrop\|ToolResultImagesOffload\|ProjectToolResultImageContent\|ProjectToolResultImages' internal/db internal/sync -g '*.go' -g '!**/*_test.go'` | Audit found `db.ProjectToolResultImagesWithPolicy(msgs, policy)` and `ProjectToolResultImageContent`. Graph and staged writes use the shared dispatcher. The drop-only comparison in sole-event dedup preserves existing drop behavior; offload compares actual stored event bytes. | Head | +| settings and frontend | `go test -tags "fts5,kit_posthog_disabled" ./internal/server -run 'TestSettingsToolResultImagesRoundTrip\|TestNormalizedSessionExportsPreserveOffloadedImages' -count=1`; `npm test -- --run src/lib/components/settings/ArchiveContentSettings.test.ts`; `npm --prefix frontend run check`; `npm run i18n:compile` | Server exit 0 in `3.460s`. Component suite reported `Tests 6 passed (6)`. Type check reported `svelte-check found 0 errors and 0 warnings`. Locale command exited 0 and printed `--emit-ts-declarations --silent`. | Head | +| transport fidelity | `go test -tags "fts5,kit_posthog_disabled" ./internal/server -run 'TestSettingsToolResultImagesRoundTrip\|TestNormalizedSessionExportsPreserveOffloadedImages' -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./cmd/agentsview -run TestSessionExport -count=1` | Normalized export tests completed in `3.460s`; raw source export tests completed in `3.195s`. Both server endpoints retain placeholder/reference text. Existing backend evidence and its limits appear below. | Head | +| acceptance-1, durable offload | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, DB `4.267s`. TestToolResultImagesOffloadPublishesBeforeInsert uses a SQLite BEFORE INSERT trigger that rejects a reference unless the expected asset bytes already exist. | Head | +| acceptance-2, route coverage | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, DB `4.267s`, sync `0.976s`. Tests cover full, bulk, incremental, late, linked, staged, and copied-session routes. | Head | +| acceptance-3, preservation | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, DB `4.267s`. Omitted late results do not offload older retained events. Removing that guard failed with `Should be empty, but was`. Linked summaries retain references when their sole older event remains inline. | Head | +| acceptance-4, transport | `go test -tags "fts5,kit_posthog_disabled" ./internal/server -run 'TestSettingsToolResultImagesRoundTrip\|TestNormalizedSessionExportsPreserveOffloadedImages' -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./cmd/agentsview -run TestSessionExport -count=1` | Normalized server tests completed in `3.460s`; raw CLI export tests completed in `3.195s`. Raw cmd/agentsview/session_export.go remains unchanged. Backend coverage is bounded as recorded below. | Head | +| acceptance-5, settings and docs | `npm test -- --run src/lib/components/settings/ArchiveContentSettings.test.ts`; `npm --prefix frontend run check`; `npm run i18n:compile`; `git diff -- docs` | Component output includes `Tests 6 passed (6)`; type check reported `svelte-check found 0 errors and 0 warnings`. Documentation describes `tool_result_images = "offload"`, retry, backup, asset publication, remote limits, and raw source exports. | Head | + +## Bounded validation + +- `go test -tags "fts5,kit_posthog_disabled" ./internal/config ./internal/db ./internal/sync -count=1`: config passed in `20.295s`, DB passed in `189.414s`. Sync reached `panic: test timed out after 10m0s` while running TestScheduledOmnigentReconciliationIsBoundedByChangedMembers. It also reported `Condition never satisfied` in TestBulkCollectorReleasesFlushedParsedBatch/staged_false. These are limits of this package run, not a full-suite success. +- `go test -tags "fts5,kit_posthog_disabled" ./internal/sync -run 'TestBulkCollectorReleasesFlushedParsedBatch\|TestScheduledOmnigentReconciliationIsBoundedByChangedMembers' -count=1 -timeout=120s`: focused follow-up passed in `17.404s`. The base overlay run of TestBulkCollectorReleasesFlushedParsedBatch also failed with `flushed parsed batch remained live while the collector awaited more results`, establishing that the intermittent retention failure predates this implementation. +- `go test -tags "fts5,kit_posthog_disabled" ./internal/config -run TestToolResultImagesConfig -count=1`: passed in `0.227s`. The earlier worker recorded `Access is denied.` in unrelated Windows config file replacement tests. This turn's full config package completed successfully. +- `go test -tags "fts5,kit_posthog_disabled" ./internal/artifact -run 'TestCanonicalMessageSegmentGolden\|TestArtifactSyncTwoNodeFolderRoundTripAndReplay' -count=1`: passed in `75.289s`. `go test -tags "fts5,kit_posthog_disabled" ./internal/artifact -run TestCanonicalMessageSegmentGolden -count=1 -timeout=60s` passed in `0.123s`. These existing tests cover stored message/result serialization and folder exchange, not local asset distribution. +- `go test -tags "fts5,kit_posthog_disabled" ./internal/duckdb -run TestPushIncrementalReplacesOnlyChangedSessions -count=1`: passed in `3.147s`. The existing fixture carries tool-result text through mirror push. +- `go test -tags "fts5,kit_posthog_disabled,pgtest" ./internal/postgres -run TestPushWithToolResultEvents -count=1 -v`: `TEST_PG_URL not set; skipping PG tests`. No PostgreSQL or CockroachDB runtime round trip is claimed. Source review found unchanged string transport through bulkInsertToolResultEvents, sanitizePG, result-content scans, and RestoreMessageResultContent. Local asset resolution remains unavailable on those remote backends. +- `go fmt ./...`: exit 0. `go vet ./...` reported `possible misuse of unsafe.Pointer` in three existing internal/rawcapture Windows locations. `go vet ./internal/config ./internal/db ./internal/sync ./internal/server ./cmd/agentsview`: exit 0, no diagnostics. +- `make lint-ci`: failed with `5 issues:` in unchanged internal/capture Windows files, four staticcheck capitalization findings and one unused function. No allowlisted implementation finding appeared. The make target stopped before NilAway. +- `git diff --check`: exit 0, no whitespace errors. + +## Reproduction and cleanup + +The base overlay substitutes HEAD versions of changed production Go files without changing branches or the worktree. It adds the full-ingest regression to the base sync test file and omits only the unavailable EngineConfig.AssetsDir field. The base fails on retained inline content, not compilation. Head also checks the published PNG bytes. A separate omission overlay removes only the new archive-omission guard; its failure proves the no-asset-write regression detects that bug. + +The existing API generator completed through npm check using an external vp.exe launcher and OFFLOAD_VP_CLI pointing to the installed Vite+ entry point. Only the two allowlisted generated settings enum files differ. Temporary generator files and offload logs moved outside the repository worktree. The repository node_modules contains no offload scratch files. + +All 13 matrix rows have commands, verbatim observations, and Base or Head evidence. PostgreSQL/CockroachDB runtime coverage, the full sync package timeout, broad vet, and broad lint remain bounded as stated above. diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index a534694dfc..9225184504 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -1336,6 +1336,7 @@ func openDB(cfg config.Config) (*db.DB, error) { return nil, err } database.SetToolResultImages(cfg.ToolResultImages) + database.SetAssetsDir(filepath.Join(cfg.DataDir, "assets")) if cfg.InstallationID != "" { unowned, err := database.EnsureInstallationIdentity(context.Background(), cfg.InstallationID) if err != nil { diff --git a/cmd/agentsview/sync_worker.go b/cmd/agentsview/sync_worker.go index 438dcdd4a8..da0d5f5942 100644 --- a/cmd/agentsview/sync_worker.go +++ b/cmd/agentsview/sync_worker.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "github.com/spf13/cobra" "go.kenn.io/agentsview/internal/config" @@ -387,6 +388,7 @@ func workerEngineConfig(cfg config.Config) sync.EngineConfig { Machine: cfg.InstallationID, BlockedResultCategories: cfg.ResultContentBlockedCategories, ToolResultImages: cfg.ToolResultImages, + AssetsDir: filepath.Join(cfg.DataDir, "assets"), ArchiveContent: cfg.ArchiveContent, } } diff --git a/docs/commands.md b/docs/commands.md index f8ac594dea..b1b1d7590f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1638,3 +1638,7 @@ profile or pass them inline: ```bash AGENTSVIEW_DATA_DIR=/tmp/av-test agentsview serve ``` + +### Ingest-time image offload + +Set `tool_result_images = "offload"` to offload supported tool-result images on future ingestion. Run `agentsview db migrate --images` to retry retained inline images after an asset-write failure. The raw `agentsview session export` command continues to stream provider source bytes. See [image storage](/docs/data/#ingest-time-image-offload) for backup and remote-backend limits. diff --git a/docs/configuration.md b/docs/configuration.md index 248fafad56..bc2c22de5f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -195,7 +195,7 @@ zoom_level = 120 | `cursor_admin_user_id` | Optional default Cursor Admin usage filter by member user ID | | `github_token` | Optional saved GitHub token for Gist publishing | | `result_content_blocked_categories` | Tool categories whose result content is not stored (default: `["Read", "Glob"]`). Changes apply to new ingestion and full rebuilds; see [storage maintenance](/docs/data/#storage-maintenance) for existing source-backed sessions. | -| `tool_result_images` | Retain supported inline tool-result image blocks with `"keep"` (default), or store readable `agentsview_image` placeholders with `"drop"`. The setting affects future ingestion and full resyncs; run `db strip --images` for existing rows; also configurable under **Settings > Archive content**, and changes require a daemon restart. | +| `tool_result_images` | Retain supported inline tool-result image blocks with `"keep"` (default), or store readable `agentsview_image` placeholders with `"drop"`, or move supported images to the local asset store with `"offload"`. The setting affects future ingestion and full resyncs; run `db strip --images` for existing rows; also configurable under **Settings > Archive content**, and changes require a daemon restart. | | `archive_content` | How much of each session the archive stores: `"full"` (default), `"transcripts"`, or `"usage"`; changes require a daemon restart — see [Archive content](#archive-content) | | `host` | Interface the server binds to (default `127.0.0.1`); non-loopback values require `require_auth = true` | | `require_auth` | Require bearer-token authentication for API access | @@ -1660,3 +1660,7 @@ Disable the CLI/web UI update check with any of: The desktop app's auto-updater is controlled separately via `AGENTSVIEW_DESKTOP_AUTOUPDATE=0`. + +### Ingest-time image offload + +`tool_result_images = "offload"` stores supported PNG, JPEG, WebP, and GIF tool-result images in `{dataDir}/assets/` before SQLite publishes their references. Restart the daemon to apply the setting. Failed writes keep inline content, and archives that omit tool content write no assets. See [image storage](/docs/data/#ingest-time-image-offload) for retries, backups, and remote-backend limits. diff --git a/docs/data.md b/docs/data.md index eeb62b80d5..a5a1d53f18 100644 --- a/docs/data.md +++ b/docs/data.md @@ -135,3 +135,12 @@ reclaimed bytes separately; its result must not be combined with savings from filtering or a future compression implementation. See [`agentsview db compact`](/docs/commands/#agentsview-db-compact) for the staging space model and interrupted-compaction recovery. + + +### Ingest-time image offload + +Set `tool_result_images = "offload"` to move supported inline tool-result PNG, JPEG, WebP, and GIF payloads into `{dataDir}/assets/` during ingestion. Restart the daemon after changing the setting. Each asset write completes before SQLite can commit its `agentsview_image` placeholder and `image_ref`. Unsupported media and malformed data remain inline. Archives that omit tool content write no image assets. `keep` retains inline content; `drop` retains the existing readable placeholder without an asset. + +If an asset write fails, ingestion and copied-session resync keep the original inline content. Retry retained inline payloads with `agentsview db migrate --images` after restoring access to the asset directory. Complete unreferenced objects from a failed database transaction remain available for reuse; the store does not automatically remove them. Back up the asset directory with the archive and copy both to another local serving host. + +PostgreSQL and CockroachDB preserve the placeholder and reference text but cannot resolve the local asset. DuckDB, artifact exports, and the normalized server session exports at `/api/v1/sessions/{id}/export` and `/api/v1/sessions/{id}/md` carry the stored content. The raw `agentsview session export` command streams provider source bytes, so its output retains the original inline payloads. diff --git a/docs/session-api.md b/docs/session-api.md index bc8836564b..4c18d52850 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -1105,3 +1105,7 @@ When [PostgreSQL sync](/docs/pg-sync/) is enabled, the `secret_findings` table, the session-level `secret_leak_count`, and the `--has-secret` filter all mirror to the shared database. Substring and regex content search work the same way against `pg serve`, with the same masking and `--reveal` constraints. + +### Ingest-time image offload + +The normalized `/api/v1/sessions/{id}/export` and `/api/v1/sessions/{id}/md` endpoints carry stored tool-result content, including `agentsview_image` placeholders and `image_ref` asset references. A serving host needs the matching `{dataDir}/assets` directory. PostgreSQL and CockroachDB retain the reference text but cannot resolve local assets. The raw `agentsview session export` command streams provider source bytes and retains their original inline payloads. diff --git a/frontend/e2e/settings-archive-content.spec.ts b/frontend/e2e/settings-archive-content.spec.ts index c247c0afb7..027cc9fae2 100644 --- a/frontend/e2e/settings-archive-content.spec.ts +++ b/frontend/e2e/settings-archive-content.spec.ts @@ -59,6 +59,10 @@ test.describe("Settings archive content", () => { const dropRadio = page.getByRole("radio", { name: "Drop" }); await expect(keepRadio).toHaveAttribute("aria-checked", "true"); await expect(dropRadio).toHaveAttribute("aria-checked", "false"); + await expect(page.getByRole("radio", { name: "Offload" })).toHaveAttribute( + "aria-checked", + "false", + ); await expect(page.locator(".archive-content-settings").getByRole("status")).toHaveCount(0); }); @@ -96,15 +100,15 @@ test.describe("Settings archive content", () => { headers: { Origin: baseURL! }, data: { tool_result_images: "keep" }, }); - // A silent restore failure would leave the daemon on drop and turn the + // A silent restore failure would leave the daemon on offload and turn the // retry into a pass that asserts nothing. expect(restored.ok()).toBe(true); }); - test("Drop selection persists after page reload", async ({ page }) => { + test("Offload selection persists after page reload", async ({ page }) => { await openArchiveContent(page); - // Fails loudly if a prior restore left the daemon on drop, because + // Fails loudly if a prior restore left the daemon on offload, because // clicking an already-selected option issues no PUT at all. await expect(page.getByRole("radio", { name: "Keep" })).toHaveAttribute( "aria-checked", @@ -116,9 +120,9 @@ test.describe("Settings archive content", () => { response.request().method() === "PUT" && new URL(response.url()).pathname === "/api/v1/settings", ); - await page.getByRole("radio", { name: "Drop" }).click(); - expect(await (await putEcho).json()).toMatchObject({ tool_result_images: "drop" }); - await expect(page.getByRole("radio", { name: "Drop" })).toHaveAttribute( + await page.getByRole("radio", { name: "Offload" }).click(); + expect(await (await putEcho).json()).toMatchObject({ tool_result_images: "offload" }); + await expect(page.getByRole("radio", { name: "Offload" })).toHaveAttribute( "aria-checked", "true", ); @@ -127,7 +131,7 @@ test.describe("Settings archive content", () => { const nav = page.getByRole("navigation", { name: "Settings" }); await nav.locator("button", { hasText: "Archive content" }).click(); - await expect(page.getByRole("radio", { name: "Drop" })).toHaveAttribute( + await expect(page.getByRole("radio", { name: "Offload" })).toHaveAttribute( "aria-checked", "true", ); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c6416d0afd..3cb6d7641f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2060,6 +2060,7 @@ "settings_tool_result_images": "Tool-result images", "settings_tool_result_images_keep": "Keep", "settings_tool_result_images_drop": "Drop", + "settings_tool_result_images_offload": "Offload", "settings_tool_result_images_hint": "Stored results keep the policy they were written under, and switching back to Keep cannot restore payloads already removed. Run agentsview db strip --images to project existing rows.", "settings_tool_result_images_restart_notice": "Restart the AgentsView daemon to apply this change to newly ingested sessions.", "settings_tool_images_title": "Tool-result images", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index ef6dbf0a0f..bee0033867 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -2059,6 +2059,7 @@ "settings_tool_result_images": "Images des résultats d'outils", "settings_tool_result_images_keep": "Conserver", "settings_tool_result_images_drop": "Supprimer", + "settings_tool_result_images_offload": "Stocker séparément", "settings_tool_result_images_hint": "Les résultats déjà stockés gardent la règle appliquée lors de leur écriture, et revenir à Conserver ne restaure pas les données déjà supprimées. Lancez agentsview db strip --images pour traiter les lignes existantes.", "settings_tool_result_images_restart_notice": "Redémarrez le démon AgentsView pour appliquer ce changement aux nouvelles sessions ingérées.", "settings_tool_images_title": "Images des résultats d'outils", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index a20491b5a7..150d73ec07 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -2060,6 +2060,7 @@ "settings_tool_result_images": "ツール結果の画像", "settings_tool_result_images_keep": "保持", "settings_tool_result_images_drop": "破棄", + "settings_tool_result_images_offload": "外部保存", "settings_tool_result_images_hint": "保存済みの結果は書き込み時のポリシーを保ち、保持に戻しても削除済みのデータは復元できません。既存の行は agentsview db strip --images で処理してください。", "settings_tool_result_images_restart_notice": "この変更を新しく取り込むセッションに適用するには、AgentsView デーモンを再起動してください。", "settings_tool_images_title": "ツール結果の画像", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index 2b8fc3c5ae..c5d105ef5d 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -1995,6 +1995,7 @@ "settings_tool_result_images": "도구 결과 이미지", "settings_tool_result_images_keep": "유지", "settings_tool_result_images_drop": "삭제", + "settings_tool_result_images_offload": "별도 저장", "settings_tool_result_images_hint": "이미 저장된 결과는 기록될 당시의 정책을 유지하며, 유지로 되돌려도 이미 삭제된 데이터는 복원되지 않습니다. 기존 행은 agentsview db strip --images 로 처리합니다.", "settings_tool_result_images_restart_notice": "이 변경을 새로 수집되는 세션에 적용하려면 AgentsView 데몬을 다시 시작하세요.", "settings_tool_images_title": "도구 결과 이미지", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index f635de9ca1..23228880eb 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -1995,6 +1995,7 @@ "settings_tool_result_images": "工具结果图片", "settings_tool_result_images_keep": "保留", "settings_tool_result_images_drop": "丢弃", + "settings_tool_result_images_offload": "单独存储", "settings_tool_result_images_hint": "已存储的结果保持写入时的策略,改回「保留」也无法恢复已删除的数据。运行 agentsview db strip --images 可处理已有记录。", "settings_tool_result_images_restart_notice": "重启 AgentsView 守护进程后,此更改才会应用到新采集的会话。", "settings_tool_images_title": "工具结果图像", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 01676061ca..e6f2381bdf 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -1995,6 +1995,7 @@ "settings_tool_result_images": "工具結果圖片", "settings_tool_result_images_keep": "保留", "settings_tool_result_images_drop": "捨棄", + "settings_tool_result_images_offload": "另存圖片", "settings_tool_result_images_hint": "已儲存的結果維持寫入時的政策,改回「保留」也無法還原已刪除的資料。執行 agentsview db strip --images 可處理既有記錄。", "settings_tool_result_images_restart_notice": "重新啟動 AgentsView 常駐程式後,此變更才會套用到新擷取的對話。", "settings_tool_images_title": "工具結果圖像", diff --git a/frontend/src/lib/api/generated/models/settingsResponseToolResultImages.ts b/frontend/src/lib/api/generated/models/settingsResponseToolResultImages.ts index 1e1353b304..0b0591b7a6 100644 --- a/frontend/src/lib/api/generated/models/settingsResponseToolResultImages.ts +++ b/frontend/src/lib/api/generated/models/settingsResponseToolResultImages.ts @@ -11,4 +11,5 @@ export type SettingsResponseToolResultImages = export const SettingsResponseToolResultImages = { keep: "keep", drop: "drop", + offload: "offload", } as const; diff --git a/frontend/src/lib/api/generated/models/settingsUpdateRequestToolResultImages.ts b/frontend/src/lib/api/generated/models/settingsUpdateRequestToolResultImages.ts index 0c09cd7454..dc26ed3819 100644 --- a/frontend/src/lib/api/generated/models/settingsUpdateRequestToolResultImages.ts +++ b/frontend/src/lib/api/generated/models/settingsUpdateRequestToolResultImages.ts @@ -11,4 +11,5 @@ export type SettingsUpdateRequestToolResultImages = export const SettingsUpdateRequestToolResultImages = { keep: "keep", drop: "drop", + offload: "offload", } as const; diff --git a/frontend/src/lib/components/settings/ArchiveContentSettings.svelte b/frontend/src/lib/components/settings/ArchiveContentSettings.svelte index ae2c726998..8d9865a4e8 100644 --- a/frontend/src/lib/components/settings/ArchiveContentSettings.svelte +++ b/frontend/src/lib/components/settings/ArchiveContentSettings.svelte @@ -10,6 +10,7 @@ $derived([ { value: "keep", label: m.settings_tool_result_images_keep() }, { value: "drop", label: m.settings_tool_result_images_drop() }, + { value: "offload", label: m.settings_tool_result_images_offload() }, ]); let restartRequired = $state(false); @@ -24,7 +25,7 @@ ariaLabel={m.settings_tool_result_images()} disabled={settings.saving || settings.readOnly} onchange={async (value) => { - if (value !== "keep" && value !== "drop") return; + if (value !== "keep" && value !== "drop" && value !== "offload") return; if (value === settings.toolResultImages) return; // A failed write leaves the stored policy alone, so the notice waits // for the save the way AgentDirSettings does. diff --git a/frontend/src/lib/components/settings/ArchiveContentSettings.test.ts b/frontend/src/lib/components/settings/ArchiveContentSettings.test.ts index 36a1e01e27..ce18257ed2 100644 --- a/frontend/src/lib/components/settings/ArchiveContentSettings.test.ts +++ b/frontend/src/lib/components/settings/ArchiveContentSettings.test.ts @@ -20,7 +20,7 @@ const settingsService = SettingsService as unknown as { putApiV1Settings: ReturnType; }; -function makeSettingsResponse(toolResultImages: "keep" | "drop") { +function makeSettingsResponse(toolResultImages: "keep" | "drop" | "offload") { return { agent_dirs: {}, chart_palette: "agentsview", @@ -48,7 +48,7 @@ describe("ArchiveContentSettings", () => { cleanup(); }); - it("renders Keep and Drop options with the store value marked aria-checked", () => { + it("renders Keep, Drop, and Offload options with the store value marked aria-checked", () => { const { getByRole } = render(ArchiveContentSettings); expect(getByRole("radio", { name: "Keep" }).getAttribute("aria-checked")).toBe("true"); expect(getByRole("radio", { name: "Drop" }).getAttribute("aria-checked")).toBe("false"); @@ -66,6 +66,16 @@ describe("ArchiveContentSettings", () => { expect(getByRole("radio", { name: "Drop" }).getAttribute("aria-checked")).toBe("true"); }); + it("saves offload and retains the returned policy", async () => { + settingsService.putApiV1Settings.mockResolvedValue(makeSettingsResponse("offload")); + const { getByRole } = render(ArchiveContentSettings); + await fireEvent.click(getByRole("radio", { name: "Offload" })); + expect(settingsService.putApiV1Settings).toHaveBeenCalledWith({ + tool_result_images: "offload", + }); + expect(getByRole("radio", { name: "Offload" }).getAttribute("aria-checked")).toBe("true"); + }); + it("shows no restart notice on first render and shows it after a saved selection", async () => { settingsService.putApiV1Settings.mockResolvedValue(makeSettingsResponse("drop")); const { getByRole, findByRole, queryByRole } = render(ArchiveContentSettings); @@ -93,11 +103,12 @@ describe("ArchiveContentSettings", () => { expect(getByRole("radio", { name: "Keep" }).getAttribute("aria-checked")).toBe("true"); }); - it("disables both radios when settings are read-only", () => { + it("disables all radios when settings are read-only", () => { settings.readOnly = true; const { getByRole } = render(ArchiveContentSettings); expect((getByRole("radio", { name: "Keep" }) as HTMLButtonElement).disabled).toBe(true); expect((getByRole("radio", { name: "Drop" }) as HTMLButtonElement).disabled).toBe(true); + expect((getByRole("radio", { name: "Offload" }) as HTMLButtonElement).disabled).toBe(true); }); }); diff --git a/frontend/src/lib/stores/settings.svelte.ts b/frontend/src/lib/stores/settings.svelte.ts index f7b810ed23..c2f8b4e46d 100644 --- a/frontend/src/lib/stores/settings.svelte.ts +++ b/frontend/src/lib/stores/settings.svelte.ts @@ -13,7 +13,7 @@ import { import { DEFAULT_CHART_PALETTE, isChartPalette, type ChartPalette } from "../utils/chartPalette.js"; import { ui } from "./ui.svelte.js"; -export type ToolResultImagesPolicy = "keep" | "drop"; +export type ToolResultImagesPolicy = "keep" | "drop" | "offload"; type TerminalConfig = TerminalResponse; @@ -112,7 +112,10 @@ class SettingsStore { ui.applyZoomDefault(data.zoom_level); // A response without the field, including every fixture that predates // it, reads as the default keep policy instead of failing the load. - this.toolResultImages = data.tool_result_images === "drop" ? "drop" : "keep"; + this.toolResultImages = + data.tool_result_images === "drop" || data.tool_result_images === "offload" + ? data.tool_result_images + : "keep"; // When the server returns an auth token (localhost only), persist // it so the client stays authenticated after remote access is // toggled on (which starts requiring auth for all requests). @@ -178,7 +181,10 @@ class SettingsStore { this.chartPalette = data.chart_palette; // A response without the field, including every fixture that predates // it, reads as the default keep policy instead of failing the load. - this.toolResultImages = data.tool_result_images === "drop" ? "drop" : "keep"; + this.toolResultImages = + data.tool_result_images === "drop" || data.tool_result_images === "offload" + ? data.tool_result_images + : "keep"; if (data.auth_token && !isRemoteConnection()) { setAuthToken(data.auth_token); } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8d680eba70..142a1ad0a9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -166,6 +166,7 @@ func TestToolResultImagesConfig(t *testing.T) { }{ {name: "missing defaults to keep", want: ToolResultImagesKeep}, {name: "keep trims and folds", toml: `tool_result_images = " KEEP "`, want: ToolResultImagesKeep}, + {name: "offload", toml: `tool_result_images = "offload"`, want: ToolResultImagesOffload}, {name: "drop trims and folds", toml: `tool_result_images = " Drop "`, want: ToolResultImagesDrop}, } for _, tt := range tests { @@ -180,17 +181,17 @@ func TestToolResultImagesConfig(t *testing.T) { cfg, err := Default() require.NoError(t, err) require.EqualError(t, cfg.applyConfigTOML(`tool_result_images = "discard"`), - `tool_result_images must be "keep" or "drop" (got "discard")`) + `tool_result_images must be "keep", "drop", or "offload" (got "discard")`) dir := setupTestEnv(t) cfg.DataDir = dir require.NoError(t, cfg.SaveSettings(map[string]any{ - "tool_result_images": ToolResultImagesDrop, + "tool_result_images": ToolResultImagesOffload, })) - assert.Equal(t, ToolResultImagesDrop, cfg.ToolResultImages) + assert.Equal(t, ToolResultImagesOffload, cfg.ToolResultImages) loaded, err := LoadMinimal() require.NoError(t, err) - assert.Equal(t, ToolResultImagesDrop, loaded.ToolResultImages) + assert.Equal(t, ToolResultImagesOffload, loaded.ToolResultImages) } func setTestHome(t *testing.T, home string) { diff --git a/internal/config/tool_result_images.go b/internal/config/tool_result_images.go index f6105e3166..1329d71f74 100644 --- a/internal/config/tool_result_images.go +++ b/internal/config/tool_result_images.go @@ -10,11 +10,12 @@ import ( type ToolResultImages string const ( - ToolResultImagesKeep ToolResultImages = "" - ToolResultImagesDrop ToolResultImages = "drop" + ToolResultImagesKeep ToolResultImages = "" + ToolResultImagesDrop ToolResultImages = "drop" + ToolResultImagesOffload ToolResultImages = "offload" ) -// ParseToolResultImages accepts the two persisted policy values. An empty +// ParseToolResultImages accepts the persisted policy values. An empty // value means the default keep policy. func ParseToolResultImages(value string) (ToolResultImages, error) { switch value = strings.ToLower(strings.TrimSpace(value)); ToolResultImages(value) { @@ -22,11 +23,11 @@ func ParseToolResultImages(value string) (ToolResultImages, error) { return ToolResultImagesKeep, nil case "keep": return ToolResultImagesKeep, nil - case "drop": - return ToolResultImagesDrop, nil + case "drop", "offload": + return ToolResultImages(value), nil default: return "", fmt.Errorf( - `tool_result_images must be "keep" or "drop" (got %q)`, value, + `tool_result_images must be "keep", "drop", or "offload" (got %q)`, value, ) } } diff --git a/internal/db/db.go b/internal/db/db.go index cb1c060959..e21b29f519 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -763,6 +763,7 @@ type DB struct { undrainedPools []*sql.DB readOnly bool toolResultImages config.ToolResultImages + assetsDir string // archiveContent indexes archiveContentRanks; see SetArchiveContent. archiveContent atomic.Int32 // writerClosed is set while the writer pool is intentionally closed for a diff --git a/internal/db/messages.go b/internal/db/messages.go index 87c4810179..9641b543dc 100644 --- a/internal/db/messages.go +++ b/internal/db/messages.go @@ -1417,7 +1417,7 @@ func (db *DB) insertMessages( if err := db.requireWritable(); err != nil { return err } - msgs, _ = ProjectToolResultImages(msgs, policy) + msgs, _ = db.ProjectToolResultImagesWithPolicy(msgs, policy) rawMessages := msgs msgs = db.messagesForStorage(msgs) if len(rawMessages) == 0 { @@ -1646,22 +1646,14 @@ func (db *DB) writeSessionIncremental( if err := db.requireWritable(); err != nil { return false, err } - msgs, _ = ProjectToolResultImages(msgs, policy) - if policy == config.ToolResultImagesDrop { - update.SubagentLinks = append([]ToolCallSubagentLink(nil), update.SubagentLinks...) - for i := range update.SubagentLinks { - content, _ := StripToolResultImages(update.SubagentLinks[i].ResultContent) - update.SubagentLinks[i].ResultContent = content - update.SubagentLinks[i].ResultContentLen = ResolveResultContentLength( - content, update.SubagentLinks[i].ResultContentLen, - ) - } - } + msgs, _ = db.ProjectToolResultImagesWithPolicy(msgs, policy) + rawMessages := msgs msgs = db.messagesForStorage(msgs) update.SubagentLinks = db.subagentLinksForStorage(update.SubagentLinks) update.ToolCallResultUpdates = db.toolCallResultUpdatesForStorage(update.ToolCallResultUpdates) if db.ArchiveContent().OmitsToolContent() { + policy = config.ToolResultImagesKeep update.Checkpoint, update.CheckpointBlobs = nil, nil } @@ -1706,7 +1698,7 @@ func (db *DB) writeSessionIncremental( } for _, link := range update.SubagentLinks { changed, err := applyToolCallSubagentLinkTx( - tx, sessionID, link, update.BlockedResultCategories, policy, + tx, sessionID, link, update.BlockedResultCategories, policy, db.AssetsDir(), ) if err != nil { return false, err @@ -1717,7 +1709,7 @@ func (db *DB) writeSessionIncremental( for _, resultUpdate := range update.ToolCallResultUpdates { changed, inserted, err := applyToolCallResultUpdateTx( tx, sessionID, resultUpdate, - update.BlockedResultCategories, policy, + update.BlockedResultCategories, policy, db.AssetsDir(), ) if err != nil { return false, err @@ -1891,7 +1883,7 @@ func (db *DB) ReplaceSessionMessagesWithToolResultImages( func (db *DB) replaceSessionMessages( sessionID string, msgs []Message, policy config.ToolResultImages, ) error { - msgs, _ = ProjectToolResultImages(msgs, policy) + msgs, _ = db.ProjectToolResultImagesWithPolicy(msgs, policy) msgs = append([]Message(nil), msgs...) _ = ValidateAndSanitize(nil, msgs, nil) rawMessages := msgs @@ -2282,7 +2274,7 @@ func (db *DB) replaceSessionContent( cp *ParserCheckpoint, blobs *ParserCheckpointBlobs, policy config.ToolResultImages, ) error { - msgs, _ = ProjectToolResultImages(msgs, policy) + msgs, _ = db.ProjectToolResultImagesWithPolicy(msgs, policy) if len(msgs) > 0 { msgs = append([]Message(nil), msgs...) _ = ValidateAndSanitize(nil, msgs, nil) @@ -3326,7 +3318,7 @@ func (db *DB) SetToolCallSubagentSession( tx, sessionID, ToolCallSubagentLink{ ToolUseID: toolUseID, SubagentSessionID: subagentSessionID, - }, nil, db.ToolResultImages(), + }, nil, db.ToolResultImages(), db.AssetsDir(), ) if err != nil { return err @@ -3354,7 +3346,7 @@ func (db *DB) SetToolCallSubagentSession( // loading content so repeated appends do not rescan the event history. func soleToolResultEventTx( tx *sql.Tx, sessionID string, messageOrdinal, callIndex int, - imagePolicy config.ToolResultImages, summary string, + imagePolicy config.ToolResultImages, ) ([]ToolResultEvent, error) { var count int var content sql.NullString @@ -3386,15 +3378,18 @@ func soleToolResultEventTx( sessionID, messageOrdinal, callIndex, err, ) } - return []ToolResultEvent{{Content: projectToolResultEventForDedup( - content.String, summary, imagePolicy, - )}}, nil + stored := content.String + if imagePolicy == config.ToolResultImagesDrop { + stored, _ = StripToolResultImages(stored) + } + // Offload dedup must compare stored bytes because readers hydrate the original event. + return []ToolResultEvent{{Content: stored}}, nil } func applyToolCallSubagentLinkTx( tx *sql.Tx, sessionID string, link ToolCallSubagentLink, blockedResultCategories map[string]bool, - imagePolicy config.ToolResultImages, + imagePolicy config.ToolResultImages, assetsDir string, ) (bool, error) { var toolName, category, currentSubagent, currentResultContent string var currentResultContentLen, messageOrdinal, callIndex int @@ -3445,7 +3440,7 @@ func applyToolCallSubagentLinkTx( if blockedResultCategories[category] { resultContent = "" } else { - resultContent = projectToolResultImageContent(resultContent, imagePolicy) + resultContent = ProjectToolResultImageContent(resultContent, imagePolicy, assetsDir) resultContentLen = ResolveResultContentLength( resultContent, resultContentLen, ) @@ -3453,7 +3448,7 @@ func applyToolCallSubagentLinkTx( // targets may already have one stored. Re-storing a summary the // event repeats would undo the dedup on every incremental pass. sole, err := soleToolResultEventTx( - tx, sessionID, messageOrdinal, callIndex, imagePolicy, resultContent, + tx, sessionID, messageOrdinal, callIndex, imagePolicy, ) if err != nil { return false, err @@ -3479,7 +3474,7 @@ func applyToolCallSubagentLinkTx( func applyToolCallResultUpdateTx( tx *sql.Tx, sessionID string, update ToolCallResultUpdate, blockedResultCategories map[string]bool, - imagePolicy config.ToolResultImages, + imagePolicy config.ToolResultImages, assetsDir string, ) (bool, []ToolResultEvent, error) { if strings.TrimSpace(update.ToolUseID) == "" || len(update.Events) == 0 { return false, nil, nil @@ -3564,9 +3559,9 @@ func applyToolCallResultUpdateTx( // stripped-byte count before the blank overwrites Content, losing the // original result length the full and staged paths both preserve. if !blocked { - if imagePolicy == config.ToolResultImagesDrop { + if imagePolicy != config.ToolResultImagesKeep { for i := range incoming { - incoming[i].Content, _ = StripToolResultImages(incoming[i].Content) + incoming[i].Content = ProjectToolResultImageContent(incoming[i].Content, imagePolicy, assetsDir) incoming[i].ContentLength = ResolveResultContentLength( incoming[i].Content, incoming[i].ContentLength, ) @@ -3640,9 +3635,9 @@ func applyToolCallResultUpdateTx( // Existing events can predate a switch from keep to drop. Project the // assembled summary too, so a late update cannot store their raw images // again. Blocked results retain their original accounting length. - if imagePolicy == config.ToolResultImagesDrop { - projected, stats := StripToolResultImages(summary) - if stats.Payloads > 0 { + if imagePolicy != config.ToolResultImagesKeep { + projected := ProjectToolResultImageContent(summary, imagePolicy, assetsDir) + if projected != summary { summary = projected resultLength = len(summary) } @@ -3650,7 +3645,7 @@ func applyToolCallResultUpdateTx( sole, err := soleToolResultEventTx( tx, sessionID, position.MessageOrdinal, position.CallIndex, - imagePolicy, summary, + imagePolicy, ) if err != nil { return false, nil, err diff --git a/internal/db/session_batch.go b/internal/db/session_batch.go index e55fd59c18..8c238d7b87 100644 --- a/internal/db/session_batch.go +++ b/internal/db/session_batch.go @@ -42,7 +42,7 @@ func (db *DB) projectSessionBatchMessages(write SessionBatchWrite) []Message { if write.ToolResultImages != nil { policy = *write.ToolResultImages } - projected, _ := ProjectToolResultImages(write.Messages, policy) + projected, _ := db.ProjectToolResultImagesWithPolicy(write.Messages, policy) return projected } diff --git a/internal/db/strip_images.go b/internal/db/strip_images.go index 95d912ad2e..c6198ecdd1 100644 --- a/internal/db/strip_images.go +++ b/internal/db/strip_images.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/secrets" ) @@ -452,3 +453,26 @@ func countStrippable(content string) ToolImageStats { _, stats := StripToolResultImages(content) return stats } + +// ProjectToolImagesForSessions rewrites copied sessions before replacement publication. +func (db *DB) ProjectToolImagesForSessions(ctx context.Context, sessionIDs []string) error { + if db.ArchiveContent().OmitsToolContent() { + return nil + } + switch db.ToolResultImages() { + case config.ToolResultImagesDrop: + return db.StripToolImagesForSessions(ctx, sessionIDs) + case config.ToolResultImagesOffload: + if err := db.requireWritable(); err != nil { + return err + } + for _, id := range sessionIDs { + if _, err := db.rewriteStoredToolResultRows(ctx, id, func(content string) (string, error) { + return ProjectToolResultImageContent(content, db.ToolResultImages(), db.AssetsDir()), nil + }); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/db/tool_result_images.go b/internal/db/tool_result_images.go index 967ec329e9..2b97428935 100644 --- a/internal/db/tool_result_images.go +++ b/internal/db/tool_result_images.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log" "mime" "strings" @@ -390,16 +391,28 @@ func (w *countingWriter) Write(p []byte) (int, error) { } // ProjectToolResultImages applies the configured policy to a message graph. -// Drop mode copies the graph and its nested tool-result slices before editing. +// Projection copies the graph and its nested tool-result slices before editing. func ProjectToolResultImages( messages []Message, policy config.ToolResultImages, ) ([]Message, ToolImageStats) { - if policy != config.ToolResultImagesDrop || len(messages) == 0 { + return projectToolResultImages(messages, policy, "") +} + +func projectToolResultImages( + messages []Message, policy config.ToolResultImages, assetsDir string, +) ([]Message, ToolImageStats) { + if (policy != config.ToolResultImagesDrop && policy != config.ToolResultImagesOffload) || len(messages) == 0 { if messages == nil { return []Message{}, ToolImageStats{} } return messages, ToolImageStats{} } + project := func(content string, length int, stats ToolImageStats) (string, ToolImageStats) { + if policy == config.ToolResultImagesDrop { + return projectToolResultText(content, length, stats) + } + return ProjectToolResultImageContent(content, policy, assetsDir), stats + } projected := make([]Message, len(messages)) copy(projected, messages) var stats ToolImageStats @@ -408,13 +421,14 @@ func ProjectToolResultImages( for j := range projected[i].ToolCalls { call := &projected[i].ToolCalls[j] call.ResultEvents = append([]ToolResultEvent(nil), call.ResultEvents...) - call.ResultContent, stats = projectToolResultText(call.ResultContent, call.ResultContentLength, stats) + call.ResultContent, stats = project(call.ResultContent, call.ResultContentLength, stats) call.ResultContentLength = ResolveResultContentLength( call.ResultContent, call.ResultContentLength, ) for k := range call.ResultEvents { event := &call.ResultEvents[k] - event.Content, stats = projectToolResultText( + PrepareToolResultEvent(event) + event.Content, stats = project( event.Content, event.ContentLength, stats, ) event.ContentLength = ResolveResultContentLength( @@ -442,35 +456,41 @@ func projectToolResultText( return projected, stats } -func projectToolResultImageContent( - content string, policy config.ToolResultImages, -) string { - if policy != config.ToolResultImagesDrop { +// ProjectToolResultImageContent publishes supported assets before returning reference text. +func ProjectToolResultImageContent(content string, policy config.ToolResultImages, assetsDir string) string { + switch policy { + case config.ToolResultImagesDrop: + projected, _ := StripToolResultImages(content) + return projected + case config.ToolResultImagesOffload: + if assetsDir == "" { + return content + } + projected, err := migrateToolResultImages(content, func(mediaType string, body []byte) (string, bool, error) { + return assets.Put(assetsDir, mediaType, body) + }) + if err != nil { + log.Printf("offloading tool-result images: %v", err) + return content + } + return projected + default: return content } - projected, _ := StripToolResultImages(content) - return projected } -func projectToolResultEventForDedup( - content, summary string, policy config.ToolResultImages, -) string { - if policy != config.ToolResultImagesDrop { - return content - } - projected, stats := StripToolResultImages(content) - if stats.Payloads == 0 { - return content - } - return projected -} +// SetAssetsDir sets the local asset destination, including on read-only handles. +func (db *DB) SetAssetsDir(dir string) { db.assetsDir = dir } + +// AssetsDir returns the local asset destination. +func (db *DB) AssetsDir() string { return db.assetsDir } // SetToolResultImages stores the policy on a writable database handle. func (db *DB) SetToolResultImages(policy config.ToolResultImages) { if db.readOnly { return } - if policy != config.ToolResultImagesDrop { + if policy != config.ToolResultImagesDrop && policy != config.ToolResultImagesOffload { policy = config.ToolResultImagesKeep } db.toolResultImages = policy @@ -478,13 +498,21 @@ func (db *DB) SetToolResultImages(policy config.ToolResultImages) { // ToolResultImages returns the policy carried by this database handle. func (db *DB) ToolResultImages() config.ToolResultImages { - if db.toolResultImages == config.ToolResultImagesDrop { - return config.ToolResultImagesDrop + if db.toolResultImages == config.ToolResultImagesDrop || db.toolResultImages == config.ToolResultImagesOffload { + return db.toolResultImages } return config.ToolResultImagesKeep } // ProjectToolResultImages applies the handle's configured policy. func (db *DB) ProjectToolResultImages(messages []Message) ([]Message, ToolImageStats) { - return ProjectToolResultImages(messages, db.ToolResultImages()) + return db.ProjectToolResultImagesWithPolicy(messages, db.ToolResultImages()) +} + +// ProjectToolResultImagesWithPolicy applies a run policy only to retained tool content. +func (db *DB) ProjectToolResultImagesWithPolicy(messages []Message, policy config.ToolResultImages) ([]Message, ToolImageStats) { + if db.ArchiveContent().OmitsToolContent() { + return messages, ToolImageStats{} + } + return projectToolResultImages(messages, policy, db.AssetsDir()) } diff --git a/internal/db/tool_result_images_test.go b/internal/db/tool_result_images_test.go index 4277b39cc6..dd668c7f76 100644 --- a/internal/db/tool_result_images_test.go +++ b/internal/db/tool_result_images_test.go @@ -3,6 +3,11 @@ package db import ( "context" "encoding/json" + "fmt" + "github.com/mattn/go-sqlite3" + "os" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -444,3 +449,251 @@ func TestStripToolResultSummaryPreservesSectionBoundaries(t *testing.T) { }) } } + +func assertOffloadedImage(t *testing.T, content, dir string) { + t.Helper() + var blocks []struct { + Ref string `json:"image_ref"` + } + require.NoError(t, json.Unmarshal([]byte(content), &blocks)) + var ref string + for _, block := range blocks { + if block.Ref != "" { + ref = block.Ref + } + } + require.True(t, strings.HasPrefix(ref, "asset://"), content) + body, err := os.ReadFile(filepath.Join(dir, strings.TrimPrefix(ref, "asset://"))) + require.NoError(t, err) + assert.Equal(t, []byte{0, 1, 2}, body) +} + +func TestProjectToolResultImageContent(t *testing.T) { + raw := testInlineImageContent() + for _, mode := range []config.ToolResultImages{config.ToolResultImagesKeep, config.ToolResultImagesDrop, config.ToolResultImagesOffload, "unknown"} { + t.Run(string(mode), func(t *testing.T) { + dir := t.TempDir() + got := ProjectToolResultImageContent(raw, mode, dir) + if mode == config.ToolResultImagesOffload { + assertOffloadedImage(t, got, dir) + assert.Contains(t, got, `"text":"before"`) + assert.Contains(t, got, `"text":"after"`) + } else { + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + if mode == config.ToolResultImagesDrop { + assert.Contains(t, got, `"type":"agentsview_image"`) + assert.NotContains(t, got, "image_ref") + } else { + assert.Equal(t, raw, got) + } + } + }) + } + for _, raw := range []string{"", "ordinary text", `[{"type":"input_image","image_url":"data:image/svg+xml;base64,AAEC"}]`, `[{"type":"input_image","image_url":"data:image/png;base64,!!!"}]`, `[{"type":"input_image","image_url":"data:image/png,AAEC"}]`} { + dir := t.TempDir() + assert.Equal(t, raw, ProjectToolResultImageContent(raw, config.ToolResultImagesOffload, dir)) + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + } + blocked := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(blocked, []byte("occupied"), 0o600)) + assert.Equal(t, raw, ProjectToolResultImageContent(raw, config.ToolResultImagesOffload, blocked)) + assert.Equal(t, raw, ProjectToolResultImageContent(raw, config.ToolResultImagesOffload, "")) +} + +func TestToolResultImagesOffloadWriteRoutes(t *testing.T) { + for _, route := range []string{"insert", "incremental", "replacement", "content", "batch", "atomic"} { + t.Run(route, func(t *testing.T) { + d := testDB(t) + d.SetToolResultImages(config.ToolResultImagesOffload) + d.SetAssetsDir(t.TempDir()) + insertSession(t, d, route, "project") + messages := []Message{testImageMessage(route)} + switch route { + case "insert": + require.NoError(t, d.InsertMessages(messages)) + case "incremental": + _, err := d.WriteSessionIncremental(route, messages, IncrementalSessionUpdate{}) + require.NoError(t, err) + case "replacement": + require.NoError(t, d.ReplaceSessionMessages(route, messages)) + case "content": + require.NoError(t, d.ReplaceSessionContent(route, messages, SessionSignalUpdate{}, nil)) + default: + writes := []SessionBatchWrite{{Session: Session{ID: route, Project: "project", Machine: "local", Agent: "codex"}, Messages: messages}} + var result SessionBatchResult + var err error + if route == "atomic" { + result, err = d.WriteSessionBatchAtomic(writes) + } else { + result, err = d.WriteSessionBatch(writes) + } + require.NoError(t, err) + require.Equal(t, 1, result.WrittenSessions) + } + stored, err := d.GetAllMessages(t.Context(), route) + require.NoError(t, err) + require.Len(t, stored, 1) + call := stored[0].ToolCalls[0] + assertOffloadedImage(t, call.ResultContent, d.AssetsDir()) + require.Len(t, call.ResultEvents, 1) + assertOffloadedImage(t, call.ResultEvents[0].Content, d.AssetsDir()) + assert.Equal(t, len(call.ResultEvents[0].Content), call.ResultEvents[0].ContentLength) + assert.Equal(t, testInlineImageContent(), messages[0].ToolCalls[0].ResultEvents[0].Content) + }) + } +} + +func TestToolResultImagesOffloadLateAndLinked(t *testing.T) { + for _, blocked := range []bool{false, true} { + for _, linked := range []bool{false, true} { + t.Run(fmt.Sprintf("blocked=%t/linked=%t", blocked, linked), func(t *testing.T) { + d := testDB(t) + d.SetToolResultImages(config.ToolResultImagesOffload) + d.SetAssetsDir(t.TempDir()) + insertSession(t, d, "late", "project") + require.NoError(t, d.InsertMessages([]Message{{SessionID: "late", Role: "assistant", ToolCalls: []ToolCall{{ToolUseID: "call", Category: "Bash"}}}})) + raw := testInlineImageContent() + update := IncrementalSessionUpdate{MsgCount: 1, NextOrdinal: 1, BlockedResultCategories: map[string]bool{"Bash": blocked}} + if linked { + update.SubagentLinks = []ToolCallSubagentLink{{ToolUseID: "call", HasResult: true, ResultContent: raw, ResultContentLen: len(raw)}} + } else { + update.ToolCallResultUpdates = []ToolCallResultUpdate{{ToolUseID: "call", Events: []ToolResultEvent{{Content: raw, Source: "function_call_output"}}}} + } + _, err := d.WriteSessionIncremental("late", nil, update) + require.NoError(t, err) + stored, err := d.GetAllMessages(t.Context(), "late") + require.NoError(t, err) + call := stored[0].ToolCalls[0] + if blocked { + assert.Empty(t, call.ResultContent) + assert.Equal(t, len(raw), call.ResultContentLength) + entries, err := os.ReadDir(d.AssetsDir()) + require.NoError(t, err) + assert.Empty(t, entries) + } else { + assertOffloadedImage(t, call.ResultContent, d.AssetsDir()) + assert.Equal(t, len(call.ResultContent), call.ResultContentLength) + } + before, err := d.GetSessionFull(t.Context(), "late") + require.NoError(t, err) + _, err = d.WriteSessionIncremental("late", nil, update) + require.NoError(t, err) + after, err := d.GetSessionFull(t.Context(), "late") + require.NoError(t, err) + assert.Equal(t, before.TranscriptRevision, after.TranscriptRevision) + }) + } + } +} + +func TestToolResultImagesResyncRoute(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(fmt.Sprint(failed), func(t *testing.T) { + d := testDB(t) + dir := t.TempDir() + if failed { + dir = filepath.Join(dir, "file") + require.NoError(t, os.WriteFile(dir, []byte("occupied"), 0o600)) + } + d.SetAssetsDir(dir) + for _, id := range []string{"copied", "untouched"} { + insertSession(t, d, id, "project") + require.NoError(t, d.InsertMessages([]Message{testImageMessage(id)})) + } + d.SetToolResultImages(config.ToolResultImagesOffload) + require.NoError(t, d.ProjectToolImagesForSessions(t.Context(), []string{"copied"})) + for _, id := range []string{"copied", "untouched"} { + messages, err := d.GetAllMessages(t.Context(), id) + require.NoError(t, err) + if id == "copied" && !failed { + assertOffloadedImage(t, messages[0].ToolCalls[0].ResultContent, dir) + } else { + assert.Equal(t, testInlineImageContent(), messages[0].ToolCalls[0].ResultContent) + } + } + }) + } +} + +func TestToolResultImagesOffloadOmittedArchives(t *testing.T) { + for _, policy := range []config.ArchiveContent{config.ArchiveContentTranscripts, config.ArchiveContentUsage} { + t.Run(string(policy), func(t *testing.T) { + d := testDB(t) + d.SetToolResultImages(config.ToolResultImagesOffload) + d.SetAssetsDir(t.TempDir()) + d.SetArchiveContent(policy) + insertSession(t, d, "omitted", "project") + message := testImageMessage("omitted") + message.ToolCalls[0].ResultEvents = nil + require.NoError(t, d.InsertMessages([]Message{message})) + _, err := d.WriteSessionIncremental("omitted", nil, IncrementalSessionUpdate{SubagentLinks: []ToolCallSubagentLink{{ToolUseID: "call-1", HasResult: true, ResultContent: testInlineImageContent()}}, ToolCallResultUpdates: []ToolCallResultUpdate{{ToolUseID: "call-1", Events: []ToolResultEvent{{Content: testInlineImageContent()}}}}}) + require.NoError(t, err) + require.NoError(t, d.ProjectToolImagesForSessions(t.Context(), []string{"omitted"})) + entries, err := os.ReadDir(d.AssetsDir()) + require.NoError(t, err) + assert.Empty(t, entries) + }) + } +} + +func TestToolResultImagesOffloadPublishesBeforeInsert(t *testing.T) { + d := testDB(t) + d.SetAssetsDir(t.TempDir()) + d.SetToolResultImages(config.ToolResultImagesOffload) + insertSession(t, d, "ordered", "project") + conn, err := d.getWriter().Conn(t.Context()) + require.NoError(t, err) + require.NoError(t, conn.Raw(func(driverConn any) error { + return driverConn.(*sqlite3.SQLiteConn).RegisterFunc("asset_published", func(content string) bool { + const object = "ae4b3280e56e2faf83f414a6e3dabe9d5fbe18976544c05fed121accb85b53fc.png" + body, err := os.ReadFile(filepath.Join(d.AssetsDir(), object)) + return err == nil && string(body) == "\x00\x01\x02" && strings.Contains(content, "asset://"+object) + }, false) + })) + require.NoError(t, conn.Close()) + _, err = d.getWriter().Exec(`CREATE TEMP TRIGGER require_asset BEFORE INSERT ON tool_result_events WHEN NOT asset_published(NEW.content) BEGIN SELECT RAISE(ABORT, 'asset missing before insert'); END`) + require.NoError(t, err) + require.NoError(t, d.InsertMessages([]Message{testImageMessage("ordered")})) + var count int + require.NoError(t, d.getReader().QueryRow("SELECT COUNT(*) FROM tool_result_events WHERE session_id = 'ordered'").Scan(&count)) + assert.Equal(t, 1, count) +} + +func TestOffloadLinkedSummaryKeepsReferenceWithOlderInlineEvent(t *testing.T) { + d := testDB(t) + insertSession(t, d, "older", "project") + require.NoError(t, d.InsertMessages([]Message{testImageMessage("older")})) + d.SetToolResultImages(config.ToolResultImagesOffload) + d.SetAssetsDir(t.TempDir()) + _, err := d.WriteSessionIncremental("older", nil, IncrementalSessionUpdate{SubagentLinks: []ToolCallSubagentLink{{ToolUseID: "call-1", HasResult: true, ResultContent: testInlineImageContent()}}}) + require.NoError(t, err) + messages, err := d.GetAllMessages(t.Context(), "older") + require.NoError(t, err) + require.Len(t, messages, 1) + require.Len(t, messages[0].ToolCalls, 1) + call := messages[0].ToolCalls[0] + assertOffloadedImage(t, call.ResultContent, d.AssetsDir()) + require.Len(t, call.ResultEvents, 1) + assert.Equal(t, testInlineImageContent(), call.ResultEvents[0].Content) + assert.Equal(t, len(call.ResultContent), call.ResultContentLength) +} + +func TestOffloadOmittedLateResultDoesNotPublishOlderImages(t *testing.T) { + d := testDB(t) + insertSession(t, d, "retained", "project") + message := testImageMessage("retained") + PrepareToolResultEvent(&message.ToolCalls[0].ResultEvents[0]) + require.NoError(t, d.InsertMessages([]Message{message})) + d.SetAssetsDir(t.TempDir()) + d.SetToolResultImages(config.ToolResultImagesOffload) + d.SetArchiveContent(config.ArchiveContentTranscripts) + _, err := d.WriteSessionIncremental("retained", nil, IncrementalSessionUpdate{ToolCallResultUpdates: []ToolCallResultUpdate{{ToolUseID: "call-1", Events: []ToolResultEvent{{Content: "later result", Source: "function_call_output"}}}}}) + require.NoError(t, err) + objects, err := os.ReadDir(d.AssetsDir()) + require.NoError(t, err) + assert.Empty(t, objects) +} diff --git a/internal/server/export.go b/internal/server/export.go index 9e957b4620..2266c9775c 100644 --- a/internal/server/export.go +++ b/internal/server/export.go @@ -183,6 +183,7 @@ type exportMessage struct { Role string Timestamp string ContentHTML template.HTML + ToolResults []string FocusedHidden bool } @@ -455,7 +456,7 @@ footer a:hover { text-decoration: underline; }
{{- range .Messages}} -
{{.Role}}{{.Timestamp}}
{{.ContentHTML}}
+
{{.Role}}{{.Timestamp}}
{{.ContentHTML}}{{range .ToolResults}}
{{.}}
{{end}}
{{- end}}
@@ -654,8 +655,20 @@ func generateExportHTML( extraClass = " thinking-only" } + var results []string + for _, call := range m.ToolCalls { + if call.ResultContent != "" { + results = append(results, call.ResultContent) + } + for _, event := range call.ResultEvents { + if event.Content != "" && event.Content != call.ResultContent { + results = append(results, event.Content) + } + } + } data.Messages[i] = exportMessage{ Ordinal: m.Ordinal, + ToolResults: results, RoleClass: roleClass, ExtraClass: extraClass, Role: m.Role, diff --git a/internal/server/huma_routes_settings.go b/internal/server/huma_routes_settings.go index 4920941465..8d8dd45cbd 100644 --- a/internal/server/huma_routes_settings.go +++ b/internal/server/huma_routes_settings.go @@ -145,10 +145,7 @@ func (s *Server) humaUpdateSettings( patch["zoom_level"] = *in.Body.ZoomLevel } if in.Body.ToolResultImages != nil { - // The enum tag already constrained this to "keep" or "drop" before - // the handler ran, and those are the two spellings the configuration - // reference documents, so the value is persisted as sent. SaveSettings - // re-validates it for callers that bypass the HTTP layer. + // SaveSettings revalidates values for callers that bypass the HTTP enum. patch["tool_result_images"] = config.ToolResultImages(*in.Body.ToolResultImages) } if in.Body.DisabledAgents != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a68bf749b3..e25b638abc 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -3771,19 +3771,22 @@ func TestSettingsToolResultImagesRoundTrip(t *testing.T) { w = putSettings(`{"tool_result_images":"drop"}`) assertStatus(t, w, http.StatusOK) + assert.Equal(t, config.ToolResultImagesDrop, loadedPolicy(t)) + w = putSettings(`{"tool_result_images":"offload"}`) + assertStatus(t, w, http.StatusOK) var updated struct { ToolResultImages string `json:"tool_result_images"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) - assert.Equal(t, "drop", updated.ToolResultImages) + assert.Equal(t, "offload", updated.ToolResultImages) var persisted struct { ToolResultImages string `toml:"tool_result_images"` } _, err := toml.DecodeFile(filepath.Join(te.dataDir, "config.toml"), &persisted) require.NoError(t, err) - assert.Equal(t, "drop", persisted.ToolResultImages) - assert.Equal(t, config.ToolResultImagesDrop, loadedPolicy(t)) + assert.Equal(t, "offload", persisted.ToolResultImages) + assert.Equal(t, config.ToolResultImagesOffload, loadedPolicy(t)) w = putSettings(`{"tool_result_images":"keep"}`) assertStatus(t, w, http.StatusOK) @@ -5705,3 +5708,19 @@ func TestSettingsAgentHomesPersistAndRoundTrip(t *testing.T) { assert.Empty(t, persisted.Agents["pi"].Homes) assert.Equal(t, []string{"/sessions/pi"}, persisted.Agents["pi"].Dirs) } + +func TestNormalizedSessionExportsPreserveOffloadedImages(t *testing.T) { + te := setup(t) + te.db.SetToolResultImages(config.ToolResultImagesOffload) + te.db.SetAssetsDir(t.TempDir()) + te.seedSession(t, "image-export", "project", 1) + require.NoError(t, te.db.InsertMessages([]db.Message{{SessionID: "image-export", Role: "assistant", Content: "image result", ToolCalls: []db.ToolCall{{ToolUseID: "call", ToolName: "Read", Category: "Read", ResultContent: `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]`}}}})) + for _, endpoint := range []string{"export", "md"} { + w := te.get(t, "/api/v1/sessions/image-export/"+endpoint) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "image_ref") + assert.Contains(t, w.Body.String(), "asset://") + assert.Contains(t, w.Body.String(), "agentsview_image") + assert.NotContains(t, w.Body.String(), "base64,AAEC") + } +} diff --git a/internal/server/settings.go b/internal/server/settings.go index a7fb8cdd07..2d2e0c8e6c 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -18,7 +18,7 @@ type settingsResponse struct { Port int `json:"port"` ChartPalette config.ChartPalette `json:"chart_palette"` ZoomLevel *config.ZoomLevel `json:"zoom_level,omitempty"` - ToolResultImages string `json:"tool_result_images" enum:"keep,drop" doc:"Inline tool-result image retention applied to ingestion after a daemon restart"` + ToolResultImages string `json:"tool_result_images" enum:"keep,drop,offload" doc:"Inline tool-result image retention applied to ingestion after a daemon restart"` AuthToken string `json:"auth_token,omitempty"` RequireAuth bool `json:"require_auth"` ReadOnly bool `json:"read_only"` @@ -50,7 +50,7 @@ type settingsUpdateRequest struct { RequireAuth *bool `json:"require_auth,omitempty"` ChartPalette *string `json:"chart_palette,omitempty"` ZoomLevel *config.ZoomLevel `json:"zoom_level,omitempty"` - ToolResultImages *string `json:"tool_result_images,omitempty" enum:"keep,drop" doc:"Inline tool-result image retention applied to ingestion after a daemon restart"` + ToolResultImages *string `json:"tool_result_images,omitempty" enum:"keep,drop,offload" doc:"Inline tool-result image retention applied to ingestion after a daemon restart"` DisabledAgents *[]string `json:"disabled_agents,omitempty"` // AgentHomes replaces the alternate home list for each listed provider. // An empty list clears that provider's homes. @@ -64,8 +64,8 @@ const toolResultImagesKeepValue = "keep" // toolResultImagesValue renders a retention policy in its documented spelling. func toolResultImagesValue(policy config.ToolResultImages) string { - if policy == config.ToolResultImagesDrop { - return string(config.ToolResultImagesDrop) + if policy == config.ToolResultImagesDrop || policy == config.ToolResultImagesOffload { + return string(policy) } return toolResultImagesKeepValue } diff --git a/internal/sync/codex_staging.go b/internal/sync/codex_staging.go index ebee19d8ba..92857d390e 100644 --- a/internal/sync/codex_staging.go +++ b/internal/sync/codex_staging.go @@ -46,6 +46,7 @@ type codexStagingSink struct { idPrefix string toolResultImages config.ToolResultImages + database *db.DB // blocked marks categories whose stored content is blanked. Their raw // content never enters scratch storage; only digest, original length, @@ -540,8 +541,12 @@ func (s *codexStagingSink) AppendToolResultEvent( // contract before the real content enters the scratch publish source. // Keep dedup above this point raw: two provider events that differ // only by stripped controls remain two events on the collecting path. - if s.toolResultImages == config.ToolResultImagesDrop { - ev.Content, _ = db.StripToolResultImages(ev.Content) + if s.database == nil || !s.database.ArchiveContent().OmitsToolContent() { + assetsDir := "" + if s.database != nil { + assetsDir = s.database.AssetsDir() + } + ev.Content = db.ProjectToolResultImageContent(ev.Content, s.toolResultImages, assetsDir) contentLength = len(ev.Content) } toolCall := db.ToolCall{ResultEvents: []db.ToolResultEvent{{ diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 3149f08335..5cf77c5ad0 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -453,6 +453,7 @@ type EngineConfig struct { // ToolResultImages carries the configured retention policy into workers // that open the source archive read-only before building a replacement. ToolResultImages config.ToolResultImages + AssetsDir string // IncludeCwdPrefixes, when non-empty, restricts ingestion to // sessions whose working directory equals one of the prefixes // or lives underneath one. Sessions without a recorded cwd are @@ -947,8 +948,11 @@ func NewEngine( progressStallAfter = defaultProgressStallAfter } toolResultImages := database.ToolResultImages() - if cfg.ToolResultImages == config.ToolResultImagesDrop { - toolResultImages = config.ToolResultImagesDrop + if cfg.AssetsDir != "" { + database.SetAssetsDir(cfg.AssetsDir) + } + if cfg.ToolResultImages == config.ToolResultImagesDrop || cfg.ToolResultImages == config.ToolResultImagesOffload { + toolResultImages = cfg.ToolResultImages } e := &Engine{ db: database, @@ -3096,6 +3100,7 @@ func (e *Engine) resyncBuildLocked( return stats, err } newDB.SetToolResultImages(e.toolResultImages) + newDB.SetAssetsDir(e.db.AssetsDir()) if err := newDB.CopyArchiveIdentityFrom(origPath); err != nil { log.Printf("resync: preserve archive identity: %v", err) newDB.Close() @@ -3728,8 +3733,8 @@ func (e *Engine) resyncBuildLocked( log.Printf("resync: reclassify is_automated: %v", err) } - if newDB.ToolResultImages() == config.ToolResultImagesDrop { - if err := newDB.StripToolImagesForSessions(ctx, copiedSessionIDs); err != nil { + if newDB.ToolResultImages() != config.ToolResultImagesKeep { + if err := newDB.ProjectToolImagesForSessions(ctx, copiedSessionIDs); err != nil { log.Printf("resync: project copied tool-result images: %v", err) stats.Aborted = true stats.Warnings = append(stats.Warnings, @@ -11853,6 +11858,7 @@ func (e *Engine) processProviderFile( }, true } stagedSink.toolResultImages = e.toolResultImages + stagedSink.database = e.db stagedSink.idPrefix = e.idPrefix stagedSink.disableSignals = e.disableSignalRecompute stagedGCRelease = beginStagedColdSync() @@ -17044,7 +17050,7 @@ func (e *Engine) prepareSessionWriteContext( if err != nil { return db.Session{}, nil, sessionWritePreserved, err } - msgs, _ = db.ProjectToolResultImages(msgs, e.toolResultImages) + msgs, _ = e.db.ProjectToolResultImagesWithPolicy(msgs, e.toolResultImages) s, err := toDBSessionContext(ctx, pw) if err != nil { return db.Session{}, nil, sessionWritePreserved, err @@ -17095,7 +17101,7 @@ func (e *Engine) prepareSessionWriteContext( } else if mergedMsgs != nil { parsedMsgs := msgs msgs = mergedMsgs - msgs, _ = db.ProjectToolResultImages(msgs, e.toolResultImages) + msgs, _ = e.db.ProjectToolResultImagesWithPolicy(msgs, e.toolResultImages) applyVisualStudioCopilotArchiveSessionFields( &s, archived, parsedMsgs, msgs, ) @@ -18778,7 +18784,7 @@ func (e *Engine) writeIncremental( }, e.blockedResultCategories, ) - dbMsgs, _ = db.ProjectToolResultImages(dbMsgs, e.toolResultImages) + dbMsgs, _ = e.db.ProjectToolResultImagesWithPolicy(dbMsgs, e.toolResultImages) // The incremental append path bypasses prepareSessionWrite, so run // the central validation/sanitization pass on the new message rows // here to keep coverage uniform across write paths. The fix counts diff --git a/internal/sync/tool_result_images_test.go b/internal/sync/tool_result_images_test.go index 022121036e..7d5f9f01b6 100644 --- a/internal/sync/tool_result_images_test.go +++ b/internal/sync/tool_result_images_test.go @@ -320,120 +320,128 @@ func TestEngineImagePolicyDeduplicatesLateProjectedResult(t *testing.T) { assert.Equal(t, want, storedEvent) } -func TestReadOnlyResyncReplacementCarriesDropPolicy(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(t.TempDir(), "archive.db") - sourcePath := filepath.Join(root, "project", "keep0.jsonl") - require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0o755)) - require.NoError(t, os.WriteFile(sourcePath, []byte( - testjsonl.NewSessionBuilder(). - AddClaudeUser("2026-01-01T00:00:00Z", "hello"). - AddClaudeAssistant("2026-01-01T00:00:01Z", "hi"). - String(), - ), 0o644)) - - writable, err := db.Open(archivePath) - require.NoError(t, err) - engine := NewEngine(writable, EngineConfig{ - AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {root}}, - Machine: "local", - }) - require.Equal(t, 1, engine.SyncAll(context.Background(), nil).Synced) - copiedContent := `[{"type":"text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,AAEC"},{"type":"text","text":"after"}]` - for _, id := range []string{"trashed", "source-missing"} { - filePath := filepath.Join(root, id+".jsonl") - require.NoError(t, writable.UpsertSession(db.Session{ - ID: id, Project: "archived", Machine: "local", - Agent: string(parser.AgentClaude), MessageCount: 1, - FilePath: &filePath, - })) - require.NoError(t, writable.InsertMessages([]db.Message{{ - SessionID: id, Ordinal: 0, Role: "assistant", - ToolCalls: []db.ToolCall{{ - ToolUseID: "copied-call", - ResultContent: copiedContent, - ResultEvents: []db.ToolResultEvent{{ - ToolUseID: "copied-call", Source: "tool", - Status: "completed", Content: copiedContent, - }}, - }}, - }})) - } - require.NoError(t, writable.SoftDeleteSession("trashed")) - require.NoError(t, writable.Update(func(tx *sql.Tx) error { - _, err := tx.Exec( - "UPDATE sessions SET source_missing_at = ? WHERE id = ?", - "2026-01-01T00:00:00Z", "source-missing", - ) - return err - })) - engine.Close() - require.NoError(t, writable.Close()) +func TestReadOnlyResyncReplacementCarriesImagePolicy(t *testing.T) { + for _, mode := range []config.ToolResultImages{config.ToolResultImagesDrop, config.ToolResultImagesOffload} { + t.Run(string(mode), func(t *testing.T) { + root := t.TempDir() + archivePath := filepath.Join(t.TempDir(), "archive.db") + sourcePath := filepath.Join(root, "project", "keep0.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0o755)) + require.NoError(t, os.WriteFile(sourcePath, []byte( + testjsonl.NewSessionBuilder(). + AddClaudeUser("2026-01-01T00:00:00Z", "hello"). + AddClaudeAssistant("2026-01-01T00:00:01Z", "hi"). + String(), + ), 0o644)) + + writable, err := db.Open(archivePath) + require.NoError(t, err) + engine := NewEngine(writable, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {root}}, + Machine: "local", + }) + require.Equal(t, 1, engine.SyncAll(context.Background(), nil).Synced) + copiedContent := `[{"type":"text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,AAEC"},{"type":"text","text":"after"}]` + for _, id := range []string{"trashed", "source-missing"} { + filePath := filepath.Join(root, id+".jsonl") + require.NoError(t, writable.UpsertSession(db.Session{ + ID: id, Project: "archived", Machine: "local", + Agent: string(parser.AgentClaude), MessageCount: 1, + FilePath: &filePath, + })) + require.NoError(t, writable.InsertMessages([]db.Message{{ + SessionID: id, Ordinal: 0, Role: "assistant", + ToolCalls: []db.ToolCall{{ + ToolUseID: "copied-call", + ResultContent: copiedContent, + ResultEvents: []db.ToolResultEvent{{ + ToolUseID: "copied-call", Source: "tool", + Status: "completed", Content: copiedContent, + }}, + }}, + }})) + } + require.NoError(t, writable.SoftDeleteSession("trashed")) + require.NoError(t, writable.Update(func(tx *sql.Tx) error { + _, err := tx.Exec( + "UPDATE sessions SET source_missing_at = ? WHERE id = ?", + "2026-01-01T00:00:00Z", "source-missing", + ) + return err + })) + engine.Close() + require.NoError(t, writable.Close()) - readOnly, err := db.OpenReadOnly(archivePath) - require.NoError(t, err) - resyncEngine := NewEngine(readOnly, EngineConfig{ - AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {root}}, - Machine: "local", - ToolResultImages: config.ToolResultImagesDrop, - }) - t.Cleanup(resyncEngine.Close) - t.Cleanup(func() { require.NoError(t, readOnly.Close()) }) - - content := `[ {"type":"input_image","image_url":"data:image/png;base64,AAEC"} ]` - tempPath := archivePath + resyncTempSuffix - operations := productionRebuildOperations - operations.rebuildFTS = func(database *db.DB) error { - messages, err := database.GetAllMessages(context.Background(), "keep0") - if err != nil { - return err - } - if len(messages) == 0 { - return fmt.Errorf("resync test session was not rebuilt") - } - messages[0].ToolCalls = []db.ToolCall{{ - ToolUseID: "call-image", - ResultContent: content, - ResultEvents: []db.ToolResultEvent{{ - ToolUseID: "call-image", Source: "tool", - Status: "completed", Content: content, - }}, - }} - return database.ReplaceSessionMessages("keep0", messages) - } - stats, err := resyncEngine.resyncBuildLocked( - context.Background(), nil, RebuildOptions{}, operations, false, - ) - require.NoError(t, err) - assert.False(t, stats.Aborted) + readOnly, err := db.OpenReadOnly(archivePath) + require.NoError(t, err) + resyncEngine := NewEngine(readOnly, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {root}}, + Machine: "local", + ToolResultImages: mode, AssetsDir: t.TempDir(), + }) + t.Cleanup(resyncEngine.Close) + t.Cleanup(func() { require.NoError(t, readOnly.Close()) }) + + content := `[ {"type":"input_image","image_url":"data:image/png;base64,AAEC"} ]` + tempPath := archivePath + resyncTempSuffix + operations := productionRebuildOperations + operations.rebuildFTS = func(database *db.DB) error { + messages, err := database.GetAllMessages(context.Background(), "keep0") + if err != nil { + return err + } + if len(messages) == 0 { + return fmt.Errorf("resync test session was not rebuilt") + } + messages[0].ToolCalls = []db.ToolCall{{ + ToolUseID: "call-image", + ResultContent: content, + ResultEvents: []db.ToolResultEvent{{ + ToolUseID: "call-image", Source: "tool", + Status: "completed", Content: content, + }}, + }} + return database.ReplaceSessionMessages("keep0", messages) + } + stats, err := resyncEngine.resyncBuildLocked( + context.Background(), nil, RebuildOptions{}, operations, false, + ) + require.NoError(t, err) + assert.False(t, stats.Aborted) - replacement, err := db.Open(tempPath) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, replacement.Close()) }) - messages, err := replacement.GetAllMessages(context.Background(), "keep0") - require.NoError(t, err) - require.Len(t, messages, 2) - assert.NotContains(t, messages[0].ToolCalls[0].ResultContent, "input_image") - assert.NotContains(t, messages[0].ToolCalls[0].ResultEvents[0].Content, "input_image") - for _, id := range []string{"trashed", "source-missing"} { - messages, err := replacement.GetAllMessages(context.Background(), id) - require.NoError(t, err) - require.Len(t, messages, 1) - require.Len(t, messages[0].ToolCalls, 1) - assert.NotContains(t, messages[0].ToolCalls[0].ResultContent, "input_image") - require.Len(t, messages[0].ToolCalls[0].ResultEvents, 1) - assert.NotContains(t, - messages[0].ToolCalls[0].ResultEvents[0].Content, - "input_image", - ) - var storedEvent string - require.NoError(t, replacement.Reader().QueryRowContext( - context.Background(), - `SELECT content FROM tool_result_events + replacement, err := db.Open(tempPath) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, replacement.Close()) }) + messages, err := replacement.GetAllMessages(context.Background(), "keep0") + require.NoError(t, err) + require.Len(t, messages, 2) + assert.NotContains(t, messages[0].ToolCalls[0].ResultContent, "input_image") + assert.NotContains(t, messages[0].ToolCalls[0].ResultEvents[0].Content, "input_image") + for _, id := range []string{"trashed", "source-missing"} { + messages, err := replacement.GetAllMessages(context.Background(), id) + require.NoError(t, err) + require.Len(t, messages, 1) + require.Len(t, messages[0].ToolCalls, 1) + assert.NotContains(t, messages[0].ToolCalls[0].ResultContent, "input_image") + require.Len(t, messages[0].ToolCalls[0].ResultEvents, 1) + assert.NotContains(t, + messages[0].ToolCalls[0].ResultEvents[0].Content, + "input_image", + ) + var storedEvent string + require.NoError(t, replacement.Reader().QueryRowContext( + context.Background(), + `SELECT content FROM tool_result_events WHERE session_id = ? AND tool_call_message_ordinal = ? AND call_index = ?`, id, 0, 0, - ).Scan(&storedEvent)) - assert.NotContains(t, storedEvent, "input_image") + ).Scan(&storedEvent)) + assert.NotContains(t, storedEvent, "input_image") + if mode == config.ToolResultImagesOffload { + assert.Contains(t, storedEvent, `"image_ref":"asset://`) + } + } + + }) } } @@ -486,60 +494,73 @@ func TestDropPolicyProjectsVisualStudioCopilotArchiveMerge(t *testing.T) { } func TestCodexImageRetentionAcrossFullAndLateResults(t *testing.T) { - const uuid = "019eb791-cf7d-75c1-8439-9ed74c122b06" - const raw = `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]` - const later = `[{"type":"input_image","image_url":"data:image/png;base64,AwQF"}]` - const want = `[{"byte_size":3,"media_type":"image/png","sha256":"","text":"[Image: image/png, 3 bytes]","type":"agentsview_image","version":1}]` - for _, threshold := range []int64{1, 1 << 30} { - t.Run(fmt.Sprint(threshold), func(t *testing.T) { - root := t.TempDir() - day := filepath.Join(root, "2024", "01", "01") - require.NoError(t, os.MkdirAll(day, 0o755)) - path := filepath.Join(day, "rollout-2024-01-01T10-00-00-"+uuid+".jsonl") - transcript := testjsonl.JoinJSONL( - testjsonl.CodexSessionMetaJSON(uuid, root, "user", "2024-01-01T10:00:00Z"), - testjsonl.CodexMsgJSON("user", "show image", "2024-01-01T10:00:01Z"), - testjsonl.CodexFunctionCallWithCallIDJSON("exec_command", "call", `{}`, "2024-01-01T10:00:02Z"), - testjsonl.CodexFunctionCallOutputJSON("call", json.RawMessage(raw), "2024-01-01T10:00:03Z"), - ) - require.NoError(t, os.WriteFile(path, []byte(transcript), 0o600)) - database := openTestDB(t) - database.SetToolResultImages(config.ToolResultImagesKeep) - engine := NewEngine(database, EngineConfig{Machine: "local", Ephemeral: true, - AgentDirs: map[parser.AgentType][]string{parser.AgentCodex: {root}}, - ToolResultImages: config.ToolResultImagesDrop, StagedCodexParseMinBytes: threshold, - DisableFilesystemProjectDiscovery: true, - }) - t.Cleanup(engine.Close) - stats := engine.SyncAll(t.Context(), nil) - require.Equal(t, 1, stats.Synced) - for _, late := range []bool{false, true} { - if late { - require.NoError(t, engine.writeIncremental(&incrementalUpdate{ - sessionID: "codex:" + uuid, machine: "local", project: "project", msgCount: 2, - toolCallUpdates: []parser.ParsedToolCallUpdate{{ToolUseID: "call", MessageOrdinal: 1, CallIndex: 0, ResultEvents: []parser.ParsedToolResultEvent{{ - ToolUseID: "call", Source: "function_call_output", Content: later, - }}}}, - })) - } - messages, err := database.GetAllMessages(t.Context(), "codex:"+uuid) - require.NoError(t, err) - var calls []db.ToolCall - for _, message := range messages { - calls = append(calls, message.ToolCalls...) - } - require.Len(t, calls, 1) - assert.Equal(t, want, calls[0].ResultContent) - count := 1 - if late { - count = 2 - } - require.Len(t, calls[0].ResultEvents, count) - for _, event := range calls[0].ResultEvents { - assert.Equal(t, want, event.Content) - assert.Equal(t, len(want), event.ContentLength) - } + for _, mode := range []config.ToolResultImages{config.ToolResultImagesDrop, config.ToolResultImagesOffload} { + t.Run(string(mode), func(t *testing.T) { + const uuid = "019eb791-cf7d-75c1-8439-9ed74c122b06" + const raw = `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]` + const later = `[{"type":"input_image","image_url":"data:image/png;base64,AwQF"}]` + const want = `[{"byte_size":3,"media_type":"image/png","sha256":"","text":"[Image: image/png, 3 bytes]","type":"agentsview_image","version":1}]` + for _, threshold := range []int64{1, 1 << 30} { + t.Run(fmt.Sprint(threshold), func(t *testing.T) { + root := t.TempDir() + day := filepath.Join(root, "2024", "01", "01") + require.NoError(t, os.MkdirAll(day, 0o755)) + path := filepath.Join(day, "rollout-2024-01-01T10-00-00-"+uuid+".jsonl") + transcript := testjsonl.JoinJSONL( + testjsonl.CodexSessionMetaJSON(uuid, root, "user", "2024-01-01T10:00:00Z"), + testjsonl.CodexMsgJSON("user", "show image", "2024-01-01T10:00:01Z"), + testjsonl.CodexFunctionCallWithCallIDJSON("exec_command", "call", `{}`, "2024-01-01T10:00:02Z"), + testjsonl.CodexFunctionCallOutputJSON("call", json.RawMessage(raw), "2024-01-01T10:00:03Z"), + ) + require.NoError(t, os.WriteFile(path, []byte(transcript), 0o600)) + database := openTestDB(t) + database.SetToolResultImages(config.ToolResultImagesKeep) + engine := NewEngine(database, EngineConfig{Machine: "local", Ephemeral: true, + AgentDirs: map[parser.AgentType][]string{parser.AgentCodex: {root}}, + ToolResultImages: mode, AssetsDir: t.TempDir(), StagedCodexParseMinBytes: threshold, + DisableFilesystemProjectDiscovery: true, + }) + t.Cleanup(engine.Close) + stats := engine.SyncAll(t.Context(), nil) + require.Equal(t, 1, stats.Synced) + for _, late := range []bool{false, true} { + if late { + require.NoError(t, engine.writeIncremental(&incrementalUpdate{ + sessionID: "codex:" + uuid, machine: "local", project: "project", msgCount: 2, + toolCallUpdates: []parser.ParsedToolCallUpdate{{ToolUseID: "call", MessageOrdinal: 1, CallIndex: 0, ResultEvents: []parser.ParsedToolResultEvent{{ + ToolUseID: "call", Source: "function_call_output", Content: later, + }}}}, + })) + } + messages, err := database.GetAllMessages(t.Context(), "codex:"+uuid) + require.NoError(t, err) + var calls []db.ToolCall + for _, message := range messages { + calls = append(calls, message.ToolCalls...) + } + require.Len(t, calls, 1) + if mode == config.ToolResultImagesDrop { + assert.Equal(t, want, calls[0].ResultContent) + } else { + assert.Contains(t, calls[0].ResultContent, `"image_ref":"asset://`) + } + count := 1 + if late { + count = 2 + } + require.Len(t, calls[0].ResultEvents, count) + for _, event := range calls[0].ResultEvents { + if mode == config.ToolResultImagesDrop { + assert.Equal(t, want, event.Content) + } else { + assert.Contains(t, event.Content, `"image_ref":"asset://`) + } + assert.Equal(t, len(event.Content), event.ContentLength) + } + } + }) } + }) } } @@ -571,3 +592,69 @@ func TestCodexDropImagesNeverEnterScratch(t *testing.T) { require.NoError(t, err) assert.NotContains(t, string(bytes), "data:image/png;base64,AAEC") } + +func TestToolResultImagesOffloadFullIngest(t *testing.T) { + database := dbtest.OpenTestDB(t) + engine := NewEngine(database, EngineConfig{Machine: "local", ToolResultImages: config.ToolResultImages("offload"), AssetsDir: t.TempDir()}) + t.Cleanup(engine.Close) + outcome := engine.writeBatchBulkWithOutcome([]pendingWrite{{ + sess: parser.ParsedSession{ID: "offload-full", Project: "project", Machine: "local", Agent: parser.AgentCodex, StartedAt: time.Unix(1, 0)}, + msgs: []parser.ParsedMessage{{Ordinal: 0, Role: parser.RoleAssistant, Content: "answer", ToolCalls: []parser.ParsedToolCall{{ToolUseID: "image", ToolName: "Bash", Category: "Bash", ResultEvents: []parser.ParsedToolResultEvent{{ToolUseID: "image", Source: "tool", Status: "completed", Content: `[{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="}]`}}}}}}, + }}, false) + require.NotNil(t, outcome) + messages, err := database.GetAllMessages(context.Background(), "offload-full") + require.NoError(t, err) + require.Len(t, messages, 1) + require.Len(t, messages[0].ToolCalls, 1) + require.Len(t, messages[0].ToolCalls[0].ResultEvents, 1) + require.Contains(t, messages[0].ToolCalls[0].ResultEvents[0].Content, `"image_ref":"asset://`) + objects, err := os.ReadDir(database.AssetsDir()) + require.NoError(t, err) + require.Len(t, objects, 1) + body, err := os.ReadFile(filepath.Join(database.AssetsDir(), objects[0].Name())) + require.NoError(t, err) + require.Len(t, body, 68) + assert.Equal(t, []byte{137, 80, 78, 71, 13, 10, 26, 10}, body[:8]) +} + +func TestToolResultImagesStagedRoute(t *testing.T) { + for _, omitted := range []bool{false, true} { + for _, blocked := range []bool{false, true} { + t.Run(fmt.Sprintf("omitted=%t/blocked=%t", omitted, blocked), func(t *testing.T) { + database := dbtest.OpenTestDB(t) + database.SetAssetsDir(t.TempDir()) + if omitted { + database.SetArchiveContent(config.ArchiveContentTranscripts) + } + sink, err := newCodexStagingSink(t.TempDir(), map[string]bool{"Bash": blocked}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sink.Close()) }) + sink.database = database + sink.toolResultImages = config.ToolResultImagesOffload + sink.AppendMessage(parser.ParsedMessage{ToolCalls: []parser.ParsedToolCall{{ToolUseID: "call", Category: "Bash"}}}) + const raw = `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]` + sink.AppendToolResultEvent("call", nil, parser.ParsedToolResultEvent{ToolUseID: "call", Source: "function_call_output", Content: raw}) + require.NoError(t, sink.Err()) + var content string + var length int + require.NoError(t, sink.scratch.QueryRow("SELECT content, content_length FROM stage_events LIMIT 1").Scan(&content, &length)) + entries, err := os.ReadDir(database.AssetsDir()) + require.NoError(t, err) + if blocked || omitted { + assert.Empty(t, entries) + } else { + require.Len(t, entries, 1) + body, err := os.ReadFile(filepath.Join(database.AssetsDir(), entries[0].Name())) + require.NoError(t, err) + assert.Equal(t, []byte{0, 1, 2}, body) + assert.Contains(t, content, `"image_ref":"asset://`) + assert.Equal(t, len(content), length) + } + if blocked { + assert.Empty(t, content) + assert.Equal(t, len(raw), length) + } + }) + } + } +} From fea768548e66a7b2cdae639eee4ad003230e5d13 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 12:07:42 -0400 Subject: [PATCH 02/16] fix(sync): preserve image policy boundaries --- .../agentsview-PR-TARGET-1718-3-PROOF.md | 39 ------------------- docs/data.md | 2 +- docs/session-api.md | 2 +- .../settings/ArchiveContentSettings.svelte | 2 +- internal/db/tool_result_images.go | 8 ---- internal/server/export.go | 15 +------ internal/server/server_test.go | 16 ++++---- internal/sync/codex_staging.go | 3 +- internal/sync/tool_result_images_test.go | 27 +++++++++++++ 9 files changed, 40 insertions(+), 74 deletions(-) delete mode 100644 .claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md diff --git a/.claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md b/.claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md deleted file mode 100644 index 716cec4bc4..0000000000 --- a/.claude/pr-sweep/agentsview-PR-TARGET-1718-3-PROOF.md +++ /dev/null @@ -1,39 +0,0 @@ -# Offload ingestion proof - -Base is 6bef76f40bbf3cfb195c5e2c57629280a9dd841b. Head is the staged worktree. Go commands ran with CGO_ENABLED=1. Commands run from the repository root unless they are frontend npm commands, which run from frontend. No commit, push, PR, or comment was made. - -| Surface | Command run | Observed result | Base/head | -| --- | --- | --- | --- | -| reproduction, full ingest | `go test -overlay D:/Repos/.claude/pr-sweep/offload-base/overlay.json -tags "fts5,kit_posthog_disabled" ./internal/sync -run TestToolResultImagesOffloadFullIngest -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Base failed at the stored reference assertion, `--- FAIL: TestToolResultImagesOffloadFullIngest`. Head passed the full-ingest PNG and asset assertions in `go.kenn.io/agentsview/internal/sync` with `0.976s`. | Base: inline image remained on 6bef76f40. Head: stored reference and one 68-byte PNG asset. | -| dispatcher | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, `go.kenn.io/agentsview/internal/db` and `4.267s`. Tests cover keep, drop, offload, unknown policy, malformed and unsupported images, missing destination, and failed asset writes. | Head | -| DB routes | `go test -tags "fts5,kit_posthog_disabled" ./internal/config ./internal/db ./internal/sync -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Full DB package completed in `189.414s`. Final focused DB tests completed in `4.267s`, including insert, incremental, replacement, content replacement, batch, atomic batch, late results, and linked results. | Head | -| staged and resync routes | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, `go.kenn.io/agentsview/internal/sync` and `0.976s`. Staged events, copied orphan/trash sessions, read-only replacements, and fail-open stored-row rewrites passed. | Head | -| preservation and negative space | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, `go.kenn.io/agentsview/internal/db` and `4.267s`. Tests cover blocked lengths, omission, raw replay identity, keep/drop, malformed data, unsupported media, and inline fallback. | Head | -| contract-isolation | `rg -n 'ToolResultImagesDrop\|ToolResultImagesOffload\|ProjectToolResultImageContent\|ProjectToolResultImages' internal/db internal/sync -g '*.go' -g '!**/*_test.go'` | Audit found `db.ProjectToolResultImagesWithPolicy(msgs, policy)` and `ProjectToolResultImageContent`. Graph and staged writes use the shared dispatcher. The drop-only comparison in sole-event dedup preserves existing drop behavior; offload compares actual stored event bytes. | Head | -| settings and frontend | `go test -tags "fts5,kit_posthog_disabled" ./internal/server -run 'TestSettingsToolResultImagesRoundTrip\|TestNormalizedSessionExportsPreserveOffloadedImages' -count=1`; `npm test -- --run src/lib/components/settings/ArchiveContentSettings.test.ts`; `npm --prefix frontend run check`; `npm run i18n:compile` | Server exit 0 in `3.460s`. Component suite reported `Tests 6 passed (6)`. Type check reported `svelte-check found 0 errors and 0 warnings`. Locale command exited 0 and printed `--emit-ts-declarations --silent`. | Head | -| transport fidelity | `go test -tags "fts5,kit_posthog_disabled" ./internal/server -run 'TestSettingsToolResultImagesRoundTrip\|TestNormalizedSessionExportsPreserveOffloadedImages' -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./cmd/agentsview -run TestSessionExport -count=1` | Normalized export tests completed in `3.460s`; raw source export tests completed in `3.195s`. Both server endpoints retain placeholder/reference text. Existing backend evidence and its limits appear below. | Head | -| acceptance-1, durable offload | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, DB `4.267s`. TestToolResultImagesOffloadPublishesBeforeInsert uses a SQLite BEFORE INSERT trigger that rejects a reference unless the expected asset bytes already exist. | Head | -| acceptance-2, route coverage | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, DB `4.267s`, sync `0.976s`. Tests cover full, bulk, incremental, late, linked, staged, and copied-session routes. | Head | -| acceptance-3, preservation | `go test -tags "fts5,kit_posthog_disabled" ./internal/db ./internal/sync -run 'Test.*(ToolResultImages\|Offload\|ImageRetention\|ImagePolicy\|DropImages)' -count=1` | Exit 0, DB `4.267s`. Omitted late results do not offload older retained events. Removing that guard failed with `Should be empty, but was`. Linked summaries retain references when their sole older event remains inline. | Head | -| acceptance-4, transport | `go test -tags "fts5,kit_posthog_disabled" ./internal/server -run 'TestSettingsToolResultImagesRoundTrip\|TestNormalizedSessionExportsPreserveOffloadedImages' -count=1`; `go test -tags "fts5,kit_posthog_disabled" ./cmd/agentsview -run TestSessionExport -count=1` | Normalized server tests completed in `3.460s`; raw CLI export tests completed in `3.195s`. Raw cmd/agentsview/session_export.go remains unchanged. Backend coverage is bounded as recorded below. | Head | -| acceptance-5, settings and docs | `npm test -- --run src/lib/components/settings/ArchiveContentSettings.test.ts`; `npm --prefix frontend run check`; `npm run i18n:compile`; `git diff -- docs` | Component output includes `Tests 6 passed (6)`; type check reported `svelte-check found 0 errors and 0 warnings`. Documentation describes `tool_result_images = "offload"`, retry, backup, asset publication, remote limits, and raw source exports. | Head | - -## Bounded validation - -- `go test -tags "fts5,kit_posthog_disabled" ./internal/config ./internal/db ./internal/sync -count=1`: config passed in `20.295s`, DB passed in `189.414s`. Sync reached `panic: test timed out after 10m0s` while running TestScheduledOmnigentReconciliationIsBoundedByChangedMembers. It also reported `Condition never satisfied` in TestBulkCollectorReleasesFlushedParsedBatch/staged_false. These are limits of this package run, not a full-suite success. -- `go test -tags "fts5,kit_posthog_disabled" ./internal/sync -run 'TestBulkCollectorReleasesFlushedParsedBatch\|TestScheduledOmnigentReconciliationIsBoundedByChangedMembers' -count=1 -timeout=120s`: focused follow-up passed in `17.404s`. The base overlay run of TestBulkCollectorReleasesFlushedParsedBatch also failed with `flushed parsed batch remained live while the collector awaited more results`, establishing that the intermittent retention failure predates this implementation. -- `go test -tags "fts5,kit_posthog_disabled" ./internal/config -run TestToolResultImagesConfig -count=1`: passed in `0.227s`. The earlier worker recorded `Access is denied.` in unrelated Windows config file replacement tests. This turn's full config package completed successfully. -- `go test -tags "fts5,kit_posthog_disabled" ./internal/artifact -run 'TestCanonicalMessageSegmentGolden\|TestArtifactSyncTwoNodeFolderRoundTripAndReplay' -count=1`: passed in `75.289s`. `go test -tags "fts5,kit_posthog_disabled" ./internal/artifact -run TestCanonicalMessageSegmentGolden -count=1 -timeout=60s` passed in `0.123s`. These existing tests cover stored message/result serialization and folder exchange, not local asset distribution. -- `go test -tags "fts5,kit_posthog_disabled" ./internal/duckdb -run TestPushIncrementalReplacesOnlyChangedSessions -count=1`: passed in `3.147s`. The existing fixture carries tool-result text through mirror push. -- `go test -tags "fts5,kit_posthog_disabled,pgtest" ./internal/postgres -run TestPushWithToolResultEvents -count=1 -v`: `TEST_PG_URL not set; skipping PG tests`. No PostgreSQL or CockroachDB runtime round trip is claimed. Source review found unchanged string transport through bulkInsertToolResultEvents, sanitizePG, result-content scans, and RestoreMessageResultContent. Local asset resolution remains unavailable on those remote backends. -- `go fmt ./...`: exit 0. `go vet ./...` reported `possible misuse of unsafe.Pointer` in three existing internal/rawcapture Windows locations. `go vet ./internal/config ./internal/db ./internal/sync ./internal/server ./cmd/agentsview`: exit 0, no diagnostics. -- `make lint-ci`: failed with `5 issues:` in unchanged internal/capture Windows files, four staticcheck capitalization findings and one unused function. No allowlisted implementation finding appeared. The make target stopped before NilAway. -- `git diff --check`: exit 0, no whitespace errors. - -## Reproduction and cleanup - -The base overlay substitutes HEAD versions of changed production Go files without changing branches or the worktree. It adds the full-ingest regression to the base sync test file and omits only the unavailable EngineConfig.AssetsDir field. The base fails on retained inline content, not compilation. Head also checks the published PNG bytes. A separate omission overlay removes only the new archive-omission guard; its failure proves the no-asset-write regression detects that bug. - -The existing API generator completed through npm check using an external vp.exe launcher and OFFLOAD_VP_CLI pointing to the installed Vite+ entry point. Only the two allowlisted generated settings enum files differ. Temporary generator files and offload logs moved outside the repository worktree. The repository node_modules contains no offload scratch files. - -All 13 matrix rows have commands, verbatim observations, and Base or Head evidence. PostgreSQL/CockroachDB runtime coverage, the full sync package timeout, broad vet, and broad lint remain bounded as stated above. diff --git a/docs/data.md b/docs/data.md index a5a1d53f18..bd0b93b1e0 100644 --- a/docs/data.md +++ b/docs/data.md @@ -143,4 +143,4 @@ Set `tool_result_images = "offload"` to move supported inline tool-result PNG, J If an asset write fails, ingestion and copied-session resync keep the original inline content. Retry retained inline payloads with `agentsview db migrate --images` after restoring access to the asset directory. Complete unreferenced objects from a failed database transaction remain available for reuse; the store does not automatically remove them. Back up the asset directory with the archive and copy both to another local serving host. -PostgreSQL and CockroachDB preserve the placeholder and reference text but cannot resolve the local asset. DuckDB, artifact exports, and the normalized server session exports at `/api/v1/sessions/{id}/export` and `/api/v1/sessions/{id}/md` carry the stored content. The raw `agentsview session export` command streams provider source bytes, so its output retains the original inline payloads. +PostgreSQL and CockroachDB preserve the placeholder and reference text but cannot resolve the local asset. DuckDB, artifact exports, and the normalized Markdown server session export at `/api/v1/sessions/{id}/md` carry the stored content. The HTML export keeps its existing contract. The raw `agentsview session export` command streams provider source bytes, so its output retains the original inline payloads. diff --git a/docs/session-api.md b/docs/session-api.md index 4c18d52850..eefa648d6d 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -1108,4 +1108,4 @@ against `pg serve`, with the same masking and `--reveal` constraints. ### Ingest-time image offload -The normalized `/api/v1/sessions/{id}/export` and `/api/v1/sessions/{id}/md` endpoints carry stored tool-result content, including `agentsview_image` placeholders and `image_ref` asset references. A serving host needs the matching `{dataDir}/assets` directory. PostgreSQL and CockroachDB retain the reference text but cannot resolve local assets. The raw `agentsview session export` command streams provider source bytes and retains their original inline payloads. +The normalized Markdown endpoint `/api/v1/sessions/{id}/md` carries stored tool-result content, including `agentsview_image` placeholders and `image_ref` asset references. The HTML export keeps its existing contract. A serving host needs the matching `{dataDir}/assets` directory. PostgreSQL and CockroachDB retain the reference text but cannot resolve local assets. The raw `agentsview session export` command streams provider source bytes and retains their original inline payloads. diff --git a/frontend/src/lib/components/settings/ArchiveContentSettings.svelte b/frontend/src/lib/components/settings/ArchiveContentSettings.svelte index 8d9865a4e8..5ab30f6a1c 100644 --- a/frontend/src/lib/components/settings/ArchiveContentSettings.svelte +++ b/frontend/src/lib/components/settings/ArchiveContentSettings.svelte @@ -10,7 +10,7 @@ $derived([ { value: "keep", label: m.settings_tool_result_images_keep() }, { value: "drop", label: m.settings_tool_result_images_drop() }, - { value: "offload", label: m.settings_tool_result_images_offload() }, + { value: "offload", label: m.settings_tool_result_images_offload() }, ]); let restartRequired = $state(false); diff --git a/internal/db/tool_result_images.go b/internal/db/tool_result_images.go index 2b97428935..4b6d27fb15 100644 --- a/internal/db/tool_result_images.go +++ b/internal/db/tool_result_images.go @@ -390,14 +390,6 @@ func (w *countingWriter) Write(p []byte) (int, error) { return len(p), nil } -// ProjectToolResultImages applies the configured policy to a message graph. -// Projection copies the graph and its nested tool-result slices before editing. -func ProjectToolResultImages( - messages []Message, policy config.ToolResultImages, -) ([]Message, ToolImageStats) { - return projectToolResultImages(messages, policy, "") -} - func projectToolResultImages( messages []Message, policy config.ToolResultImages, assetsDir string, ) ([]Message, ToolImageStats) { diff --git a/internal/server/export.go b/internal/server/export.go index 2266c9775c..9e957b4620 100644 --- a/internal/server/export.go +++ b/internal/server/export.go @@ -183,7 +183,6 @@ type exportMessage struct { Role string Timestamp string ContentHTML template.HTML - ToolResults []string FocusedHidden bool } @@ -456,7 +455,7 @@ footer a:hover { text-decoration: underline; }
{{- range .Messages}} -
{{.Role}}{{.Timestamp}}
{{.ContentHTML}}{{range .ToolResults}}
{{.}}
{{end}}
+
{{.Role}}{{.Timestamp}}
{{.ContentHTML}}
{{- end}}
@@ -655,20 +654,8 @@ func generateExportHTML( extraClass = " thinking-only" } - var results []string - for _, call := range m.ToolCalls { - if call.ResultContent != "" { - results = append(results, call.ResultContent) - } - for _, event := range call.ResultEvents { - if event.Content != "" && event.Content != call.ResultContent { - results = append(results, event.Content) - } - } - } data.Messages[i] = exportMessage{ Ordinal: m.Ordinal, - ToolResults: results, RoleClass: roleClass, ExtraClass: extraClass, Role: m.Role, diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e25b638abc..a2ff1233a4 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -5709,18 +5709,16 @@ func TestSettingsAgentHomesPersistAndRoundTrip(t *testing.T) { assert.Equal(t, []string{"/sessions/pi"}, persisted.Agents["pi"].Dirs) } -func TestNormalizedSessionExportsPreserveOffloadedImages(t *testing.T) { +func TestMarkdownSessionExportPreservesOffloadedImages(t *testing.T) { te := setup(t) te.db.SetToolResultImages(config.ToolResultImagesOffload) te.db.SetAssetsDir(t.TempDir()) te.seedSession(t, "image-export", "project", 1) require.NoError(t, te.db.InsertMessages([]db.Message{{SessionID: "image-export", Role: "assistant", Content: "image result", ToolCalls: []db.ToolCall{{ToolUseID: "call", ToolName: "Read", Category: "Read", ResultContent: `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]`}}}})) - for _, endpoint := range []string{"export", "md"} { - w := te.get(t, "/api/v1/sessions/image-export/"+endpoint) - require.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), "image_ref") - assert.Contains(t, w.Body.String(), "asset://") - assert.Contains(t, w.Body.String(), "agentsview_image") - assert.NotContains(t, w.Body.String(), "base64,AAEC") - } + w := te.get(t, "/api/v1/sessions/image-export/md") + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "image_ref") + assert.Contains(t, w.Body.String(), "asset://") + assert.Contains(t, w.Body.String(), "agentsview_image") + assert.NotContains(t, w.Body.String(), "base64,AAEC") } diff --git a/internal/sync/codex_staging.go b/internal/sync/codex_staging.go index 92857d390e..0c78d0b91f 100644 --- a/internal/sync/codex_staging.go +++ b/internal/sync/codex_staging.go @@ -541,7 +541,8 @@ func (s *codexStagingSink) AppendToolResultEvent( // contract before the real content enters the scratch publish source. // Keep dedup above this point raw: two provider events that differ // only by stripped controls remain two events on the collecting path. - if s.database == nil || !s.database.ArchiveContent().OmitsToolContent() { + if s.toolResultImages != config.ToolResultImagesOffload || + s.database == nil || !s.database.ArchiveContent().OmitsToolContent() { assetsDir := "" if s.database != nil { assetsDir = s.database.AssetsDir() diff --git a/internal/sync/tool_result_images_test.go b/internal/sync/tool_result_images_test.go index 7d5f9f01b6..3a0b787460 100644 --- a/internal/sync/tool_result_images_test.go +++ b/internal/sync/tool_result_images_test.go @@ -593,6 +593,33 @@ func TestCodexDropImagesNeverEnterScratch(t *testing.T) { assert.NotContains(t, string(bytes), "data:image/png;base64,AAEC") } +func TestCodexDropImagesNeverEnterScratchWhenToolContentOmitted(t *testing.T) { + database := dbtest.OpenTestDB(t) + database.SetArchiveContent(config.ArchiveContentTranscripts) + sink, err := newCodexStagingSink(t.TempDir(), nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sink.Close()) }) + sink.database = database + sink.toolResultImages = config.ToolResultImagesDrop + sink.AppendMessage(parser.ParsedMessage{ToolCalls: []parser.ParsedToolCall{{ToolUseID: "call", Category: "Bash"}}}) + const raw = `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]` + const want = `[{"byte_size":3,"media_type":"image/png","sha256":"","text":"[Image: image/png, 3 bytes]","type":"agentsview_image","version":1}]` + sink.AppendToolResultEvent("call", nil, parser.ParsedToolResultEvent{ + ToolUseID: "call", Source: "function_call_output", Content: raw, + }) + require.NoError(t, sink.Err()) + var content string + var contentLength int + require.NoError(t, sink.scratch.QueryRow( + "SELECT content, content_length FROM stage_events LIMIT 1", + ).Scan(&content, &contentLength)) + assert.Equal(t, want, content) + assert.Equal(t, len(want), contentLength) + bytes, err := os.ReadFile(sink.Path()) + require.NoError(t, err) + assert.NotContains(t, string(bytes), "data:image/png;base64,AAEC") +} + func TestToolResultImagesOffloadFullIngest(t *testing.T) { database := dbtest.OpenTestDB(t) engine := NewEngine(database, EngineConfig{Machine: "local", ToolResultImages: config.ToolResultImages("offload"), AssetsDir: t.TempDir()}) From 25b1cb881794a5c4dd80d59539be96e5389ff4d7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 12:56:52 -0400 Subject: [PATCH 03/16] docs: point image offload to migration --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index bc2c22de5f..c3198f3a6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -195,7 +195,7 @@ zoom_level = 120 | `cursor_admin_user_id` | Optional default Cursor Admin usage filter by member user ID | | `github_token` | Optional saved GitHub token for Gist publishing | | `result_content_blocked_categories` | Tool categories whose result content is not stored (default: `["Read", "Glob"]`). Changes apply to new ingestion and full rebuilds; see [storage maintenance](/docs/data/#storage-maintenance) for existing source-backed sessions. | -| `tool_result_images` | Retain supported inline tool-result image blocks with `"keep"` (default), or store readable `agentsview_image` placeholders with `"drop"`, or move supported images to the local asset store with `"offload"`. The setting affects future ingestion and full resyncs; run `db strip --images` for existing rows; also configurable under **Settings > Archive content**, and changes require a daemon restart. | +| `tool_result_images` | Retain supported inline tool-result image blocks with `"keep"` (default), or store readable `agentsview_image` placeholders with `"drop"`, or move supported images to the local asset store with `"offload"`. The setting affects future ingestion and full resyncs; run `db migrate --images` for existing rows; also configurable under **Settings > Archive content**, and changes require a daemon restart. | | `archive_content` | How much of each session the archive stores: `"full"` (default), `"transcripts"`, or `"usage"`; changes require a daemon restart — see [Archive content](#archive-content) | | `host` | Interface the server binds to (default `127.0.0.1`); non-loopback values require `require_auth = true` | | `require_auth` | Require bearer-token authentication for API access | From 0ab883d2ec58d5b76364539528f35764b2d331be Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 13:46:00 -0400 Subject: [PATCH 04/16] fix(sync): close image offload projection gaps --- docs/data.md | 1 - frontend/messages/en.json | 2 +- frontend/messages/fr.json | 2 +- frontend/messages/ja.json | 2 +- frontend/messages/ko.json | 2 +- frontend/messages/zh-CN.json | 2 +- frontend/messages/zh-TW.json | 2 +- internal/sync/codex_staging.go | 16 +++++++++------- internal/sync/tool_result_images_test.go | 8 ++++++++ 9 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/data.md b/docs/data.md index bd0b93b1e0..f0b65dce42 100644 --- a/docs/data.md +++ b/docs/data.md @@ -136,7 +136,6 @@ filtering or a future compression implementation. See [`agentsview db compact`](/docs/commands/#agentsview-db-compact) for the staging space model and interrupted-compaction recovery. - ### Ingest-time image offload Set `tool_result_images = "offload"` to move supported inline tool-result PNG, JPEG, WebP, and GIF payloads into `{dataDir}/assets/` during ingestion. Restart the daemon after changing the setting. Each asset write completes before SQLite can commit its `agentsview_image` placeholder and `image_ref`. Unsupported media and malformed data remain inline. Archives that omit tool content write no image assets. `keep` retains inline content; `drop` retains the existing readable placeholder without an asset. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3cb6d7641f..f9e9b8ba91 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2061,7 +2061,7 @@ "settings_tool_result_images_keep": "Keep", "settings_tool_result_images_drop": "Drop", "settings_tool_result_images_offload": "Offload", - "settings_tool_result_images_hint": "Stored results keep the policy they were written under, and switching back to Keep cannot restore payloads already removed. Run agentsview db strip --images to project existing rows.", + "settings_tool_result_images_hint": "Stored results keep the policy they were written under, and switching back to Keep cannot restore payloads already removed. Run agentsview db migrate --images to project existing rows.", "settings_tool_result_images_restart_notice": "Restart the AgentsView daemon to apply this change to newly ingested sessions.", "settings_tool_images_title": "Tool-result images", "settings_tool_images_description": "Inline images stored inside tool results.", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index bee0033867..9284350f8e 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -2060,7 +2060,7 @@ "settings_tool_result_images_keep": "Conserver", "settings_tool_result_images_drop": "Supprimer", "settings_tool_result_images_offload": "Stocker séparément", - "settings_tool_result_images_hint": "Les résultats déjà stockés gardent la règle appliquée lors de leur écriture, et revenir à Conserver ne restaure pas les données déjà supprimées. Lancez agentsview db strip --images pour traiter les lignes existantes.", + "settings_tool_result_images_hint": "Les résultats déjà stockés gardent la règle appliquée lors de leur écriture, et revenir à Conserver ne restaure pas les données déjà supprimées. Lancez agentsview db migrate --images pour traiter les lignes existantes.", "settings_tool_result_images_restart_notice": "Redémarrez le démon AgentsView pour appliquer ce changement aux nouvelles sessions ingérées.", "settings_tool_images_title": "Images des résultats d'outils", "settings_tool_images_description": "Images intégrées stockées dans les résultats d'outils.", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 150d73ec07..8f6262592e 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -2061,7 +2061,7 @@ "settings_tool_result_images_keep": "保持", "settings_tool_result_images_drop": "破棄", "settings_tool_result_images_offload": "外部保存", - "settings_tool_result_images_hint": "保存済みの結果は書き込み時のポリシーを保ち、保持に戻しても削除済みのデータは復元できません。既存の行は agentsview db strip --images で処理してください。", + "settings_tool_result_images_hint": "保存済みの結果は書き込み時のポリシーを保ち、保持に戻しても削除済みのデータは復元できません。既存の行は agentsview db migrate --images で処理してください。", "settings_tool_result_images_restart_notice": "この変更を新しく取り込むセッションに適用するには、AgentsView デーモンを再起動してください。", "settings_tool_images_title": "ツール結果の画像", "settings_tool_images_description": "ツール結果内に保存されたインライン画像。", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index c5d105ef5d..280c2f9a05 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -1996,7 +1996,7 @@ "settings_tool_result_images_keep": "유지", "settings_tool_result_images_drop": "삭제", "settings_tool_result_images_offload": "별도 저장", - "settings_tool_result_images_hint": "이미 저장된 결과는 기록될 당시의 정책을 유지하며, 유지로 되돌려도 이미 삭제된 데이터는 복원되지 않습니다. 기존 행은 agentsview db strip --images 로 처리합니다.", + "settings_tool_result_images_hint": "이미 저장된 결과는 기록될 당시의 정책을 유지하며, 유지로 되돌려도 이미 삭제된 데이터는 복원되지 않습니다. 기존 행은 agentsview db migrate --images 로 처리합니다.", "settings_tool_result_images_restart_notice": "이 변경을 새로 수집되는 세션에 적용하려면 AgentsView 데몬을 다시 시작하세요.", "settings_tool_images_title": "도구 결과 이미지", "settings_tool_images_description": "도구 결과 내에 저장된 인라인 이미지.", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 23228880eb..cf2a28601f 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -1996,7 +1996,7 @@ "settings_tool_result_images_keep": "保留", "settings_tool_result_images_drop": "丢弃", "settings_tool_result_images_offload": "单独存储", - "settings_tool_result_images_hint": "已存储的结果保持写入时的策略,改回「保留」也无法恢复已删除的数据。运行 agentsview db strip --images 可处理已有记录。", + "settings_tool_result_images_hint": "已存储的结果保持写入时的策略,改回「保留」也无法恢复已删除的数据。运行 agentsview db migrate --images 可处理已有记录。", "settings_tool_result_images_restart_notice": "重启 AgentsView 守护进程后,此更改才会应用到新采集的会话。", "settings_tool_images_title": "工具结果图像", "settings_tool_images_description": "存储在工具结果中的内联图像。", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index e6f2381bdf..354a0f23eb 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -1996,7 +1996,7 @@ "settings_tool_result_images_keep": "保留", "settings_tool_result_images_drop": "捨棄", "settings_tool_result_images_offload": "另存圖片", - "settings_tool_result_images_hint": "已儲存的結果維持寫入時的政策,改回「保留」也無法還原已刪除的資料。執行 agentsview db strip --images 可處理既有記錄。", + "settings_tool_result_images_hint": "已儲存的結果維持寫入時的政策,改回「保留」也無法還原已刪除的資料。執行 agentsview db migrate --images 可處理既有記錄。", "settings_tool_result_images_restart_notice": "重新啟動 AgentsView 常駐程式後,此變更才會套用到新擷取的對話。", "settings_tool_images_title": "工具結果圖像", "settings_tool_images_description": "儲存在工具結果中的內嵌圖像。", diff --git a/internal/sync/codex_staging.go b/internal/sync/codex_staging.go index 0c78d0b91f..e647a084cb 100644 --- a/internal/sync/codex_staging.go +++ b/internal/sync/codex_staging.go @@ -541,15 +541,17 @@ func (s *codexStagingSink) AppendToolResultEvent( // contract before the real content enters the scratch publish source. // Keep dedup above this point raw: two provider events that differ // only by stripped controls remain two events on the collecting path. - if s.toolResultImages != config.ToolResultImagesOffload || - s.database == nil || !s.database.ArchiveContent().OmitsToolContent() { - assetsDir := "" - if s.database != nil { - assetsDir = s.database.AssetsDir() + imagePolicy := s.toolResultImages + assetsDir := "" + if s.database != nil { + assetsDir = s.database.AssetsDir() + if imagePolicy == config.ToolResultImagesOffload && + s.database.ArchiveContent().OmitsToolContent() { + imagePolicy = config.ToolResultImagesDrop } - ev.Content = db.ProjectToolResultImageContent(ev.Content, s.toolResultImages, assetsDir) - contentLength = len(ev.Content) } + ev.Content = db.ProjectToolResultImageContent(ev.Content, imagePolicy, assetsDir) + contentLength = len(ev.Content) toolCall := db.ToolCall{ResultEvents: []db.ToolResultEvent{{ Content: ev.Content, ContentLength: contentLength, diff --git a/internal/sync/tool_result_images_test.go b/internal/sync/tool_result_images_test.go index 3a0b787460..b83f538659 100644 --- a/internal/sync/tool_result_images_test.go +++ b/internal/sync/tool_result_images_test.go @@ -677,6 +677,14 @@ func TestToolResultImagesStagedRoute(t *testing.T) { assert.Contains(t, content, `"image_ref":"asset://`) assert.Equal(t, len(content), length) } + if omitted { + assert.NotContains(t, content, "data:image/png;base64,AAEC") + if !blocked { + assert.Equal(t, len(content), length) + assert.Contains(t, content, "agentsview_image") + assert.NotContains(t, content, `"image_ref":"asset://`) + } + } if blocked { assert.Empty(t, content) assert.Equal(t, len(raw), length) From d6276060a7027f3361ced59b50d885464cacea7f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 18:14:03 -0400 Subject: [PATCH 05/16] fix(sync): project artifact imports through image policy --- internal/db/artifact_import_session.go | 1 + internal/db/artifact_import_test.go | 35 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/db/artifact_import_session.go b/internal/db/artifact_import_session.go index aa68f6f545..61f62c8466 100644 --- a/internal/db/artifact_import_session.go +++ b/internal/db/artifact_import_session.go @@ -73,6 +73,7 @@ func (db *DB) applyArtifactImportedSession( ) } write = sanitizeSessionBatchWrite(write) + write.Messages = db.projectSessionBatchMessages(write) write.Session, write.Messages = db.sessionAndMessagesForStorage( write.Session, write.Messages, ) diff --git a/internal/db/artifact_import_test.go b/internal/db/artifact_import_test.go index 038f929e79..edd36709d0 100644 --- a/internal/db/artifact_import_test.go +++ b/internal/db/artifact_import_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/config" ) func artifactImportTestWork(origin string, sequence int) ArtifactImportWork { @@ -604,6 +605,40 @@ func TestApplyArtifactImportedSessionPreservesLocalCollision(t *testing.T) { assert.Equal(t, map[string]string{gid: imported.ManifestHash}, provenance) } +func TestApplyArtifactImportedSessionProjectsToolResultImages(t *testing.T) { + database := testDB(t) + database.SetToolResultImages(config.ToolResultImagesOffload) + database.SetAssetsDir(t.TempDir()) + ctx := t.Context() + origin := "peer-a1b2c3" + gid := origin + "~image" + imported := ArtifactImportedSession{ + Origin: origin, + GID: gid, + ManifestHash: strings.Repeat("a", 64), + ImportedSessionID: gid, + } + write := SessionBatchWrite{ + Session: Session{ + ID: gid, Project: "project", Machine: origin, Agent: "codex", + }, + Messages: []Message{testImageMessage(gid)}, + ReplaceMessages: true, + } + + result, err := database.ApplyArtifactImportedSession(ctx, imported, write) + require.NoError(t, err) + require.True(t, result.Written) + + messages, err := database.GetAllMessages(ctx, gid) + require.NoError(t, err) + require.Len(t, messages, 1) + call := messages[0].ToolCalls[0] + assertOffloadedImage(t, call.ResultContent, database.AssetsDir()) + require.Len(t, call.ResultEvents, 1) + assertOffloadedImage(t, call.ResultEvents[0].Content, database.AssetsDir()) +} + func TestArtifactImportedManifestHashesChunksWithinSQLiteVariableLimit( t *testing.T, ) { From 4b4ab7c3b9d04d9ce10f0ffaaf3be62aa824b152 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 18:57:03 -0400 Subject: [PATCH 06/16] fix: preserve empty message slice for archive policy --- internal/db/tool_result_images.go | 3 +++ internal/db/tool_result_images_test.go | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/db/tool_result_images.go b/internal/db/tool_result_images.go index 4b6d27fb15..abed4e0fc9 100644 --- a/internal/db/tool_result_images.go +++ b/internal/db/tool_result_images.go @@ -504,6 +504,9 @@ func (db *DB) ProjectToolResultImages(messages []Message) ([]Message, ToolImageS // ProjectToolResultImagesWithPolicy applies a run policy only to retained tool content. func (db *DB) ProjectToolResultImagesWithPolicy(messages []Message, policy config.ToolResultImages) ([]Message, ToolImageStats) { if db.ArchiveContent().OmitsToolContent() { + if messages == nil { + return []Message{}, ToolImageStats{} + } return messages, ToolImageStats{} } return projectToolResultImages(messages, policy, db.AssetsDir()) diff --git a/internal/db/tool_result_images_test.go b/internal/db/tool_result_images_test.go index dd668c7f76..584c08c065 100644 --- a/internal/db/tool_result_images_test.go +++ b/internal/db/tool_result_images_test.go @@ -132,6 +132,19 @@ func TestDBPolicyZeroValue(t *testing.T) { assert.Equal(t, config.ToolResultImagesDrop, d.ToolResultImages()) } +func TestProjectToolResultImagesNormalizesNilForOmittedArchives(t *testing.T) { + d := testDB(t) + d.SetArchiveContent(config.ArchiveContentTranscripts) + + projected, stats := d.ProjectToolResultImagesWithPolicy( + nil, config.ToolResultImagesKeep, + ) + + assert.NotNil(t, projected) + assert.Empty(t, projected) + assert.Zero(t, stats) +} + func TestIngestWithDropRemovesInlineImagesFromBothTables(t *testing.T) { d := testDB(t) d.SetToolResultImages(config.ToolResultImagesDrop) From 20a09cb07ce3d80a07663b1a2d67bb0282d86429 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 19:38:25 -0400 Subject: [PATCH 07/16] fix: keep image policy projections side effect free --- internal/assets/assets.go | 29 +++-- internal/db/tool_result_images.go | 112 ++++++++++++++++++-- internal/db/tool_result_images_test.go | 26 +++++ internal/sync/engine.go | 17 ++- internal/sync/parsediff_integration_test.go | 43 ++++++++ 5 files changed, 210 insertions(+), 17 deletions(-) diff --git a/internal/assets/assets.go b/internal/assets/assets.go index ce39e08d0f..f4872f4bb6 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -35,23 +35,34 @@ func ExtForMediaType(mediaType string) (string, bool) { return ext, ok } +// Reference returns the content-addressed asset:// reference for body. +// mediaType must name a passive image format. +func Reference(mediaType string, body []byte) (string, error) { + ext, ok := mediaTypeToExt[mediaType] + if !ok { + return "", fmt.Errorf("unsupported asset type: %s", mediaType) + } + + sum := sha256.Sum256(body) + hash := fmt.Sprintf("%x", sum[:]) + return "asset://" + hash + ext, nil +} + // Put writes body to the assets directory under its SHA-256 hash and returns // the asset:// reference. created is false when a complete object already // existed; repairing a partial one reports true. mediaType must name a passive // image format. func Put(assetsDir, mediaType string, body []byte) (ref string, created bool, err error) { - ext, ok := mediaTypeToExt[mediaType] - if !ok { - return "", false, fmt.Errorf("unsupported asset type: %s", mediaType) + ref, err = Reference(mediaType, body) + if err != nil { + return "", false, err } - sum := sha256.Sum256(body) - hash := fmt.Sprintf("%x", sum[:]) - filename := hash + ext + filename := strings.TrimPrefix(ref, "asset://") destPath := filepath.Join(assetsDir, filename) - if isCompleteObject(destPath, int64(len(body)), hash) { - return "asset://" + filename, false, nil + if isCompleteObject(destPath, int64(len(body)), strings.TrimSuffix(filename, filepath.Ext(filename))) { + return ref, false, nil } if err := writeObject(assetsDir, destPath, func(out *os.File) error { @@ -61,7 +72,7 @@ func Put(assetsDir, mediaType string, body []byte) (ref string, created bool, er return "", false, err } - return "asset://" + filename, true, nil + return ref, true, nil } // isCompleteObject reports whether the content-addressed path already holds a diff --git a/internal/db/tool_result_images.go b/internal/db/tool_result_images.go index abed4e0fc9..e12c295c2d 100644 --- a/internal/db/tool_result_images.go +++ b/internal/db/tool_result_images.go @@ -49,8 +49,20 @@ func stripToolResultImageArray(content string) (string, ToolImageStats) { changed := false for i, raw := range blocks { var block toolImageBlock - if err := json.Unmarshal(raw, &block); err != nil || - block.Type != "input_image" { + if err := json.Unmarshal(raw, &block); err != nil { + continue + } + if block.Type == "agentsview_image" { + placeholder, ok := stripOffloadedImagePlaceholder(raw) + if !ok { + continue + } + projected[i] = placeholder + changed = true + stats.Payloads++ + continue + } + if block.Type != "input_image" { continue } var fields map[string]json.RawMessage @@ -111,6 +123,43 @@ func stripToolResultImageArray(content string) (string, ToolImageStats) { return result.String(), stats } +func stripOffloadedImagePlaceholder(raw json.RawMessage) (json.RawMessage, bool) { + var placeholder struct { + Type string `json:"type"` + ImageRef string `json:"image_ref"` + MediaType string `json:"media_type"` + ByteSize int64 `json:"byte_size"` + } + if err := json.Unmarshal(raw, &placeholder); err != nil || + placeholder.Type != "agentsview_image" || + !strings.HasPrefix(placeholder.ImageRef, "asset://") || + placeholder.MediaType == "" || placeholder.ByteSize < 0 { + return nil, false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { + return nil, false + } + for key := range fields { + if strings.EqualFold(key, "image_ref") || + strings.EqualFold(key, "text") { + delete(fields, key) + } + } + textValue, err := json.Marshal(fmt.Sprintf( + "[Image: %s, %d bytes]", placeholder.MediaType, placeholder.ByteSize, + )) + if err != nil { + return nil, false + } + fields["text"] = textValue + projected, err := json.Marshal(fields) + if err != nil { + return nil, false + } + return projected, true +} + // scanSummarySections walks a labeled or anonymous tool-result summary and // calls fn once per section that holds a JSON array, passing the offsets the // array occupies in content and its raw bytes. Preview, strip and migrate all @@ -392,6 +441,30 @@ func (w *countingWriter) Write(p []byte) (int, error) { func projectToolResultImages( messages []Message, policy config.ToolResultImages, assetsDir string, +) ([]Message, ToolImageStats) { + return projectToolResultImagesWithPut( + messages, policy, assetsDir, + func(mediaType string, body []byte) (string, bool, error) { + return assets.Put(assetsDir, mediaType, body) + }, + ) +} + +func projectToolResultImagesWithoutWriting( + messages []Message, policy config.ToolResultImages, assetsDir string, +) ([]Message, ToolImageStats) { + return projectToolResultImagesWithPut( + messages, policy, assetsDir, + func(mediaType string, body []byte) (string, bool, error) { + ref, err := assets.Reference(mediaType, body) + return ref, false, err + }, + ) +} + +func projectToolResultImagesWithPut( + messages []Message, policy config.ToolResultImages, assetsDir string, + put imagePutFunc, ) ([]Message, ToolImageStats) { if (policy != config.ToolResultImagesDrop && policy != config.ToolResultImagesOffload) || len(messages) == 0 { if messages == nil { @@ -403,7 +476,7 @@ func projectToolResultImages( if policy == config.ToolResultImagesDrop { return projectToolResultText(content, length, stats) } - return ProjectToolResultImageContent(content, policy, assetsDir), stats + return projectToolResultImageContentWithPut(content, policy, assetsDir, put), stats } projected := make([]Message, len(messages)) copy(projected, messages) @@ -450,6 +523,20 @@ func projectToolResultText( // ProjectToolResultImageContent publishes supported assets before returning reference text. func ProjectToolResultImageContent(content string, policy config.ToolResultImages, assetsDir string) string { + return projectToolResultImageContentWithPut( + content, policy, assetsDir, + func(mediaType string, body []byte) (string, bool, error) { + return assets.Put(assetsDir, mediaType, body) + }, + ) +} + +func projectToolResultImageContentWithPut( + content string, + policy config.ToolResultImages, + assetsDir string, + put imagePutFunc, +) string { switch policy { case config.ToolResultImagesDrop: projected, _ := StripToolResultImages(content) @@ -458,9 +545,7 @@ func ProjectToolResultImageContent(content string, policy config.ToolResultImage if assetsDir == "" { return content } - projected, err := migrateToolResultImages(content, func(mediaType string, body []byte) (string, bool, error) { - return assets.Put(assetsDir, mediaType, body) - }) + projected, err := migrateToolResultImages(content, put) if err != nil { log.Printf("offloading tool-result images: %v", err) return content @@ -511,3 +596,18 @@ func (db *DB) ProjectToolResultImagesWithPolicy(messages []Message, policy confi } return projectToolResultImages(messages, policy, db.AssetsDir()) } + +// ProjectToolResultImagesForComparison applies the policy without publishing +// assets. Content-addressed references still match stored offload rows, so +// report-only parse-diff runs can compare normalized content without writes. +func (db *DB) ProjectToolResultImagesForComparison( + messages []Message, policy config.ToolResultImages, +) ([]Message, ToolImageStats) { + if db.ArchiveContent().OmitsToolContent() { + if messages == nil { + return []Message{}, ToolImageStats{} + } + return messages, ToolImageStats{} + } + return projectToolResultImagesWithoutWriting(messages, policy, db.AssetsDir()) +} diff --git a/internal/db/tool_result_images_test.go b/internal/db/tool_result_images_test.go index 584c08c065..906d71a807 100644 --- a/internal/db/tool_result_images_test.go +++ b/internal/db/tool_result_images_test.go @@ -119,6 +119,17 @@ func TestStripToolResultImagesNegativeSpace(t *testing.T) { } } +func TestStripToolResultImagesRemovesOffloadReference(t *testing.T) { + content := `[{"byte_size":3,"image_ref":"asset://abc.png","media_type":"image/png","sha256":"abc","text":"![Image: image/png, 3 bytes](asset://abc.png)","type":"agentsview_image","version":1}]` + + got, stats := StripToolResultImages(content) + + assert.Equal(t, int64(1), stats.Payloads) + assert.Contains(t, got, `"text":"[Image: image/png, 3 bytes]"`) + assert.NotContains(t, got, "image_ref") + assert.NotContains(t, got, "asset://") +} + func TestDBPolicyZeroValue(t *testing.T) { d := testDB(t) assert.Equal(t, config.ToolResultImagesKeep, d.ToolResultImages()) @@ -132,6 +143,21 @@ func TestDBPolicyZeroValue(t *testing.T) { assert.Equal(t, config.ToolResultImagesDrop, d.ToolResultImages()) } +func TestProjectToolResultImagesForComparisonDoesNotWriteAssets(t *testing.T) { + d := testDB(t) + d.SetAssetsDir(t.TempDir()) + + projected, _ := d.ProjectToolResultImagesForComparison( + []Message{testImageMessage("comparison")}, + config.ToolResultImagesOffload, + ) + + assert.Contains(t, projected[0].ToolCalls[0].ResultContent, `"image_ref":"asset://`) + entries, err := os.ReadDir(d.AssetsDir()) + require.NoError(t, err) + assert.Empty(t, entries) +} + func TestProjectToolResultImagesNormalizesNilForOmittedArchives(t *testing.T) { d := testDB(t) d.SetArchiveContent(config.ArchiveContentTranscripts) diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 5cf77c5ad0..5aa4a719e0 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -17041,6 +17041,19 @@ func (e *Engine) prepareSessionWrite( return s, msgs, verdict } +func (e *Engine) projectToolResultImagesForPrepare( + messages []db.Message, +) ([]db.Message, db.ToolImageStats) { + if e.forceParse { + return e.db.ProjectToolResultImagesForComparison( + messages, e.toolResultImages, + ) + } + return e.db.ProjectToolResultImagesWithPolicy( + messages, e.toolResultImages, + ) +} + func (e *Engine) prepareSessionWriteContext( ctx context.Context, pw pendingWrite, @@ -17050,7 +17063,7 @@ func (e *Engine) prepareSessionWriteContext( if err != nil { return db.Session{}, nil, sessionWritePreserved, err } - msgs, _ = e.db.ProjectToolResultImagesWithPolicy(msgs, e.toolResultImages) + msgs, _ = e.projectToolResultImagesForPrepare(msgs) s, err := toDBSessionContext(ctx, pw) if err != nil { return db.Session{}, nil, sessionWritePreserved, err @@ -17101,7 +17114,7 @@ func (e *Engine) prepareSessionWriteContext( } else if mergedMsgs != nil { parsedMsgs := msgs msgs = mergedMsgs - msgs, _ = e.db.ProjectToolResultImagesWithPolicy(msgs, e.toolResultImages) + msgs, _ = e.projectToolResultImagesForPrepare(msgs) applyVisualStudioCopilotArchiveSessionFields( &s, archived, parsedMsgs, msgs, ) diff --git a/internal/sync/parsediff_integration_test.go b/internal/sync/parsediff_integration_test.go index 6f67255956..4662a4309d 100644 --- a/internal/sync/parsediff_integration_test.go +++ b/internal/sync/parsediff_integration_test.go @@ -245,6 +245,49 @@ func TestParseDiffUsageOnlyArchiveIsIdentical(t *testing.T) { assert.Empty(t, report.Sessions) } +func TestParseDiffOffloadDoesNotWriteAssets(t *testing.T) { + env := setupSingleAgentTestEnv(t, parser.AgentClaude) + const raw = `[{"type":"input_image","image_url":"data:image/png;base64,AAEC"}]` + content := testjsonl.NewSessionBuilder(). + AddClaudeUser(tsEarly, "show the image"). + AddRaw(testjsonl.ClaudeAssistantJSON([]map[string]any{ + {"type": "tool_use", "id": "image-call", "name": "Bash", "input": map[string]any{}}, + }, tsEarlyS1)). + AddRaw(testjsonl.ClaudeToolResultUserJSON("image-call", raw, tsEarlyS5)). + String() + env.writeClaudeSession(t, "test-proj", "pd-offload.jsonl", content) + + assetsDir := t.TempDir() + cfg := sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentClaude: {env.claudeDir}, + }, + Machine: "local", + ToolResultImages: config.ToolResultImagesOffload, + AssetsDir: assetsDir, + } + ingest := sync.NewEngine(env.db, cfg) + t.Cleanup(ingest.Close) + require.Equal(t, 1, ingest.SyncAll(t.Context(), nil).Synced) + + entries, err := os.ReadDir(assetsDir) + require.NoError(t, err) + require.Len(t, entries, 1) + require.NoError(t, os.Remove(filepath.Join(assetsDir, entries[0].Name()))) + + diff := sync.NewDiffEngine(env.db, cfg) + t.Cleanup(diff.Close) + report, err := diff.ParseDiff(t.Context(), sync.ParseDiffOptions{ + Agents: []parser.AgentType{parser.AgentClaude}, + }) + require.NoError(t, err) + assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Identical: 1}, report.Totals) + + entries, err = os.ReadDir(assetsDir) + require.NoError(t, err) + assert.Empty(t, entries) +} + // TestParseDiffDetectsStoredDrift mutates stored rows directly after // a sync and verifies each drifted session is classified DiffChanged // with the expected field names while an untouched control session From 8d932ad0bbd979d9da10e80d0b12c11b233f1d76 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 11 Sep 2026 20:05:04 -0400 Subject: [PATCH 08/16] fix(frontend): fetch offloaded images with auth --- .../activity/ActivityInsight.svelte | 3 +- .../components/content/MessageContent.svelte | 3 +- .../lib/components/content/SkillBlock.svelte | 3 +- .../lib/components/content/ToolBlock.svelte | 3 +- .../lib/components/pinned/PinnedPage.svelte | 3 +- .../recall/GeneratedInsightsPanel.svelte | 4 +- frontend/src/lib/utils/markdown.test.ts | 47 ++++++++++++- frontend/src/lib/utils/markdown.ts | 68 ++++++++++++++++--- 8 files changed, 117 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/activity/ActivityInsight.svelte b/frontend/src/lib/components/activity/ActivityInsight.svelte index ff77e2e9bf..5dbcff70de 100644 --- a/frontend/src/lib/components/activity/ActivityInsight.svelte +++ b/frontend/src/lib/components/activity/ActivityInsight.svelte @@ -14,7 +14,7 @@ import { insights } from "../../stores/insights.svelte.js"; import { router } from "../../stores/router.svelte.js"; import { ui } from "../../stores/ui.svelte.js"; - import { renderMarkdown } from "../../utils/markdown.js"; + import { loadAssetImages, renderMarkdown } from "../../utils/markdown.js"; import { highlightCodeFences } from "../../utils/highlight-fences.js"; import type { AgentName } from "../../api/types.js"; import { LightbulbIcon, PlusIcon } from "../../icons.js"; @@ -241,6 +241,7 @@
{@html renderMarkdown(insight.content, { renderUnknownXmlBlocksAsPreformatted: ui.renderUnknownXmlBlocksAsPreformatted, diff --git a/frontend/src/lib/components/content/MessageContent.svelte b/frontend/src/lib/components/content/MessageContent.svelte index 3f257be466..50bafe9a2d 100644 --- a/frontend/src/lib/components/content/MessageContent.svelte +++ b/frontend/src/lib/components/content/MessageContent.svelte @@ -36,7 +36,7 @@ import { sync } from "../../stores/sync.svelte.js"; import { applyHighlight } from "../../utils/highlight.js"; import { highlightCodeFences } from "../../utils/highlight-fences.js"; - import { renderMarkdown } from "../../utils/markdown.js"; + import { loadAssetImages, renderMarkdown } from "../../utils/markdown.js"; import { displayToolName } from "../../utils/toolDisplay.js"; import { CirclePlayIcon, PinIcon } from "../../icons.js"; import type { Session } from "../../api/types.js"; @@ -508,6 +508,7 @@ content: segment.content, current: isCurrentHighlight, }} + use:loadAssetImages={segment.content} > {@html renderMarkdown(segment.content, { renderUnknownXmlBlocksAsPreformatted: ui.renderUnknownXmlBlocksAsPreformatted, diff --git a/frontend/src/lib/components/content/SkillBlock.svelte b/frontend/src/lib/components/content/SkillBlock.svelte index 9f67775a02..ba63b0d34f 100644 --- a/frontend/src/lib/components/content/SkillBlock.svelte +++ b/frontend/src/lib/components/content/SkillBlock.svelte @@ -1,6 +1,6 @@