diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+AgentAttachments.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+AgentAttachments.swift new file mode 100644 index 0000000000..c18918f140 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+AgentAttachments.swift @@ -0,0 +1,40 @@ +import Foundation + +extension RustCoreBridge { + /// One cached pasted image sent to `agent.attachments.retentionPlan`. + struct AgentAttachmentEntry: Encodable, Sendable { + let path: String + let modifiedMs: Int64? + let abandoned: Bool + } + + /// Files Core says to delete; Swift only lists the cache and performs the removal. + struct AgentAttachmentRetentionPlan: Decodable, Sendable { + struct Decision: Decodable, Sendable { + let path: String + let reason: String + } + let delete: [Decision] + let keep: [Decision] + /// Images to write to a new ledger before deleting anything (empty once a ledger exists). + let seed: [String] + } + + func agentAttachmentRetentionPlan( + nowMs: Int64, entries: [AgentAttachmentEntry], acceptedReferences: [String], ledgerPresent: Bool + ) throws -> AgentAttachmentRetentionPlan { + struct Request: Encodable { + let nowMs: Int64 + let entries: [AgentAttachmentEntry] + let acceptedReferences: [String] + let ledgerPresent: Bool + let caseInsensitive = false + } + let result: Result = executeResult( + command: "agent.attachments.retentionPlan", + payload: Request(nowMs: nowMs, entries: entries, acceptedReferences: acceptedReferences, + ledgerPresent: ledgerPresent) + ) + return try result.get() + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift index 16f79657d7..b46a3c6186 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift @@ -200,23 +200,75 @@ extension AppModel { }.prefix(20)) } + /// Caches pasted images and the ledger of the ones a conversation accepted. + /// Both live in Caches (never the app bundle); Core decides what may be deleted. + private var agentPastedImageDirectory: URL { + services.fileStorage.cacheDirectory().appendingPathComponent("AgentPastedImages", isDirectory: true) + } + + private var agentAcceptedAttachmentLedger: URL { + services.fileStorage.cacheDirectory().appendingPathComponent("AgentAcceptedAttachments.json", isDirectory: false) + } + + private func acceptedAgentAttachmentPaths() -> [String] { + let storage = services.fileStorage + guard storage.fileExists(at: agentAcceptedAttachmentLedger), + let data = try? storage.readData(from: agentAcceptedAttachmentLedger, options: []), + let paths = try? JSONDecoder().decode([String].self, from: data) else { return [] } + return paths + } + + /// `.atomic` writes a temporary sibling and renames it over the ledger. + private func writeAgentAcceptedAttachmentLedger(_ paths: [String]) throws { + let storage = services.fileStorage + try storage.createDirectory(at: agentAcceptedAttachmentLedger.deletingLastPathComponent(), withIntermediateDirectories: true) + try storage.writeData(try JSONEncoder().encode(paths), to: agentAcceptedAttachmentLedger, options: .atomic) + } + + /// Remembers the cached images an accepted prompt refers to, so a later + /// paste never deletes an image a conversation still points at. + func recordAcceptedAgentAttachments(_ files: [AgentFileReference]) { + let storage = services.fileStorage + let directory = agentPastedImageDirectory.standardizedFileURL.path + "/" + let accepted = files.map(\.url.path).filter { $0.hasPrefix(directory) } + // Without a ledger the cache has not been migrated; the next paste seeds it, accepted images included. + guard !accepted.isEmpty, storage.fileExists(at: agentAcceptedAttachmentLedger) else { return } + var paths = acceptedAgentAttachmentPaths().filter { storage.fileExists(at: URL(fileURLWithPath: $0)) } + for path in accepted where !paths.contains(path) { paths.append(path) } + do { + try writeAgentAcceptedAttachmentLedger(paths) + } catch { services.logSink?.emit(AgentImagesCleanupFailed(count: 1, error: error)) } + } + /// Stores a pasted image so it can be attached like any local file. Agents /// read attachments as ACP resource links, so the image must outlive the - /// paste; it lives in Caches (never the app bundle) and is pruned after a week. + /// paste; Core's retention plan keeps every image an accepted prompt references. func saveAgentPastedImage(_ png: Data) throws -> URL { let storage = services.fileStorage - let directory = storage.cacheDirectory().appendingPathComponent("AgentPastedImages", isDirectory: true) + let directory = agentPastedImageDirectory try storage.createDirectory(at: directory, withIntermediateDirectories: true) - let cutoff = Date().addingTimeInterval(-7 * 24 * 3600) + let entries = storage.listDirectory(at: directory).map { file in + RustCoreBridge.AgentAttachmentEntry( + path: file.path, + modifiedMs: storage.metadata(for: file)?.modificationDate.map { Int64($0.timeIntervalSince1970 * 1000) }, + abandoned: false) + } var cleanupCount: Int64 = 0 var cleanupError: (any Error)? - for file in storage.listDirectory(at: directory) - where (storage.metadata(for: file)?.modificationDate ?? .distantFuture) < cutoff { - do { try storage.removeItem(at: file) } - catch where (error as NSError).domain == NSCocoaErrorDomain && [NSFileNoSuchFileError, NSFileReadNoSuchFileError].contains((error as NSError).code) { - // log-exempt: 预期的缺失:过期图片已由其他清理移除 - } catch { cleanupCount += 1; cleanupError = error } - } + do { + let plan = try RustCoreBridge(coreLogSink: services.logSink).agentAttachmentRetentionPlan( + nowMs: Int64(Date().timeIntervalSince1970 * 1000), entries: entries, + acceptedReferences: acceptedAgentAttachmentPaths(), + ledgerPresent: storage.fileExists(at: agentAcceptedAttachmentLedger)) + // A cache that predates the ledger is seeded first; if that fails nothing is deleted. + if !storage.fileExists(at: agentAcceptedAttachmentLedger) { try writeAgentAcceptedAttachmentLedger(plan.seed) } + for decision in plan.delete { + do { try storage.removeItem(at: URL(fileURLWithPath: decision.path)) } + catch where (error as NSError).domain == NSCocoaErrorDomain && [NSFileNoSuchFileError, NSFileReadNoSuchFileError].contains((error as NSError).code) { + // log-exempt: 预期的缺失:过期图片已由其他清理移除 + } catch { cleanupCount += 1; cleanupError = error } + } + } catch { cleanupCount += 1; cleanupError = error } if let cleanupError { services.logSink?.emit(AgentImagesCleanupFailed(count: cleanupCount, error: cleanupError)) } let url = directory.appendingPathComponent(AgentPastedImage.fileName(), isDirectory: false) try storage.writeData(png, to: url, options: .atomic) diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index 610a498b48..f5aba84aea 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -53,6 +53,7 @@ struct AgentConversationView: View { onFindFiles: { await model.agentFileMentions(matching: $0) }, fileMentionRevision: model.workspaceSnapshotID, onSavePastedImage: { try model.saveAgentPastedImage($0) }, + onAttachmentsAccepted: { model.recordAcceptedAgentAttachments($0) }, availableAgents: selectableAgents, historyOnly: !isEditor && model.agentChatsInEditor, showsChatActions: !isEditor, @@ -255,6 +256,7 @@ struct AgentConfiguredConversationView: View { var onFindFiles: ((String) async -> [AgentFileMention])? = nil var fileMentionRevision: UUID? = nil var onSavePastedImage: ((Data) throws -> URL)? = nil + var onAttachmentsAccepted: (([AgentFileReference]) -> Void)? = nil var availableAgents: [AgentOption]? = nil var historyOnly = false var showsChatActions = true @@ -284,6 +286,7 @@ struct AgentConfiguredConversationView: View { onFindFiles: onFindFiles, fileMentionRevision: fileMentionRevision, onSavePastedImage: onSavePastedImage, + onAttachmentsAccepted: onAttachmentsAccepted, historyOnly: historyOnly, showsChatActions: showsChatActions, chatsInEditor: chatsInEditor, onToggleLocation: onToggleLocation, onFocusEditor: onFocusEditor, onOpenNewEditorTab: onOpenNewEditorTab, onSessionChanged: onSessionChanged, @@ -390,6 +393,7 @@ private struct AgentConnectionView: View { var onFindFiles: ((String) async -> [AgentFileMention])? = nil var fileMentionRevision: UUID? = nil var onSavePastedImage: ((Data) throws -> URL)? = nil + var onAttachmentsAccepted: (([AgentFileReference]) -> Void)? = nil var historyOnly = false var showsChatActions = true var chatsInEditor = false @@ -674,7 +678,7 @@ private struct AgentConnectionView: View { isBlocked: feature.isCreatingSession || feature.selectedConversation?.isLoading == true || feature.connectionState != .ready, - onSend: { try feature.send($0, files: $1) }, + onSend: { try feature.send($0, files: $1); onAttachmentsAccepted?($1) }, onCancel: { feature.cancel() }, onSelectAgent: onSelectAgent, onOpenSettings: onOpenSettings, diff --git a/macos/Tests/LitheTests/AgentAttachmentRetentionTests.swift b/macos/Tests/LitheTests/AgentAttachmentRetentionTests.swift new file mode 100644 index 0000000000..71203bfab2 --- /dev/null +++ b/macos/Tests/LitheTests/AgentAttachmentRetentionTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Agent attachment retention") +struct AgentAttachmentRetentionTests { + /// Retention rules are Core's; the shared fixture is the contract both platforms follow. + @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) + func bridgeReturnsTheSharedRetentionPlans() throws { + struct Fixture: Decodable { + struct Case: Decodable { + struct Request: Decodable { + struct Entry: Decodable { let path: String; let modifiedMs: Int64?; let abandoned: Bool? } + let nowMs: Int64 + let entries: [Entry] + let acceptedReferences: [String]? + let ledgerPresent: Bool + let caseInsensitive: Bool? + } + let name: String + let request: Request + let expected: RustCoreBridge.AgentAttachmentRetentionPlan + } + let cases: [Case] + } + let url = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/agent/attachment-retention-v1.json") + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + for item in fixture.cases where item.request.caseInsensitive != true { + let plan = try RustCoreBridge().agentAttachmentRetentionPlan( + nowMs: item.request.nowMs, + entries: item.request.entries.map { .init(path: $0.path, modifiedMs: $0.modifiedMs, abandoned: $0.abandoned ?? false) }, + acceptedReferences: item.request.acceptedReferences ?? [], + ledgerPresent: item.request.ledgerPresent) + #expect(plan.seed == item.expected.seed, "\(item.name)") + #expect(plan.delete.map(\.path) == item.expected.delete.map(\.path), "\(item.name)") + #expect(plan.delete.map(\.reason) == item.expected.delete.map(\.reason), "\(item.name)") + #expect(plan.keep.map(\.path) == item.expected.keep.map(\.path), "\(item.name)") + #expect(plan.keep.map(\.reason) == item.expected.keep.map(\.reason), "\(item.name)") + } + } +} diff --git a/rust/lithe-agent-host/src/attachment_retention.rs b/rust/lithe-agent-host/src/attachment_retention.rs new file mode 100644 index 0000000000..4d6a142b07 --- /dev/null +++ b/rust/lithe-agent-host/src/attachment_retention.rs @@ -0,0 +1,123 @@ +//! Retention plan for pasted Agent images. +//! +//! A pasted image is a file the Agent reads later as a resource link, so it must +//! outlive the paste for as long as any conversation still refers to it. Platforms +//! list their pasted-image cache and keep a ledger of the references a conversation +//! accepted; this module decides which files may be deleted. Deleting stays with +//! the platform. Contract: `shared/fixtures/agent/attachment-retention-v1.json`. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +/// Age after which an unreferenced image is no longer needed (seven days). +pub const RETENTION_MILLISECONDS: i64 = 7 * 24 * 3600 * 1000; + +/// One file in the platform's pasted-image cache. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Entry { + pub path: String, + /// Last modification time in Unix milliseconds; unknown ages are never expired. + #[serde(default)] + pub modified_ms: Option, + /// True for a paste that was abandoned or failed before any conversation accepted it. + #[serde(default)] + pub abandoned: bool, +} + +/// Payload of `agent.attachments.retentionPlan`. Pure: no files are read or written. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Request { + pub now_ms: i64, + pub entries: Vec, + /// Paths a conversation has accepted, from the platform's ledger. + #[serde(default)] + pub accepted_references: Vec, + /// Whether the platform's ledger file exists. Only its existence matters, never + /// its contents: a missing ledger means the cache predates the ledger, so every + /// image already cached is seeded as accepted instead of being expired. + pub ledger_present: bool, + /// Compare paths without case (Windows); separators are always normalized. + #[serde(default)] + pub case_insensitive: bool, +} + +/// Why a file is deleted or kept; stable snake_case codes for logs and tests. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Reason { + Referenced, + Abandoned, + Expired, + Recent, +} + +/// One decision for one cache entry. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Decision { + pub path: String, + pub reason: Reason, +} + +/// Files the platform should delete and files it must keep. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + pub delete: Vec, + pub keep: Vec, + /// Paths the platform must write to a new ledger before deleting anything; + /// empty whenever the ledger already exists. + pub seed: Vec, +} + +fn key(path: &str, case_insensitive: bool) -> String { + let normalized = path.replace('\\', "/"); + if case_insensitive { + normalized.to_lowercase() + } else { + normalized + } +} + +/// A referenced image is always kept, even when the paste was marked abandoned. +pub fn plan(request: &Request) -> Plan { + let seeded = request + .entries + .iter() + .filter(|entry| !request.ledger_present && !entry.abandoned); + let referenced: HashSet = request + .accepted_references + .iter() + .chain(seeded.clone().map(|entry| &entry.path)) + .map(|path| key(path, request.case_insensitive)) + .collect(); + let cutoff = request.now_ms.saturating_sub(RETENTION_MILLISECONDS); + let mut result = Plan { + delete: Vec::new(), + keep: Vec::new(), + seed: seeded.map(|entry| entry.path.clone()).collect(), + }; + for entry in &request.entries { + let reason = if referenced.contains(&key(&entry.path, request.case_insensitive)) { + Reason::Referenced + } else if entry.abandoned { + Reason::Abandoned + } else if entry.modified_ms.is_some_and(|modified| modified < cutoff) { + Reason::Expired + } else { + Reason::Recent + }; + let decision = Decision { + path: entry.path.clone(), + reason, + }; + match reason { + Reason::Referenced | Reason::Recent => result.keep.push(decision), + Reason::Abandoned | Reason::Expired => result.delete.push(decision), + } + } + result +} diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index a788a48d8d..60ffd7edb1 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -8,6 +8,7 @@ pub mod failure_log; +pub mod attachment_retention; pub mod catalog; mod claude_desktop; mod claude_earlier_history; diff --git a/rust/lithe-core/src/agent/attachments.rs b/rust/lithe-core/src/agent/attachments.rs new file mode 100644 index 0000000000..7ed70cc54a --- /dev/null +++ b/rust/lithe-core/src/agent/attachments.rs @@ -0,0 +1,11 @@ +//! Pasted-image retention dispatch. Listing and deleting files belong to the platform. + +use crate::protocol::{CoreError, ErrorCode}; +use lithe_agent_host::attachment_retention; +use serde_json::Value; + +/// Decides which pasted images may be deleted; reads and writes no files. +pub(crate) fn retention_plan(request: attachment_retention::Request) -> Result { + serde_json::to_value(attachment_retention::plan(&request)) + .map_err(|issue| CoreError::new(ErrorCode::Unknown, issue.to_string())) +} diff --git a/rust/lithe-core/src/agent/mod.rs b/rust/lithe-core/src/agent/mod.rs index 046e0ee711..bd8e66d3f0 100644 --- a/rust/lithe-core/src/agent/mod.rs +++ b/rust/lithe-core/src/agent/mod.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; +pub(crate) mod attachments; pub(crate) mod edits; pub(crate) mod hooks; pub(crate) mod instructions; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 738ada51ec..0da0bddf46 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -79,6 +79,8 @@ pub enum CoreCommand { AgentEditsReversePlan, /// Validates an unsaved Hook draft field by field (`agent.hooks.validateDraft`). AgentHooksValidateDraft, + /// Plans which pasted Agent images may be deleted (`agent.attachments.retentionPlan`). + AgentAttachmentsRetentionPlan, /// Budgets diff prefixes before crossing the native boundary (`ai.commitInputBudget`). AiCommitInputBudget, /// Plans a credential-free commit message request (`ai.planCommit`). @@ -828,6 +830,7 @@ impl CoreCommand { "agent.installCli" => Some(Self::AgentInstallCli), "agent.edits.reversePlan" => Some(Self::AgentEditsReversePlan), "agent.hooks.validateDraft" => Some(Self::AgentHooksValidateDraft), + "agent.attachments.retentionPlan" => Some(Self::AgentAttachmentsRetentionPlan), "ai.commitInputBudget" => Some(Self::AiCommitInputBudget), "ai.planCommit" => Some(Self::AiPlanCommit), "ai.decodeCommitMessage" => Some(Self::AiDecodeCommitMessage), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 6a0cb8c491..360386c6aa 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -244,6 +244,11 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { CoreCommand::AgentHooksValidateDraft => { agent_response(id, parsed.payload, crate::agent::hooks::validate_draft) } + CoreCommand::AgentAttachmentsRetentionPlan => agent_response( + id, + parsed.payload, + crate::agent::attachments::retention_plan, + ), CoreCommand::McpParseConfiguration => { match crate::mcp::parse_configuration(parsed.payload) { Ok(data) => CoreResponse::success(id, data), diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index fb221be0d0..40efa4090a 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -270,3 +270,19 @@ fn hook_draft_validation_dispatch_returns_the_shared_field_codes() { let response = run(command, json!({"hook": {"name": "x"}})); assert_eq!(response["error"]["code"], "invalid_request", "{response}"); } + +#[test] +fn attachment_retention_dispatch_follows_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/attachment-retention-v1.json" + )) + .unwrap(); + let command = fixture["command"].as_str().unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = run(command, case["request"].clone()); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } + let response = run(command, json!({"entries": []})); + assert_eq!(response["error"]["code"], "invalid_request", "{response}"); +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5856125107..5976b4192b 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -6799,3 +6799,19 @@ the failure again. ```json {"ts":"2026-11-03T08:00:01.123Z","level":"WARN","event":"vcs.git.command.failed","channel":"vcs/git","op":"op-7f3a","fields":{"command":"fetch","arguments":"fetch --prune origin","repository":"/src/app","source":"user","duration_ms":311,"exit_code":128,"stderr":"fatal: could not read from remote repository."}} ``` + +`agent.attachments.retentionPlan` decides which pasted Agent images the platform may +delete; it reads and writes no files. The payload is `{nowMs, entries, acceptedReferences, +ledgerPresent, caseInsensitive}`: `entries` lists the platform's pasted-image cache as `{path, modifiedMs, +abandoned}` (`modifiedMs` is Unix milliseconds and may be omitted; `abandoned` marks a +paste that failed or was discarded before any conversation accepted it), +`acceptedReferences` is the platform ledger of paths a conversation accepted, and +`caseInsensitive` (Windows) compares paths without case; separators are always normalized. +`ledgerPresent` reports only whether the ledger file exists, never whether it is empty: when it +is missing, every non-abandoned cached image counts as accepted (the cache predates the +ledger) and is returned in `seed`, which the platform writes atomically (temporary file, then +rename) before deleting anything. The result is `{delete, keep, seed}`, the first two lists of `{path, reason}` in entry order. Reasons: +`referenced` (kept, even when flagged abandoned), `recent` (kept, unreferenced and at most +seven days old or of unknown age), `abandoned` and `expired` (deleted). The cache and the +ledger live in the platform cache directory, never in the app bundle or install directory. +The fixture is `shared/fixtures/agent/attachment-retention-v1.json`. diff --git a/shared/fixtures/agent/attachment-retention-v1.json b/shared/fixtures/agent/attachment-retention-v1.json new file mode 100644 index 0000000000..2fa8ebf1fb --- /dev/null +++ b/shared/fixtures/agent/attachment-retention-v1.json @@ -0,0 +1,359 @@ +{ + "command": "agent.attachments.retentionPlan", + "description": "Retention plan for pasted Agent images shared by macOS and Windows. Images that any conversation accepted are kept; abandoned pastes and unreferenced images older than seven days are deleted by the platform.", + "cases": [ + { + "name": "recent unreferenced image is kept", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png", + "modifiedMs": 1799913600000 + } + ], + "ledgerPresent": true + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "/c/a.png", + "reason": "recent" + } + ], + "seed": [] + } + }, + { + "name": "old referenced image is kept", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png", + "modifiedMs": 1797408000000 + } + ], + "acceptedReferences": [ + "/c/a.png" + ], + "ledgerPresent": true + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "/c/a.png", + "reason": "referenced" + } + ], + "seed": [] + } + }, + { + "name": "old unreferenced image expires", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png", + "modifiedMs": 1799308800000 + } + ], + "ledgerPresent": true + }, + "expected": { + "delete": [ + { + "path": "/c/a.png", + "reason": "expired" + } + ], + "keep": [], + "seed": [] + } + }, + { + "name": "exactly seven days is still kept", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png", + "modifiedMs": 1799395200000 + } + ], + "ledgerPresent": true + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "/c/a.png", + "reason": "recent" + } + ], + "seed": [] + } + }, + { + "name": "abandoned paste is deleted at once", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png", + "modifiedMs": 1800000000000, + "abandoned": true + } + ], + "ledgerPresent": true + }, + "expected": { + "delete": [ + { + "path": "/c/a.png", + "reason": "abandoned" + } + ], + "keep": [], + "seed": [] + } + }, + { + "name": "referenced image survives an abandoned flag", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png", + "modifiedMs": 1800000000000, + "abandoned": true + } + ], + "acceptedReferences": [ + "/c/a.png" + ], + "ledgerPresent": true + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "/c/a.png", + "reason": "referenced" + } + ], + "seed": [] + } + }, + { + "name": "unknown age is never expired", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/a.png" + } + ], + "ledgerPresent": true + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "/c/a.png", + "reason": "recent" + } + ], + "seed": [] + } + }, + { + "name": "windows paths compare by separator and case", + "request": { + "nowMs": 1800000000000, + "caseInsensitive": true, + "entries": [ + { + "path": "C:\\Cache\\Pasted-1.PNG", + "modifiedMs": 1797408000000 + } + ], + "acceptedReferences": [ + "c:/cache/pasted-1.png" + ], + "ledgerPresent": true + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "C:\\Cache\\Pasted-1.PNG", + "reason": "referenced" + } + ], + "seed": [] + } + }, + { + "name": "case differs on a case-sensitive platform", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/A.png", + "modifiedMs": 1797408000000 + } + ], + "acceptedReferences": [ + "/c/a.png" + ], + "ledgerPresent": true + }, + "expected": { + "delete": [ + { + "path": "/c/A.png", + "reason": "expired" + } + ], + "keep": [], + "seed": [] + } + }, + { + "name": "mixed cache keeps order within each list", + "request": { + "nowMs": 1800000000000, + "entries": [ + { + "path": "/c/1.png", + "modifiedMs": 1797408000000 + }, + { + "path": "/c/2.png", + "modifiedMs": 1797408000000 + }, + { + "path": "/c/3.png", + "modifiedMs": 1799827200000 + }, + { + "path": "/c/4.png", + "modifiedMs": 1799222400000 + } + ], + "acceptedReferences": [ + "/c/2.png" + ], + "ledgerPresent": true + }, + "expected": { + "delete": [ + { + "path": "/c/1.png", + "reason": "expired" + }, + { + "path": "/c/4.png", + "reason": "expired" + } + ], + "keep": [ + { + "path": "/c/2.png", + "reason": "referenced" + }, + { + "path": "/c/3.png", + "reason": "recent" + } + ], + "seed": [] + } + }, + { + "name": "missing ledger seeds every cached image and keeps old ones", + "request": { + "nowMs": 1800000000000, + "ledgerPresent": false, + "entries": [ + { + "path": "/c/old.png", + "modifiedMs": 1797408000000 + }, + { + "path": "/c/new.png", + "modifiedMs": 1799913600000 + } + ] + }, + "expected": { + "delete": [], + "keep": [ + { + "path": "/c/old.png", + "reason": "referenced" + }, + { + "path": "/c/new.png", + "reason": "referenced" + } + ], + "seed": [ + "/c/old.png", + "/c/new.png" + ] + } + }, + { + "name": "empty ledger that exists does not migrate again", + "request": { + "nowMs": 1800000000000, + "ledgerPresent": true, + "acceptedReferences": [], + "entries": [ + { + "path": "/c/old.png", + "modifiedMs": 1797408000000 + } + ] + }, + "expected": { + "delete": [ + { + "path": "/c/old.png", + "reason": "expired" + } + ], + "keep": [], + "seed": [] + } + }, + { + "name": "missing ledger never seeds an abandoned paste", + "request": { + "nowMs": 1800000000000, + "ledgerPresent": false, + "entries": [ + { + "path": "/c/gone.png", + "abandoned": true + } + ] + }, + "expected": { + "delete": [ + { + "path": "/c/gone.png", + "reason": "abandoned" + } + ], + "keep": [], + "seed": [] + } + } + ] +} diff --git a/shared/platform-feature-matrix/features/agent-file-references.json b/shared/platform-feature-matrix/features/agent-file-references.json index 379b8eb873..9c7795bbc9 100644 --- a/shared/platform-feature-matrix/features/agent-file-references.json +++ b/shared/platform-feature-matrix/features/agent-file-references.json @@ -18,7 +18,9 @@ "macos/Tests/LitheTests/AgentComposerPasteTests.swift", "macos/Sources/Lithe/Application/Features/SearchWorkflowCoordinator.swift", "macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift", - "shared/fixtures/search/unicode-contiguous.json" + "shared/fixtures/search/unicode-contiguous.json", + "shared/fixtures/agent/attachment-retention-v1.json", + "macos/Tests/LitheTests/AgentAttachmentRetentionTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -36,12 +38,13 @@ "windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts", "windows/tauri/src-tauri/src/agent_clipboard.rs", "windows/tauri/src/platform/agent-pasted-images.ts", - "windows/tauri/tests/agent-pasted-images.test.ts" + "windows/tauri/tests/agent-pasted-images.test.ts", + "shared/fixtures/agent/attachment-retention-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。在 Finder 复制文件后于输入框 ⌘V 应附加文件,截图后 ⌘V 应生成带缩略图的图片附件(保存在 Caches/AgentPastedImages,一周后清理,不写 app bundle),复制普通文字时 ⌘V 照常粘贴,Caps Lock 开启时附件 ⌘V 同样生效;切到另一窗口或同窗口其他输入框后,附件粘贴不得进入原 Agent 草稿;在深色和浅色主题下单独输入 @ 或全角 @ 应立即列出已加载工作区的文件候选(最多 20 项,不重新扫描);扫描结束前输入 @,文件清单就绪后应自动出现候选,无需重输;继续输入关键字应筛选文件,旧查询不得覆盖新候选;方向键切换、回车/Tab 选中后移除 @ 查询并附加该文件,Esc 只关闭候选且保留草稿。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 macOS 裸 @ 与 @Name 共享项目文件可见性,均不显示 .build/node_modules 生成文件,不受 Search exclusions(例如 *.log)影响;全局搜索仍可单独包含生成目录名称。 Unicode 回归:@i 能找到 src/İ.txt,小写展开和希腊字母上下文转换不丢失候选;与共享 unicode-contiguous fixture 核对。 Windows:在真实 Tauri 窗口执行 Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", - "notes": "2026-10-22:Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。" + "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。在 Finder 复制文件后于输入框 ⌘V 应附加文件,截图后 ⌘V 应生成带缩略图的图片附件(保存在 Caches/AgentPastedImages,仍被任一会话引用的图片不清理,未引用且超过一周的才清理,不写 app bundle),复制普通文字时 ⌘V 照常粘贴,Caps Lock 开启时附件 ⌘V 同样生效;切到另一窗口或同窗口其他输入框后,附件粘贴不得进入原 Agent 草稿;在深色和浅色主题下单独输入 @ 或全角 @ 应立即列出已加载工作区的文件候选(最多 20 项,不重新扫描);扫描结束前输入 @,文件清单就绪后应自动出现候选,无需重输;继续输入关键字应筛选文件,旧查询不得覆盖新候选;方向键切换、回车/Tab 选中后移除 @ 查询并附加该文件,Esc 只关闭候选且保留草稿。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 macOS 裸 @ 与 @Name 共享项目文件可见性,均不显示 .build/node_modules 生成文件,不受 Search exclusions(例如 *.log)影响;全局搜索仍可单独包含生成目录名称。 Unicode 回归:@i 能找到 src/İ.txt,小写展开和希腊字母上下文转换不丢失候选;与共享 unicode-contiguous fixture 核对。 Windows:在真实 Tauri 窗口执行 Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,失败或放弃的粘贴立即清理,未被任何会话引用且超过一周的图片在首次使用时清理,已接受引用的图片保留。目录不递归展开。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "#1662 AGT-06:粘贴图片的保留计划统一由 Core `agent.attachments.retentionPlan` 决定:被会话接受的引用(平台缓存目录内的账本文件,两端发送成功后追加)永不删除,放弃或失败的粘贴立即删除,未引用且超过 7 天的删除。删除了 macOS 每次粘贴按 7 天清理而不看引用的规则;Windows 首次使用时新增按同一计划清理的过期图片,放弃批次也改走 Core 计划。账本与图片都在平台缓存目录,不触碰 app bundle 或安装目录。 2026-10-22:Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。" } diff --git a/windows/tauri/src-tauri/capabilities/main.json b/windows/tauri/src-tauri/capabilities/main.json index a98646cfe1..a03594ac02 100644 --- a/windows/tauri/src-tauri/capabilities/main.json +++ b/windows/tauri/src-tauri/capabilities/main.json @@ -47,6 +47,7 @@ "fs:allow-mkdir", "fs:allow-write-text-file", "fs:allow-remove", + "fs:allow-rename", "fs:allow-copy-file", { "identifier": "http:default", diff --git a/windows/tauri/src/features/agent/components/agent-composer.tsx b/windows/tauri/src/features/agent/components/agent-composer.tsx index 44bfadebc4..c7fcc3b3fe 100644 --- a/windows/tauri/src/features/agent/components/agent-composer.tsx +++ b/windows/tauri/src/features/agent/components/agent-composer.tsx @@ -36,7 +36,7 @@ import { type AgentCommandKeyState, } from "../services/agent-command-completion"; import { chooseAgentFiles } from "../services/agent-file-picker"; -import { savePastedImages } from "@/platform/agent-pasted-images"; +import { recordAcceptedPastedImages, savePastedImages } from "@/platform/agent-pasted-images"; import { convertFileSrc } from "@/platform/tauri-core"; import { readAgentClipboardFiles } from "@/platform/agent-local-files"; import { agentConnection } from "../stores/agent-connection-service"; @@ -289,10 +289,9 @@ export function AgentComposer({ return; } try { - agentConnection().send( - draft, - files.map((file) => file.uri), - ); + const uris = files.map((file) => file.uri); + agentConnection().send(draft, uris); + recordAcceptedPastedImages(uris).catch((cleanup) => console.warn("Agent attachment ledger failed", cleanup)); onDraftChange(""); onFilesChange([]); setMention(null); diff --git a/windows/tauri/src/platform/agent-pasted-images.ts b/windows/tauri/src/platform/agent-pasted-images.ts index f6a41d6ee2..d7230c406a 100644 --- a/windows/tauri/src/platform/agent-pasted-images.ts +++ b/windows/tauri/src/platform/agent-pasted-images.ts @@ -1,5 +1,6 @@ import { appCacheDir, join } from "@tauri-apps/api/path"; -import { mkdir, remove, writeFile } from "@tauri-apps/plugin-fs"; +import { exists, mkdir, readDir, readTextFile, remove, rename, stat, writeFile, writeTextFile } from "@tauri-apps/plugin-fs"; +import { invoke } from "./tauri-core"; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; const MAX_BATCH_BYTES = 32 * 1024 * 1024; @@ -9,13 +10,91 @@ const EXTENSIONS: Record = { }; let prepare: Promise | undefined; +interface RetentionEntry { path: string; modifiedMs?: number; abandoned: boolean } +interface RetentionPlan { delete: { path: string; reason: string }[]; keep: { path: string; reason: string }[]; seed: string[] } + +/** Ledger of images a conversation accepted; it sits beside the cache, never in the install directory. */ +async function ledgerPath(): Promise { + return join(await appCacheDir(), "agent", "pasted-images-accepted.json"); +} + +/** `present` is the file's existence alone; Core never treats an empty ledger as a first run. */ +async function readLedger(): Promise<{ present: boolean; paths: string[] }> { + const path = await ledgerPath(); + if (!(await exists(path))) return { present: false, paths: [] }; + const parsed: unknown = JSON.parse(await readTextFile(path)); + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === "string")) { + throw new Error("agent.composer.imageCleanupFailed"); + } + return { present: true, paths: parsed }; +} + +/** Publishes the ledger through a temporary sibling so a crash never leaves a partial file. */ +async function writeLedger(paths: string[]): Promise { + const path = await ledgerPath(); + const temporary = `${path}.tmp`; + await writeTextFile(temporary, JSON.stringify(paths)); + await rename(temporary, path); +} + +/** + * Core decides what may be deleted and, for a cache that predates the ledger, which images + * to seed as accepted. The seed is written before the caller deletes anything. + */ +async function retentionPlan(entries: RetentionEntry[]): Promise { + const ledger = await readLedger(); + const plan = await invoke("agent.attachments.retentionPlan", { + nowMs: Date.now(), + entries, + acceptedReferences: ledger.paths, + ledgerPresent: ledger.present, + caseInsensitive: true, + }); + if (!ledger.present) await writeLedger(plan.seed); + return plan; +} + +function filePath(uri: string): string | null { + const match = /^file:\/\/\/(.+)$/.exec(uri); + return match ? decodeURIComponent(match[1]) : null; +} + +/** Remembers the cached images an accepted prompt refers to, so retention never deletes them. */ +export async function recordAcceptedPastedImages(uris: string[]): Promise { + const root = (await directory()).replace(/\\/g, "/").toLowerCase() + "/"; + const accepted = uris.map(filePath).filter((path): path is string => + path !== null && path.replace(/\\/g, "/").toLowerCase().startsWith(root)); + if (accepted.length === 0) return; + const ledger = await readLedger(); + // Without a ledger the cache has not been migrated yet; the next sweep seeds it, accepted images included. + if (!ledger.present) return; + const known = new Set(); + const kept: string[] = []; + for (const path of [...ledger.paths, ...accepted]) { + if (known.has(path.toLowerCase()) || !(await exists(path))) continue; + known.add(path.toLowerCase()); + kept.push(path); + } + await writeLedger(kept); +} + +async function removePlanned(plan: RetentionPlan): Promise { + const results = await Promise.allSettled(plan.delete.map((item) => remove(item.path))); + if (results.some((result) => result.status === "rejected")) throw new Error("agent.composer.imageCleanupFailed"); +} + /** User cache owned by the Agent attachment adapter; never a packaged resource. */ async function directory(): Promise { prepare ??= (async () => { const root = await join(await appCacheDir(), "agent", "pasted-images"); await mkdir(root, { recursive: true }); - // Accepted attachments remain available to historical conversations. Only an - // abandoned paste owns deletion; age alone does not establish ownership. + const entries: RetentionEntry[] = []; + for (const item of await readDir(root)) { + if (!item.isFile) continue; + const path = await join(root, item.name); + entries.push({ path, modifiedMs: (await stat(path)).mtime?.getTime(), abandoned: false }); + } + try { await removePlanned(await retentionPlan(entries)); } catch (cleanup) { console.warn("Agent paste retention failed", cleanup); } return root; })().catch((error) => { prepare = undefined; throw error; }); return prepare; @@ -27,7 +106,7 @@ export function validatePastedImage(file: Blob): void { } } -/** Failed batches and stale paste operations remove their own files immediately. */ +/** Failed batches and stale paste operations are reported to Core as abandoned and removed at once. */ export async function savePastedImages(files: File[]): Promise<{ paths: string[]; discard: () => Promise }> { if (files.length > 32) throw new Error("agent.composer.tooManyImages"); files.forEach(validatePastedImage); @@ -35,9 +114,7 @@ export async function savePastedImages(files: File[]): Promise<{ paths: string[] const root = await directory(); const paths: string[] = []; const discard = async () => { - const results = await Promise.allSettled(paths.map((path) => remove(path))); - const failures = results.filter((result) => result.status === "rejected"); - if (failures.length) throw new Error("agent.composer.imageCleanupFailed"); + await removePlanned(await retentionPlan(paths.map((path) => ({ path, abandoned: true })))); }; try { for (const file of files) { diff --git a/windows/tauri/tests/agent-pasted-images.test.ts b/windows/tauri/tests/agent-pasted-images.test.ts index 20ae7901d1..a31dbbfe42 100644 --- a/windows/tauri/tests/agent-pasted-images.test.ts +++ b/windows/tauri/tests/agent-pasted-images.test.ts @@ -1,19 +1,101 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +const fixture = await Bun.file(new URL("../../../shared/fixtures/agent/attachment-retention-v1.json", import.meta.url)).json(); const writes: string[] = []; const removals: string[] = []; +const invocations: { command: string; args: any }[] = []; +const listing: { name: string; isFile: boolean }[] = []; +const modified = new Map(); +const files = new Map(); let failWrite = 0; let failRemove = false; +const retentionCase = (name: string) => fixture.cases.find((item: any) => item.name === name); +let planCase: any = retentionCase("mixed cache keeps order within each list"); mock.module("@tauri-apps/api/path", () => ({ appCacheDir: async () => "C:/cache", join: async (...parts: string[]) => parts.join("/") })); mock.module("@tauri-apps/plugin-fs", () => ({ mkdir: async () => {}, + exists: async (path: string) => files.has(path) || writes.includes(path), + readDir: async () => listing, + readTextFile: async (path: string) => files.get(path) ?? "[]", + writeTextFile: async (path: string, text: string) => { files.set(path, text); }, + rename: async (from: string, to: string) => { files.set(to, files.get(from)!); files.delete(from); }, + stat: async (path: string) => ({ mtime: new Date(modified.get(path) ?? Date.now()) }), writeFile: async (path: string) => { writes.push(path); if (writes.length === failWrite) throw new Error("write refused"); }, remove: async (path: string) => { removals.push(path); if (failRemove && removals.length === 1) throw new Error("remove refused"); }, })); -const { savePastedImages } = await import("../src/platform/agent-pasted-images"); +// Core stand-in: the first sweep returns the shared fixture's mixed-cache answer; later +// calls only carry abandoned pastes, which the fixture's "abandoned" rule deletes at once. +mock.module("../src/platform/tauri-core", () => ({ + invoke: async (command: string, args: any) => { + invocations.push({ command, args }); + if (args.entries.length > 0 && args.entries.every((entry: any) => entry.abandoned)) { + return { delete: args.entries.map((entry: any) => ({ path: entry.path, reason: "abandoned" })), keep: [] }; + } + const local = (path: string) => args.entries.find((entry: any) => entry.path.endsWith(path.slice(2))).path; + const place = (items: any[]) => items.map((item) => ({ ...item, path: local(item.path) })); + return args.entries.length === 0 + ? { delete: [], keep: [], seed: [] } + : { delete: place(planCase.expected.delete), keep: place(planCase.expected.keep), seed: planCase.expected.seed.map(local) }; + }, +})); +const { savePastedImages, recordAcceptedPastedImages } = await import("../src/platform/agent-pasted-images"); const image = () => new File([new Uint8Array(4)], "sample.png", { type: "image/png" }); -beforeEach(() => { writes.length = 0; removals.length = 0; failWrite = 0; failRemove = false; }); +beforeEach(() => { + writes.length = 0; removals.length = 0; invocations.length = 0; listing.length = 0; modified.clear(); files.clear(); + failWrite = 0; failRemove = false; +}); describe("Agent pasted image storage", () => { + test("the first use sweeps the cache with the Core plan and the accepted ledger", async () => { + const root = "C:/cache/agent/pasted-images"; + const now = Date.now(); + for (const [name, days] of [["1.png", 30], ["2.png", 30], ["3.png", 2], ["4.png", 9]] as const) { + listing.push({ name, isFile: true }); + modified.set(`${root}/${name}`, now - days * 86_400_000); + } + listing.push({ name: "folder", isFile: false }); + files.set("C:/cache/agent/pasted-images-accepted.json", JSON.stringify([`${root}/2.png`])); + await savePastedImages([image()]); + expect(invocations[0].command).toBe("agent.attachments.retentionPlan"); + expect(invocations[0].args.acceptedReferences).toEqual([`${root}/2.png`]); + expect(invocations[0].args.caseInsensitive).toBe(true); + expect(invocations[0].args.entries.map((entry: any) => entry.path)).toEqual(["1", "2", "3", "4"].map((n) => `${root}/${n}.png`)); + expect(invocations[0].args.entries.every((entry: any) => entry.abandoned === false)).toBe(true); + expect(removals).toEqual([`${root}/1.png`, `${root}/4.png`]); + }); + // A fresh module instance repeats the once-per-process first sweep. + const freshSweep = async (caseName: string, ledger: string | null) => { + const root = "C:/cache/agent/pasted-images"; + planCase = retentionCase(caseName); + for (const entry of planCase.request.entries) { + listing.push({ name: entry.path.slice(3), isFile: true }); + modified.set(`${root}/${entry.path.slice(3)}`, planCase.request.nowMs - 30 * 86_400_000); + } + if (ledger !== null) files.set("C:/cache/agent/pasted-images-accepted.json", ledger); + const module = await import(`../src/platform/agent-pasted-images?${caseName}`); + await module.savePastedImages([image()]); + return root; + }; + test("a missing ledger seeds the existing images and deletes nothing", async () => { + const root = await freshSweep("missing ledger seeds every cached image and keeps old ones", null); + expect(invocations[0].args.ledgerPresent).toBe(false); + expect(removals).toEqual([]); + expect(JSON.parse(files.get("C:/cache/agent/pasted-images-accepted.json")!)).toEqual([`${root}/old.png`, `${root}/new.png`]); + expect(files.has("C:/cache/agent/pasted-images-accepted.json.tmp")).toBe(false); + }); + test("an existing empty ledger never migrates again", async () => { + const root = await freshSweep("empty ledger that exists does not migrate again", "[]"); + expect(invocations[0].args.ledgerPresent).toBe(true); + expect(removals).toEqual([`${root}/old.png`]); + expect(files.get("C:/cache/agent/pasted-images-accepted.json")).toBe("[]"); + }); + test("an accepted prompt records only cached images in the ledger", async () => { + const root = "C:/cache/agent/pasted-images"; + files.set("C:/cache/agent/pasted-images-accepted.json", "[]"); + files.set(`${root}/5.png`, "x"); + files.set("C:/other/6.png", "x"); + await recordAcceptedPastedImages([`file:///${root}/5.png`, "file:///C:/other/6.png", "file:///C:/cache/agent/pasted-images/gone.png"]); + expect(JSON.parse(files.get("C:/cache/agent/pasted-images-accepted.json")!)).toEqual([`${root}/5.png`]); + }); test("accepted files stay in the application cache until their owner discards them", async () => { const batch = await savePastedImages([image(), image()]); expect(batch.paths).toEqual(writes);