From f189c43a85ec472597158cfd747c6548541c11f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:36:50 -0700 Subject: [PATCH 01/40] feat(native-chat): a paired server keeps a store for chat attachments A structured chat on a paired Orca server had nowhere to put a file the client attached. The server now keeps a per-chat attachment store under its userData, filled through agentSessionAttachment.uploadStart/Append/Commit/Abort and read back for previews through agentSessionAttachment.read, behind the structured session gate and advertised as agent-session.attachments.v1. Nothing records cleanup as owed: a sweep re-derives what may go from the host's chat records and journal (unfinished uploads after an hour, uploads for a chat that never existed or that its journal never mentions after a day). The clipboard RPC's in-flight bookkeeping moves into a shared ChunkedUploadRegistry both use. --- ...sion-attachment-journal-references.test.ts | 96 ++++++++ ...t-session-attachment-journal-references.ts | 32 +++ .../agent-session-attachment-store.test.ts | 170 +++++++++++++ .../agent-session-attachment-store.ts | 226 ++++++++++++++++++ .../agent-session-attachment-sweep.test.ts | 140 +++++++++++ .../agent-session-attachment-sweep.ts | 178 ++++++++++++++ .../runtime/agent-session-record-store.ts | 6 + src/main/runtime/chunked-upload-registry.ts | 130 ++++++++++ src/main/runtime/rpc/methods/clipboard.ts | 113 +++------ src/main/runtime/rpc/methods/index.ts | 2 + ...ructured-agent-session-attachments.test.ts | 144 +++++++++++ .../structured-agent-session-attachments.ts | 76 ++++++ ...uctured-agent-session-attachment-wiring.ts | 56 +++++ .../structured-agent-session-runtime.ts | 11 + src/shared/agent-session-attachments.ts | 82 +++++++ src/shared/protocol-version.ts | 5 + .../agent-session-attachment-params.ts | 36 +++ .../rpc-params-catalog.generated.ts | 11 + 18 files changed, 1435 insertions(+), 79 deletions(-) create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts create mode 100644 src/main/runtime/chunked-upload-registry.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-attachments.ts create mode 100644 src/main/runtime/structured-agent-session-attachment-wiring.ts create mode 100644 src/shared/agent-session-attachments.ts create mode 100644 src/shared/rpc-contract/agent-session-attachment-params.ts diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts new file mode 100644 index 000000000000..95f5370fe90f --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { createAgentSessionAttachmentJournalMentions } from './agent-session-attachment-journal-references' + +// A Windows path is the case that matters: JSON escapes its separators, so a raw-path search misses. +const storedPath = 'C:\\Users\\me\\AppData\\Orca\\agent-session-attachments\\abc\\u1\\shot.png' +const needle = JSON.stringify(storedPath).slice(1, -1) + +let stateDirectory: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-journal-')) +}) + +afterEach(async () => { + closeTestJournalHostDatabase(stateDirectory) + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function insertRow(sessionId: string, seq: number, row: unknown): void { + openTestJournalHostDatabase(stateDirectory) + .db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + .run(sessionId, 'epoch-1', seq, 1, JSON.stringify(row)) +} + +describe('createAgentSessionAttachmentJournalMentions', () => { + it('finds a sent image by its path in the chat journal', () => { + insertRow('session-1', 1, { + kind: 'item', + body: { kind: 'message', blocks: [{ type: 'image-ref', path: storedPath }] } + }) + const mentions = createAgentSessionAttachmentJournalMentions( + () => openTestJournalHostDatabase(stateDirectory).db + ) + expect(mentions('session-1', needle)).toBe(true) + expect(mentions('session-1', JSON.stringify('/other.png').slice(1, -1))).toBe(false) + }) + + it('finds a file referenced in message text', () => { + insertRow('session-1', 1, { + body: { kind: 'message', blocks: [{ type: 'text', text: `look at @${storedPath} please` }] } + }) + const mentions = createAgentSessionAttachmentJournalMentions( + () => openTestJournalHostDatabase(stateDirectory).db + ) + expect(mentions('session-1', needle)).toBe(true) + }) + + it("does not count another chat's journal", () => { + insertRow('session-2', 1, { blocks: [{ type: 'image-ref', path: storedPath }] }) + insertRow('session-1', 1, { blocks: [{ type: 'text', text: 'hello' }] }) + const mentions = createAgentSessionAttachmentJournalMentions( + () => openTestJournalHostDatabase(stateDirectory).db + ) + expect(mentions('session-1', needle)).toBe(false) + }) + + it('counts a host-held queued draft', () => { + insertRow('session-1', 1, { blocks: [{ type: 'text', text: 'hello' }] }) + openTestJournalHostDatabase(stateDirectory) + .db.prepare( + `INSERT INTO queued_messages + (session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + 'session-1', + 'queued-1', + 0, + JSON.stringify({ blocks: [{ type: 'image-ref', path: storedPath }] }), + 'fp', + 1, + 'host-1', + 'queued' + ) + const mentions = createAgentSessionAttachmentJournalMentions( + () => openTestJournalHostDatabase(stateDirectory).db + ) + expect(mentions('session-1', needle)).toBe(true) + }) + + it('says nothing about a chat whose journal holds no rows', () => { + const mentions = createAgentSessionAttachmentJournalMentions( + () => openTestJournalHostDatabase(stateDirectory).db + ) + expect(mentions('session-1', needle)).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts new file mode 100644 index 000000000000..21a681a0f53b --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts @@ -0,0 +1,32 @@ +import type Database from '../../sqlite/sync-database' + +/** + * Whether a chat's journal or its host-held queued drafts mention `needle` (a JSON-escaped path). + * Null when the chat has no journal rows at all: an unread or not-yet-written journal is no + * evidence that a file was never sent. + */ +export function createAgentSessionAttachmentJournalMentions( + db: () => Database.Database +): (sessionId: string, needle: string) => boolean | null { + return (sessionId, needle) => { + const connection = db() + const hasRows = connection + .prepare('SELECT 1 FROM journal_rows WHERE session_id = ? LIMIT 1') + .get(sessionId) + if (!hasRows) { + return null + } + const inRows = connection + .prepare('SELECT 1 FROM journal_rows WHERE session_id = ? AND instr(row_json, ?) > 0 LIMIT 1') + .get(sessionId, needle) + if (inRows) { + return true + } + const inQueue = connection + .prepare( + 'SELECT 1 FROM queued_messages WHERE session_id = ? AND instr(body_json, ?) > 0 LIMIT 1' + ) + .get(sessionId, needle) + return Boolean(inQueue) + } +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts new file mode 100644 index 000000000000..d3b1b75de035 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, readdir, readFile, rm, stat, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionAttachmentStore } from './agent-session-attachment-store' + +let root: string +let store: AgentSessionAttachmentStore +const caller = 'client-a' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachments-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments')) +}) + +afterEach(async () => { + store.clearInFlightForTests() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) +}) + +async function upload(name: string, bytes: Buffer, sessionId = 'session-1') { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId, + name, + byteLength: bytes.byteLength + }) + await store.appendChunk({ + callerKey: caller, + uploadId, + offset: 0, + contentBase64: bytes.toString('base64') + }) + return store.commitUpload({ callerKey: caller, uploadId }) +} + +describe('AgentSessionAttachmentStore', () => { + it('stores the bytes under the chat, keeping the file name', async () => { + const stored = await upload('screen shot.png', Buffer.from('png-bytes')) + expect(stored.name).toBe('screen shot.png') + expect(stored.byteLength).toBe(9) + expect(stored.path.startsWith(store.sessionDirectory('session-1'))).toBe(true) + expect(stored.path.endsWith('screen shot.png')).toBe(true) + expect(await readFile(stored.path, 'utf8')).toBe('png-bytes') + // The part file is gone once the name holds the whole file. + expect(await readdir(join(stored.path, '..'))).toEqual(['screen shot.png']) + }) + + it('keeps only the last path segment of a name, never a path out of the store', async () => { + const stored = await upload('../../../etc/passwd', Buffer.from('x')) + expect(stored.name).toBe('passwd') + expect(stored.path.startsWith(store.sessionDirectory('session-1'))).toBe(true) + }) + + it('appends chunks in order and refuses one out of order or past the declared size', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 4 + }) + const chunk = (text: string) => Buffer.from(text).toString('base64') + await store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: chunk('ab') }) + await expect( + store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: chunk('cd') }) + ).rejects.toThrow('out of order') + await expect( + store.appendChunk({ callerKey: caller, uploadId, offset: 2, contentBase64: chunk('cde') }) + ).rejects.toThrow('exceeded its declared size') + await store.appendChunk({ callerKey: caller, uploadId, offset: 2, contentBase64: chunk('cd') }) + const stored = await store.commitUpload({ callerKey: caller, uploadId }) + expect(await readFile(stored.path, 'utf8')).toBe('abcd') + }) + + it('refuses an incomplete commit and removes what it had', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 10 + }) + await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('incomplete') + expect(await readdir(store.sessionDirectory('session-1'))).toEqual([]) + }) + + it('stores an empty file', async () => { + const stored = await upload('empty.txt', Buffer.alloc(0)) + expect((await stat(stored.path)).size).toBe(0) + }) + + it('does not let another client touch an upload it did not start', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await expect( + store.appendChunk({ callerKey: 'client-b', uploadId, offset: 0, contentBase64: 'eA==' }) + ).rejects.toThrow('not found') + await expect(store.commitUpload({ callerKey: 'client-b', uploadId })).rejects.toThrow( + 'not found' + ) + await expect(store.abortUpload({ callerKey: 'client-b', uploadId })).rejects.toThrow( + 'not found' + ) + expect(store.isUploadInFlight(uploadId)).toBe(true) + }) + + it('removes an aborted upload', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await store.abortUpload({ callerKey: caller, uploadId }) + expect(store.isUploadInFlight(uploadId)).toBe(false) + expect(await readdir(store.sessionDirectory('session-1'))).toEqual([]) + }) + + it('forgets and removes an upload its client abandoned', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + vi.useRealTimers() + await vi.waitFor(async () => + expect(await readdir(store.sessionDirectory('session-1'))).toEqual([]) + ) + expect(store.isUploadInFlight(uploadId)).toBe(false) + await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('not found') + }) + + it('reads back a stored image and nothing outside the store', async () => { + const stored = await upload('shot.png', Buffer.from('png-bytes')) + await expect(store.readPreview(stored.path)).resolves.toEqual({ + content: Buffer.from('png-bytes').toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + const outside = join(root, 'outside.png') + await writeFile(outside, 'secret') + await expect(store.readPreview(outside)).rejects.toThrow('Not an attachment image') + const notImage = await upload('notes.txt', Buffer.from('text')) + await expect(store.readPreview(notImage.path)).rejects.toThrow('Not an attachment image') + }) + + it('reads only a file exactly at //', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'shot.png', + byteLength: 3 + }) + const uploadDir = join(store.sessionDirectory('session-1'), uploadId) + // Deeper than a stored file: nothing the store wrote lives there. + await mkdir(join(uploadDir, 'nested'), { recursive: true }) + await writeFile(join(uploadDir, 'nested', 'x.png'), 'x') + await expect(store.readPreview(join(uploadDir, 'nested', 'x.png'))).rejects.toThrow( + 'Not an attachment image' + ) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts new file mode 100644 index 000000000000..447479edeb2f --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -0,0 +1,226 @@ +// The server-owned store for files a client attaches to a structured chat. +// +// `///`: one directory per upload keeps the +// user's file name intact (agents read meaning into it) without two uploads colliding. Bytes land +// in a hidden part file and are renamed into place on commit, so a stored name is always complete. +// +// Nothing here records what is owed: whether a file may go is re-derived on every sweep from the +// host's chat records and journal (see `sweepAgentSessionAttachments`). + +import { + appendFile, + mkdir, + readFile, + realpath, + rename, + rm, + stat, + writeFile +} from 'node:fs/promises' +import { extname, join, relative, isAbsolute, sep } from 'node:path' +import { + AGENT_SESSION_ATTACHMENT_PREVIEW_MAX_BYTES, + sanitizeAgentSessionAttachmentName, + type AgentSessionAttachmentUploadCommitResult +} from '../../../shared/agent-session-attachments' +import { journalPathSegment } from '../agent-session-journal/journal-paths' +import { + ChunkedUploadRegistry, + nextChunkedUploadLength +} from '../../runtime/chunked-upload-registry' + +export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' +const UPLOAD_MAX_CONCURRENT = 8 +const UPLOAD_IDLE_TTL_MS = 5 * 60 * 1000 +const NOT_FOUND = 'Attachment upload was not found' + +const PREVIEW_IMAGE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.avif': 'image/avif' +} + +type InFlightUpload = { + callerKey: string + sessionId: string + uploadDir: string + name: string + expectedLength: number + receivedLength: number + writing: boolean +} + +export class AgentSessionAttachmentStore { + private readonly uploads = new ChunkedUploadRegistry({ + maxConcurrent: UPLOAD_MAX_CONCURRENT, + ttlMs: UPLOAD_IDLE_TTL_MS, + tooManyMessage: 'Too many attachment uploads are in progress', + notFoundMessage: NOT_FOUND, + onExpire: (upload) => void removeQuietly(upload.uploadDir) + }) + + constructor(readonly rootDir: string) {} + + sessionDirectory(sessionId: string): string { + return join(this.rootDir, journalPathSegment(sessionId)) + } + + /** Upload ids whose bytes are still arriving; the sweep leaves their directories alone. */ + isUploadInFlight(uploadId: string): boolean { + return this.uploads.has(uploadId) + } + + async startUpload(args: { + callerKey: string + sessionId: string + name: string + byteLength: number + }): Promise<{ uploadId: string }> { + const sessionDir = this.sessionDirectory(args.sessionId) + const name = sanitizeAgentSessionAttachmentName(args.name) + const uploadId = this.uploads.create((id) => ({ + callerKey: args.callerKey, + sessionId: args.sessionId, + uploadDir: join(sessionDir, id), + name, + expectedLength: args.byteLength, + receivedLength: 0, + writing: false + })) + const uploadDir = join(sessionDir, uploadId) + try { + await mkdir(uploadDir, { recursive: true }) + await writeFile(join(uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), '', { flag: 'wx' }) + } catch (error) { + this.uploads.delete(uploadId) + await removeQuietly(uploadDir) + throw error + } + return { uploadId } + } + + async appendChunk(args: { + callerKey: string + uploadId: string + offset: number + contentBase64: string + }): Promise<{ receivedBytes: number }> { + const upload = this.requireOwned(args.uploadId, args.callerKey) + if (upload.writing) { + throw new Error('Attachment chunk is already being written') + } + const bytes = Buffer.from(args.contentBase64, 'base64') + const nextLength = nextChunkedUploadLength(upload, args.offset, bytes.byteLength, { + outOfOrder: 'Attachment chunk offset is out of order', + exceeded: 'Attachment upload exceeded its declared size' + }) + upload.writing = true + try { + await appendFile(join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), bytes) + upload.receivedLength = nextLength + } finally { + upload.writing = false + } + this.uploads.touch(args.uploadId) + return { receivedBytes: upload.receivedLength } + } + + async commitUpload(args: { + callerKey: string + uploadId: string + }): Promise { + const upload = this.requireOwned(args.uploadId, args.callerKey) + if (upload.writing) { + throw new Error('Attachment chunk is still being written') + } + this.uploads.delete(args.uploadId) + try { + if (upload.receivedLength !== upload.expectedLength) { + throw new Error('Attachment upload is incomplete') + } + const finalPath = join(upload.uploadDir, upload.name) + await rename(join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), finalPath) + return { path: finalPath, name: upload.name, byteLength: upload.receivedLength } + } catch (error) { + await removeQuietly(upload.uploadDir) + throw error + } + } + + async abortUpload(args: { callerKey: string; uploadId: string }): Promise<{ aborted: true }> { + const upload = this.uploads.peek(args.uploadId) + if (upload) { + if (upload.callerKey !== args.callerKey) { + throw new Error(NOT_FOUND) + } + this.uploads.delete(args.uploadId) + await removeQuietly(upload.uploadDir) + } + return { aborted: true } + } + + /** A stored image's bytes, for a client that shows it. Anything outside the store is refused. */ + async readPreview( + filePath: string + ): Promise<{ content: string; isBinary: true; isImage: true; mimeType: string }> { + const mimeType = PREVIEW_IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] + if (!isAbsolute(filePath) || !mimeType) { + throw new Error('Not an attachment image') + } + const [root, resolved] = await Promise.all([realpath(this.rootDir), realpath(filePath)]) + const inside = relative(root, resolved) + const segments = inside.split(sep) + // `//` exactly: never a part file, never anything shallower or deeper. + if ( + inside === '' || + isAbsolute(inside) || + segments.length !== 3 || + segments.some((segment) => segment === '..') || + segments[2] === AGENT_SESSION_ATTACHMENT_PART_FILE + ) { + throw new Error('Not an attachment image') + } + const info = await stat(resolved) + if (!info.isFile()) { + throw new Error('Not an attachment image') + } + if (info.size > AGENT_SESSION_ATTACHMENT_PREVIEW_MAX_BYTES) { + throw new Error('Attachment image is too large to preview') + } + const content = (await readFile(resolved)).toString('base64') + return { content, isBinary: true, isImage: true, mimeType } + } + + clearInFlightForTests(): void { + this.uploads.clear() + } + + private requireOwned(uploadId: string, callerKey: string): InFlightUpload { + const upload = this.uploads.require(uploadId) + // Another client's upload reads as absent, not as forbidden. + if (upload.callerKey !== callerKey) { + throw new Error(NOT_FOUND) + } + return upload + } +} + +export async function removeQuietly(path: string): Promise { + // Cleanup is best effort: a file that will not go is retried by the next sweep. + await rm(path, { recursive: true, force: true }).catch(() => {}) +} + +let installedStore: AgentSessionAttachmentStore | null = null + +/** Installed with the structured host, which owns the state directory the store lives in. */ +export function setAgentSessionAttachmentStore(store: AgentSessionAttachmentStore | null): void { + installedStore = store +} + +export function getAgentSessionAttachmentStore(): AgentSessionAttachmentStore | null { + return installedStore +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts new file mode 100644 index 000000000000..433af38f4863 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts @@ -0,0 +1,140 @@ +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + AgentSessionAttachmentStore +} from './agent-session-attachment-store' +import { sweepAgentSessionAttachments } from './agent-session-attachment-sweep' + +const HOUR = 60 * 60 * 1000 +const NOW = Date.now() + +let root: string +let store: AgentSessionAttachmentStore + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachment-sweep-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments')) +}) + +afterEach(async () => { + store.clearInFlightForTests() + await rm(root, { recursive: true, force: true }) +}) + +/** A stored upload (or a bare part file) last touched `ageMs` ago. */ +async function seedUpload( + sessionId: string, + uploadId: string, + ageMs: number, + file: string = 'shot.png' +): Promise { + const uploadDir = join(store.sessionDirectory(sessionId), uploadId) + await mkdir(uploadDir, { recursive: true }) + await writeFile(join(uploadDir, file), 'bytes') + const at = new Date(NOW - ageMs) + await utimes(uploadDir, at, at) + return join(uploadDir, file) +} + +async function remaining(sessionId: string): Promise { + return readdir(store.sessionDirectory(sessionId)).catch(() => []) +} + +describe('sweepAgentSessionAttachments', () => { + it('removes a part file nobody finished after an hour, and keeps a fresh one', async () => { + await seedUpload('session-1', 'stale', HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await seedUpload('session-1', 'fresh', HOUR - 1000, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds: () => ['session-1'], journalMentions: () => true }, + NOW + ) + expect(await remaining('session-1')).toEqual(['fresh']) + }) + + it('removes the uploads of a chat that never came to exist, after a day', async () => { + await seedUpload('ghost', 'old', 24 * HOUR + 1) + await seedUpload('ghost', 'new', HOUR) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds: () => [], journalMentions: () => null }, + NOW + ) + expect(await remaining('ghost')).toEqual(['new']) + }) + + it('judges nothing abandoned while the record list is incomplete', async () => { + await seedUpload('ghost', 'old', 24 * HOUR + 1) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds: () => null, journalMentions: () => null }, + NOW + ) + expect(await remaining('ghost')).toEqual(['old']) + }) + + it("removes a recorded chat's upload its journal never mentions, and keeps a sent one", async () => { + const sent = await seedUpload('session-1', 'sent', 48 * HOUR) + await seedUpload('session-1', 'unsent', 48 * HOUR) + const journalMentions = vi.fn( + (_sessionId: string, needle: string) => needle === JSON.stringify(sent).slice(1, -1) + ) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds: () => ['session-1'], journalMentions }, + NOW + ) + expect(await remaining('session-1')).toEqual(['sent']) + expect(journalMentions).toHaveBeenCalledWith('session-1', expect.any(String)) + }) + + it('keeps a recorded chat upload while its journal holds no rows yet', async () => { + await seedUpload('session-1', 'unknown', 48 * HOUR) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds: () => ['session-1'], journalMentions: () => null }, + NOW + ) + expect(await remaining('session-1')).toEqual(['unknown']) + }) + + it('does not rescan a file already proven sent', async () => { + await seedUpload('session-1', 'sent', 48 * HOUR) + const journalMentions = vi.fn(() => true) + const known = new Set() + const facts = { recordedSessionIds: () => ['session-1'], journalMentions } + await sweepAgentSessionAttachments(store, facts, NOW, known) + await sweepAgentSessionAttachments(store, facts, NOW, known) + expect(journalMentions).toHaveBeenCalledTimes(1) + }) + + it('leaves an upload in flight alone however old its directory is', async () => { + const { uploadId } = await store.startUpload({ + callerKey: 'client-a', + sessionId: 'ghost', + name: 'a.txt', + byteLength: 1 + }) + const at = new Date(NOW - 48 * HOUR) + await utimes(join(store.sessionDirectory('ghost'), uploadId), at, at) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds: () => [], journalMentions: () => null }, + NOW + ) + expect(await remaining('ghost')).toEqual([uploadId]) + }) + + it('reads no facts when nothing is stored', async () => { + const recordedSessionIds = vi.fn(() => []) + await sweepAgentSessionAttachments( + store, + { recordedSessionIds, journalMentions: () => null }, + NOW + ) + expect(recordedSessionIds).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts new file mode 100644 index 000000000000..09d58faa08f8 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts @@ -0,0 +1,178 @@ +// When a stored attachment may go, re-derived from what the host already holds. +// +// - A part file nobody is writing (the uploader crashed or vanished) goes after an hour. +// - A chat the host has no record of never came to exist: its uploads go after a day. +// - A recorded chat's upload that its journal never mentions after a day was never sent (the chip +// was removed, the draft abandoned): it goes too. A chat with no journal rows yet is no evidence. +// - Everything a chat's journal mentions lives as long as that chat. The host never deletes a chat +// today, so that is as long as its transcript. + +import { readdir, rmdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { journalPathSegment } from '../agent-session-journal/journal-paths' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + removeQuietly, + type AgentSessionAttachmentStore +} from './agent-session-attachment-store' + +export const ABANDONED_PART_MAX_AGE_MS = 60 * 60 * 1000 +export const UNSENT_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +export type AgentSessionAttachmentSweepFacts = { + /** Every chat this host holds a record for; null while that list is incomplete (records still + * owed their import), when no upload can be judged abandoned. */ + recordedSessionIds: () => Iterable | null + /** Whether the chat's journal mentions `needle`; null when the journal holds no rows yet. */ + journalMentions: (sessionId: string, needle: string) => boolean | null +} + +export type AgentSessionAttachmentSweepResult = { removed: string[] } + +export async function sweepAgentSessionAttachments( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + now = Date.now(), + /** Verdicts already proven this process: a sent file stays sent, so it is not rescanned. */ + knownReferenced = new Set() +): Promise { + const removed: string[] = [] + const sessionSegments = await listDirectories(store.rootDir) + if (sessionSegments.length === 0) { + return { removed } + } + const recorded = facts.recordedSessionIds() + const sessionIdBySegment = new Map() + for (const sessionId of recorded ?? []) { + sessionIdBySegment.set(journalPathSegment(sessionId), sessionId) + } + for (const sessionSegment of sessionSegments) { + const sessionDir = join(store.rootDir, sessionSegment) + const sessionId = sessionIdBySegment.get(sessionSegment) ?? null + const recordsComplete = recorded !== null + for (const uploadId of await listDirectories(sessionDir)) { + if (store.isUploadInFlight(uploadId)) { + continue + } + const uploadDir = join(sessionDir, uploadId) + const verdict = await uploadVerdict(uploadDir, { + sessionId, + recordsComplete, + facts, + now, + knownReferenced + }) + if (verdict === 'remove') { + await removeQuietly(uploadDir) + removed.push(uploadDir) + } + // Each scan can read a long journal; let other work run between uploads. + await new Promise((resolve) => setImmediate(resolve)) + } + // Non-recursive, so an upload that starts in this chat meanwhile keeps its directory. + await rmdir(sessionDir).catch(() => {}) + } + return { removed } +} + +async function uploadVerdict( + uploadDir: string, + context: { + sessionId: string | null + recordsComplete: boolean + facts: AgentSessionAttachmentSweepFacts + now: number + knownReferenced: Set + } +): Promise<'keep' | 'remove'> { + const { sessionId, recordsComplete, facts, now, knownReferenced } = context + const entries = await listEntries(uploadDir) + const stored = entries.filter((entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE) + const ageMs = now - (await modifiedAt(uploadDir)) + if (stored.length === 0) { + return ageMs > ABANDONED_PART_MAX_AGE_MS ? 'remove' : 'keep' + } + if (ageMs <= UNSENT_ATTACHMENT_MAX_AGE_MS) { + return 'keep' + } + if (sessionId === null) { + return recordsComplete ? 'remove' : 'keep' + } + const path = join(uploadDir, stored[0]) + if (knownReferenced.has(path)) { + return 'keep' + } + // Journal rows are JSON, so the path appears there escaped. + const mentioned = facts.journalMentions(sessionId, JSON.stringify(path).slice(1, -1)) + if (mentioned === true) { + knownReferenced.add(path) + } + return mentioned === false ? 'remove' : 'keep' +} + +async function listEntries(dir: string): Promise { + try { + return await readdir(dir) + } catch { + return [] + } +} + +async function listDirectories(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } catch { + return [] + } +} + +async function modifiedAt(path: string): Promise { + try { + return (await stat(path)).mtimeMs + } catch { + return Number.POSITIVE_INFINITY + } +} + +export type AgentSessionAttachmentSweeper = { stop: () => void } + +/** + * Sweeps shortly after the host comes up and then periodically. Best effort: a failed sweep is + * logged and the next one tries again; nothing waits on it. + */ +export function startAgentSessionAttachmentSweeps( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + options: { + initialDelayMs: number + intervalMs: number + onError: (error: unknown) => void + } +): AgentSessionAttachmentSweeper { + const knownReferenced = new Set() + let running = false + let stopped = false + const run = (): void => { + if (running || stopped) { + return + } + running = true + void sweepAgentSessionAttachments(store, facts, Date.now(), knownReferenced) + .catch(options.onError) + .finally(() => { + running = false + }) + } + const initial = setTimeout(run, options.initialDelayMs) + const interval = setInterval(run, options.intervalMs) + initial.unref?.() + interval.unref?.() + return { + stop: () => { + stopped = true + clearTimeout(initial) + clearInterval(interval) + } + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0fd..73707f47244d 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -109,6 +109,12 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] + /** Every chat this host holds a row for, readable or not. */ + listRecordedSessionIds = (): string[] => [ + ...this.state.records.keys(), + ...this.state.unreadableRecords.keys() + ] + /** Whether this host has recorded a chat, readable or not. Nothing removes a record row. */ holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 diff --git a/src/main/runtime/chunked-upload-registry.ts b/src/main/runtime/chunked-upload-registry.ts new file mode 100644 index 000000000000..729fc089c639 --- /dev/null +++ b/src/main/runtime/chunked-upload-registry.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto' + +type RegistryEntry = { + value: T + expiresAt: number + ttlTimer: ReturnType +} + +export type ChunkedUploadRegistryOptions = { + maxConcurrent: number + /** Idle lifetime: every touch restarts it. */ + ttlMs: number + tooManyMessage: string + notFoundMessage: string + /** Runs when an upload expires unfinished; never for an explicit delete. */ + onExpire?: (value: T) => void +} + +/** + * In-flight chunked uploads held between start and commit: bounded in number, each forgotten after + * an idle TTL so a client that disappears mid-upload cannot pin the slot. + */ +export class ChunkedUploadRegistry { + private readonly entries = new Map>() + + constructor(private readonly options: ChunkedUploadRegistryOptions) {} + + get size(): number { + return this.entries.size + } + + /** `build` receives the new id, for state that is named after it. */ + create(build: (uploadId: string) => T): string { + this.pruneExpired() + if (this.entries.size >= this.options.maxConcurrent) { + throw new Error(this.options.tooManyMessage) + } + const uploadId = randomUUID() + this.entries.set(uploadId, { + value: build(uploadId), + expiresAt: Date.now() + this.options.ttlMs, + ttlTimer: this.scheduleExpiry(uploadId) + }) + return uploadId + } + + require(uploadId: string): T { + this.pruneExpired() + const entry = this.entries.get(uploadId) + if (!entry) { + throw new Error(this.options.notFoundMessage) + } + return entry.value + } + + peek(uploadId: string): T | undefined { + this.pruneExpired() + return this.entries.get(uploadId)?.value + } + + has(uploadId: string): boolean { + return this.entries.has(uploadId) + } + + touch(uploadId: string): void { + const entry = this.entries.get(uploadId) + if (!entry) { + return + } + clearTimeout(entry.ttlTimer) + entry.expiresAt = Date.now() + this.options.ttlMs + entry.ttlTimer = this.scheduleExpiry(uploadId) + } + + delete(uploadId: string): T | undefined { + const entry = this.entries.get(uploadId) + if (!entry) { + return undefined + } + clearTimeout(entry.ttlTimer) + this.entries.delete(uploadId) + return entry.value + } + + clear(): void { + for (const uploadId of this.entries.keys()) { + this.delete(uploadId) + } + } + + private expire(uploadId: string): void { + const value = this.delete(uploadId) + if (value !== undefined) { + this.options.onExpire?.(value) + } + } + + private pruneExpired(now = Date.now()): void { + for (const [uploadId, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.expire(uploadId) + } + } + } + + private scheduleExpiry(uploadId: string): ReturnType { + const timer = setTimeout(() => this.expire(uploadId), this.options.ttlMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer + } +} + +/** The next chunk must start where the last one ended and stay within the declared size. */ +export function nextChunkedUploadLength( + upload: { expectedLength: number; receivedLength: number }, + offset: number, + chunkLength: number, + messages: { outOfOrder: string; exceeded: string } +): number { + if (offset !== upload.receivedLength) { + throw new Error(messages.outOfOrder) + } + const nextLength = upload.receivedLength + chunkLength + if (nextLength > upload.expectedLength) { + throw new Error(messages.exceeded) + } + return nextLength +} diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 60ed142455f3..ac3572868228 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,6 +1,6 @@ import { defineMethod, type RpcContext } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' -import { randomUUID } from 'node:crypto' +import { ChunkedUploadRegistry, nextChunkedUploadLength } from '../../chunked-upload-registry' import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' import { AbortImageUpload, @@ -15,57 +15,19 @@ export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 type ClipboardImageUpload = { - expectedBase64Length: number + expectedLength: number connectionId?: string | null mobileClientId?: string chunks: string[] - receivedBase64Length: number - expiresAt: number - ttlTimer: ReturnType + receivedLength: number } -const clipboardImageUploads = new Map() - -function pruneExpiredUploads(now = Date.now()): void { - for (const [uploadId, upload] of clipboardImageUploads) { - if (upload.expiresAt <= now) { - deleteUpload(uploadId) - } - } -} - -function scheduleUploadExpiry(uploadId: string): ReturnType { - const timer = setTimeout(() => { - clipboardImageUploads.delete(uploadId) - }, CLIPBOARD_IMAGE_UPLOAD_TTL_MS) - if (typeof timer === 'object' && 'unref' in timer) { - timer.unref() - } - return timer -} - -function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): void { - clearTimeout(upload.ttlTimer) - upload.expiresAt = Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS - upload.ttlTimer = scheduleUploadExpiry(uploadId) -} - -function deleteUpload(uploadId: string): void { - const upload = clipboardImageUploads.get(uploadId) - if (upload) { - clearTimeout(upload.ttlTimer) - } - clipboardImageUploads.delete(uploadId) -} - -function getUpload(uploadId: string): ClipboardImageUpload { - pruneExpiredUploads() - const upload = clipboardImageUploads.get(uploadId) - if (!upload) { - throw new Error('Clipboard image upload was not found') - } - return upload -} +const clipboardImageUploads = new ChunkedUploadRegistry({ + maxConcurrent: CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT, + ttlMs: CLIPBOARD_IMAGE_UPLOAD_TTL_MS, + tooManyMessage: 'Too many clipboard image uploads are in progress', + notFoundMessage: 'Clipboard image upload was not found' +}) function mobileClientId(ctx: RpcContext): string | undefined { if (ctx.clientKind !== 'mobile') { @@ -111,20 +73,14 @@ export const CLIPBOARD_METHODS = [ name: 'clipboard.startImageUpload', params: StartImageUpload, handler: (params, ctx) => { - pruneExpiredUploads() - if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) { - throw new Error('Too many clipboard image uploads are in progress') - } - const uploadId = randomUUID() - clipboardImageUploads.set(uploadId, { - expectedBase64Length: params.expectedBase64Length, + const mobileClient = mobileClientId(ctx) + const uploadId = clipboardImageUploads.create(() => ({ + expectedLength: params.expectedBase64Length, connectionId: params.connectionId, - mobileClientId: mobileClientId(ctx), + mobileClientId: mobileClient, chunks: [], - receivedBase64Length: 0, - expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, - ttlTimer: scheduleUploadExpiry(uploadId) - }) + receivedLength: 0 + })) return { uploadId } } }), @@ -132,29 +88,31 @@ export const CLIPBOARD_METHODS = [ name: 'clipboard.appendImageUploadChunk', params: AppendImageUploadChunk, handler: (params, ctx) => { - const upload = getUpload(params.uploadId) + const upload = clipboardImageUploads.require(params.uploadId) assertMobileUploadOwner(upload, ctx) - if (params.offset !== upload.receivedBase64Length) { - throw new Error('Clipboard image chunk offset is out of order') - } - const nextLength = upload.receivedBase64Length + params.contentBase64.length - if (nextLength > upload.expectedBase64Length) { - throw new Error('Clipboard image upload exceeded expected size') - } + const nextLength = nextChunkedUploadLength( + upload, + params.offset, + params.contentBase64.length, + { + outOfOrder: 'Clipboard image chunk offset is out of order', + exceeded: 'Clipboard image upload exceeded expected size' + } + ) upload.chunks.push(params.contentBase64) - upload.receivedBase64Length = nextLength - refreshUploadExpiry(params.uploadId, upload) - return { receivedBase64Length: upload.receivedBase64Length } + upload.receivedLength = nextLength + clipboardImageUploads.touch(params.uploadId) + return { receivedBase64Length: upload.receivedLength } } }), defineMethod({ name: 'clipboard.commitImageUpload', params: CommitImageUpload, handler: async (params, ctx) => { - const upload = getUpload(params.uploadId) + const upload = clipboardImageUploads.require(params.uploadId) const clientId = assertMobileUploadOwner(upload, ctx) try { - if (upload.receivedBase64Length !== upload.expectedBase64Length) { + if (upload.receivedLength !== upload.expectedLength) { throw new Error('Clipboard image upload is incomplete') } const path = await saveClipboardImageBufferAsTempFile( @@ -170,7 +128,7 @@ export const CLIPBOARD_METHODS = [ } finally { // Why: failed SSH or filesystem commits must not leave bounded upload // memory pinned until TTL cleanup. - deleteUpload(params.uploadId) + clipboardImageUploads.delete(params.uploadId) } } }), @@ -178,19 +136,16 @@ export const CLIPBOARD_METHODS = [ name: 'clipboard.abortImageUpload', params: AbortImageUpload, handler: (params, ctx) => { - pruneExpiredUploads() - const upload = clipboardImageUploads.get(params.uploadId) + const upload = clipboardImageUploads.peek(params.uploadId) if (upload) { assertMobileUploadOwner(upload, ctx) } - deleteUpload(params.uploadId) + clipboardImageUploads.delete(params.uploadId) return { aborted: true } } }) ] export function resetClipboardImageUploadsForTest(): void { - for (const uploadId of clipboardImageUploads.keys()) { - deleteUpload(uploadId) - } + clipboardImageUploads.clear() } diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 3376d9bf994c..90fa185cb37f 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -46,6 +46,7 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' import { AGENT_LAUNCH_METHODS } from './agent-launch' @@ -63,6 +64,7 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS, ...AGENT_LAUNCH_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts new file mode 100644 index 000000000000..9c4ef0782124 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -0,0 +1,144 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from '../../../../shared/protocol-version' +import { + AgentSessionAttachmentStore, + setAgentSessionAttachmentStore +} from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' +import { + clearStructuredHostStub, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +let root: string +let store: AgentSessionAttachmentStore + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachment-rpc-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments')) + setAgentSessionAttachmentStore(store) + installStructuredHostStub() +}) + +afterEach(async () => { + store.clearInFlightForTests() + setAgentSessionAttachmentStore(null) + clearStructuredHostStub() + await rm(root, { recursive: true, force: true }) +}) + +async function call( + method: string, + params: unknown, + client: { clientId?: string; clientKind?: 'runtime' | 'mobile'; clientCapabilities?: string[] } +): Promise { + const dispatcher = new RpcDispatcher({ + runtime: { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => {} + } as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS + }) + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { id: 'request-1', authToken: 'token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +function result(response: RpcResponse): T { + if (!response.ok) { + throw new Error(`refused: ${JSON.stringify(response)}`) + } + return response.result as T +} + +const CLIENT_A = { ...STRUCTURED_CLIENT, clientId: 'client-a' } + +describe('agentSessionAttachment.*', () => { + it('is advertised, so a client can tell a host with the store from an older one', () => { + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY) + }) + + it('stores an upload for the chat and hands back the server path', async () => { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'shot.png', byteLength: 3 }, + CLIENT_A + ) + ) + result( + await call( + 'agentSessionAttachment.uploadAppend', + { uploadId, offset: 0, contentBase64: Buffer.from('png').toString('base64') }, + CLIENT_A + ) + ) + const stored = result<{ path: string; name: string }>( + await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) + ) + expect(stored.name).toBe('shot.png') + expect(stored.path.startsWith(store.sessionDirectory('session-alpha'))).toBe(true) + expect(await readFile(stored.path, 'utf8')).toBe('png') + + const preview = await call('agentSessionAttachment.read', { path: stored.path }, CLIENT_A) + expect(preview).toMatchObject({ ok: true, result: { isBinary: true, mimeType: 'image/png' } }) + }) + + it('refuses a client that cannot read structured sessions', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'shot.png', byteLength: 3 }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + }) + + it('refuses a declared size over the attachment limit before storing anything', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'big.bin', byteLength: 51 * 1024 * 1024 }, + CLIENT_A + ) + expect(response.ok).toBe(false) + }) + + it("keeps one client's upload out of another's reach", async () => { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'a.txt', byteLength: 1 }, + CLIENT_A + ) + ) + const response = await call( + 'agentSessionAttachment.uploadCommit', + { uploadId }, + { + ...STRUCTURED_CLIENT, + clientId: 'client-b' + } + ) + expect(response).toMatchObject({ ok: false }) + expect(store.isUploadInFlight(uploadId)).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts new file mode 100644 index 000000000000..68295c287c18 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -0,0 +1,76 @@ +// `agentSessionAttachment.*` — files a client attaches to a structured chat, stored on this host. +// +// Behind the same gate as `agentSession.*`: only a client that reads structured sessions may write +// into their store. A namespace of its own, because these reach the attachment store rather than +// the session host. Clients call them only where the host advertises +// `agent-session.attachments.v1`; an older host answers `method_not_found`. + +import { defineMethod, type RpcContext } from '../core' +import { + requireInstalledStructuredHost, + structuredCallerFor +} from './structured-agent-session-gate' +import { + AttachmentReadParams, + AttachmentUploadAppendParams, + AttachmentUploadIdParams, + AttachmentUploadStartParams +} from '../../../../shared/rpc-contract/agent-session-attachment-params' +import { + getAgentSessionAttachmentStore, + type AgentSessionAttachmentStore +} from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' + +async function requireStore( + ctx: RpcContext +): Promise<{ store: AgentSessionAttachmentStore; callerKey: string }> { + await requireInstalledStructuredHost(ctx) + const store = getAgentSessionAttachmentStore() + if (!store) { + throw new Error('Chat attachments are not available on this host') + } + return { store, callerKey: structuredCallerFor(ctx).callerKey } +} + +export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ + defineMethod({ + name: 'agentSessionAttachment.uploadStart', + params: AttachmentUploadStartParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.startUpload({ callerKey, ...params }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadAppend', + params: AttachmentUploadAppendParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.appendChunk({ callerKey, ...params }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadCommit', + params: AttachmentUploadIdParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.commitUpload({ callerKey, uploadId: params.uploadId }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadAbort', + params: AttachmentUploadIdParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.abortUpload({ callerKey, uploadId: params.uploadId }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.read', + params: AttachmentReadParams, + handler: async (params, ctx) => { + const { store } = await requireStore(ctx) + return store.readPreview(params.path) + } + }) +] diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts new file mode 100644 index 000000000000..1ef6f309495e --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -0,0 +1,56 @@ +// Installs the chat attachment store beside the structured host, and its sweeps. The store lives in +// the same state directory as the journal, and the sweeps read the host's records and journal. + +import { join } from 'node:path' +import { AGENT_SESSION_ATTACHMENTS_DIR_NAME } from '../../shared/agent-session-attachments' +import { + AgentSessionAttachmentStore, + setAgentSessionAttachmentStore +} from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { + startAgentSessionAttachmentSweeps, + type AgentSessionAttachmentSweeper +} from '../native-chat/agent-session-attachments/agent-session-attachment-sweep' +import { createAgentSessionAttachmentJournalMentions } from '../native-chat/agent-session-attachments/agent-session-attachment-journal-references' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +const FIRST_SWEEP_DELAY_MS = 60 * 1000 +const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000 + +let sweeper: AgentSessionAttachmentSweeper | null = null + +export function installAgentSessionAttachments(deps: { + stateDirectory: string + store: AgentSessionRecordStore + journalDatabase: JournalHostDatabase + logger: StructuredAgentSessionLogger +}): void { + stopAgentSessionAttachments() + const attachments = new AgentSessionAttachmentStore( + join(deps.stateDirectory, AGENT_SESSION_ATTACHMENTS_DIR_NAME) + ) + setAgentSessionAttachmentStore(attachments) + sweeper = startAgentSessionAttachmentSweeps( + attachments, + { + // Records still owed their import are missing from the list, so nothing reads as abandoned. + recordedSessionIds: () => + deps.journalDatabase.legacyRecordImportOwed ? null : deps.store.listRecordedSessionIds(), + journalMentions: createAgentSessionAttachmentJournalMentions(() => deps.journalDatabase.db) + }, + { + initialDelayMs: FIRST_SWEEP_DELAY_MS, + intervalMs: SWEEP_INTERVAL_MS, + onError: (error) => + deps.logger.warn('chat attachment sweep failed', { scope: 'attachment-sweep', error }) + } + ) +} + +export function stopAgentSessionAttachments(): void { + sweeper?.stop() + sweeper = null + setAgentSessionAttachmentStore(null) +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..01b09f976915 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -38,6 +38,10 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' @@ -186,6 +190,7 @@ export async function stopStructuredAgentSessionRuntime(options?: { const pending = installing installing = null setStructuredAgentSessionHost(null) + stopAgentSessionAttachments() const outstanding = [...pendingTeardown] pendingTeardown.clear() const installed = pending ? await pending.catch(() => null) : null @@ -325,6 +330,12 @@ async function installOnJournal( ...(await modelCatalogHostDeps({ store, deps, envResolvers })) }) setStructuredAgentSessionHost(host) + installAgentSessionAttachments({ + stateDirectory: deps.stateDirectory, + store, + journalDatabase, + logger: deps.logger + }) return { host, adapter, diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts new file mode 100644 index 000000000000..f28cb837f5ca --- /dev/null +++ b/src/shared/agent-session-attachments.ts @@ -0,0 +1,82 @@ +// Files a client attaches to a structured chat on a paired Orca server: the client uploads the +// bytes, the server keeps them in a store it owns, and the agent only ever sees the server path. + +import type { ImportSkipReason } from './filesystem-import-result-types' + +/** Directory name of the server's store, under its userData. Also how a client recognizes a + * stored attachment's path without a round trip. */ +export const AGENT_SESSION_ATTACHMENTS_DIR_NAME = 'agent-session-attachments' + +export const AGENT_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024 + +/** 384 KiB of bytes is 512 KiB of base64 on the wire, the same frame size file imports use. */ +export const AGENT_SESSION_ATTACHMENT_CHUNK_BYTES = 384 * 1024 + +export const AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS = + (AGENT_SESSION_ATTACHMENT_CHUNK_BYTES / 3) * 4 + +/** Previews are images read whole into one reply, so they stay well under the frame limit. */ +export const AGENT_SESSION_ATTACHMENT_PREVIEW_MAX_BYTES = 10 * 1024 * 1024 + +export const AGENT_SESSION_ATTACHMENT_NAME_MAX_LENGTH = 200 + +export type AgentSessionAttachmentUploadStartResult = { uploadId: string } +export type AgentSessionAttachmentUploadCommitResult = { + path: string + name: string + byteLength: number +} + +/** Whether `filePath` names a file in some server's attachment store. Only routes a preview read; + * the server re-checks the real path before reading anything. */ +export function isAgentSessionAttachmentStorePath(filePath: string): boolean { + return filePath.split(/[\\/]/).includes(AGENT_SESSION_ATTACHMENTS_DIR_NAME) +} + +/** + * A stored file keeps the user's file name, since agents read meaning into names and extensions. + * Only the last path segment survives, without control characters or separators. + */ +export function sanitizeAgentSessionAttachmentName(name: string): string { + const leaf = name.split(/[\\/]/).findLast((part) => part.length > 0) ?? '' + // eslint-disable-next-line no-control-regex -- stripping control characters is the point + const cleaned = leaf.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_').trim() + const visible = cleaned.replace(/^\.+/, '') + if (visible.length === 0) { + return 'attachment' + } + if (visible.length <= AGENT_SESSION_ATTACHMENT_NAME_MAX_LENGTH) { + return visible + } + const dot = visible.lastIndexOf('.') + const extension = dot > 0 && visible.length - dot <= 16 ? visible.slice(dot) : '' + return visible.slice(0, AGENT_SESSION_ATTACHMENT_NAME_MAX_LENGTH - extension.length) + extension +} + +/** Pins an upload to the server the attachment was meant for: every call re-checks the pairing, + * and every chunk the server process, so a re-pair or a replaced server aborts the upload. */ +export type AgentSessionAttachmentUploadTarget = { + environmentId: string + sessionId: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId: string +} + +export type AgentSessionAttachmentPathUploadResult = { + /** Input order; `path` is where the server stored the file. */ + uploaded: { sourcePath: string; path: string }[] + skipped: { sourcePath: string; reason: ImportSkipReason }[] + failed: { sourcePath: string; reason: string }[] +} + +/** Asks a clipboard image save to land in the chat's attachment store on the paired server named + * by the save's `runtimeEnvironmentId`, instead of that server's temp directory. */ +export type AgentSessionAttachmentClipboardTarget = Omit< + AgentSessionAttachmentUploadTarget, + 'environmentId' +> + +/** `orca-paste-…png`, the name every pasted image gets, so it reads as "Pasted image". */ +export function agentSessionPastedImageName(now: number, unique: string): string { + return `orca-paste-${now}-${unique}.png` +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81d..259f4316be2d 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -253,6 +253,10 @@ export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = // `answers` to an older host; they fall back to the answer packed into `optionId`. export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = 'agent-session.question-answers.v1' as const +// Why: a structured chat on a paired server stores attached files on that server +// (agentSessionAttachment.*); an older server has no store, so clients must refuse +// the attach rather than hand the agent a path from this machine. +export const AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session.attachments.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given, @@ -409,6 +413,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/shared/rpc-contract/agent-session-attachment-params.ts b/src/shared/rpc-contract/agent-session-attachment-params.ts new file mode 100644 index 000000000000..0288c1e46323 --- /dev/null +++ b/src/shared/rpc-contract/agent-session-attachment-params.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + AGENT_SESSION_ATTACHMENT_MAX_BYTES +} from '../agent-session-attachments' +import { isValidBase64 } from './clipboard-params' +import { Identifier, SessionId } from './structured-agent-session-params' + +const UploadId = Identifier('Invalid upload id', 128) + +export const AttachmentUploadStartParams = z + .object({ + sessionId: SessionId, + name: z.string().min(1).max(1024), + byteLength: z + .number() + .int() + .nonnegative() + .max(AGENT_SESSION_ATTACHMENT_MAX_BYTES, 'Attachment is too large') + }) + .strict() + +export const AttachmentUploadAppendParams = z + .object({ + uploadId: UploadId, + offset: z.number().int().nonnegative(), + contentBase64: z + .string() + .max(AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, 'Attachment chunk is too large') + .refine(isValidBase64, 'Attachment chunk must be base64') + }) + .strict() + +export const AttachmentUploadIdParams = z.object({ uploadId: UploadId }).strict() + +export const AttachmentReadParams = z.object({ path: z.string().min(1).max(4096) }).strict() diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d6d81433e8e0..80e969fb60f9 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -39,6 +39,12 @@ import { } from './accounts-params' import { PrepareCodexForWslPaneParams } from './agent-hooks-params' import { AgentLaunch, AgentLaunchReplay } from './agent-launch-params' +import { + AttachmentReadParams, + AttachmentUploadAppendParams, + AttachmentUploadIdParams, + AttachmentUploadStartParams +} from './agent-session-attachment-params' import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params' import { AiVaultListSessionsParams, @@ -617,6 +623,11 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.subscribeTurnCompletions': null, 'agentSession.threadGoal': ThreadGoalParams, 'agentSession.unsubscribe': UnsubscribeParams, + 'agentSessionAttachment.read': AttachmentReadParams, + 'agentSessionAttachment.uploadAbort': AttachmentUploadIdParams, + 'agentSessionAttachment.uploadAppend': AttachmentUploadAppendParams, + 'agentSessionAttachment.uploadCommit': AttachmentUploadIdParams, + 'agentSessionAttachment.uploadStart': AttachmentUploadStartParams, 'agentTeams.prepareLaunch': AgentTeamsPrepareLaunch, 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat, 'aiVault.listSessions': AiVaultListSessionsParams, From 67079806062aa87e5764042c8a9d1ae9941b6936 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:36:55 -0700 Subject: [PATCH 02/40] feat(native-chat): upload attached files into a paired server's chat store Main streams dropped or picked files (fs:uploadPathsToAgentSessionAttachments) and pasted images (clipboard:saveImageAsTempFile with agentSessionAttachment) into the chat's store on its paired server, reusing the file-import slice streamer and pinning every call to the pairing revision and server process. The browser client's paste takes the same route. --- .../agent-session-attachment-upload-ipc.ts | 36 ++++ .../agent-session-attachment-upload.test.ts | 152 +++++++++++++++ .../ipc/agent-session-attachment-upload.ts | 177 ++++++++++++++++++ .../filesystem/filesystem-write-handlers.ts | 2 + src/main/ipc/runtime-upload-file-stream.ts | 40 +++- ...ard-agent-session-attachment-paste.test.ts | 117 ++++++++++++ src/main/window/clipboard-image-temp-file.ts | 3 + src/main/window/clipboard-ipc-handlers.ts | 18 ++ src/preload/api/filesystem-api.ts | 8 + src/preload/api/fs-bridge.ts | 8 + ...ui-bridge-clipboard-and-window-controls.ts | 2 + src/preload/api/ui-window-api.ts | 2 + .../web-agent-session-attachment-upload.ts | 62 ++++++ .../src/web/preload-api/web-filesystem-api.ts | 1 + .../src/web/preload-api/web-ui-api.ts | 9 + 15 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 src/main/ipc/agent-session-attachment-upload-ipc.ts create mode 100644 src/main/ipc/agent-session-attachment-upload.test.ts create mode 100644 src/main/ipc/agent-session-attachment-upload.ts create mode 100644 src/main/window/clipboard-agent-session-attachment-paste.test.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts diff --git a/src/main/ipc/agent-session-attachment-upload-ipc.ts b/src/main/ipc/agent-session-attachment-upload-ipc.ts new file mode 100644 index 000000000000..9fcf1ba357d5 --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload-ipc.ts @@ -0,0 +1,36 @@ +import { app, ipcMain } from 'electron' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { uploadExternalPathsToAgentSessionAttachments } from './agent-session-attachment-upload' +import { abortWhenRendererGone } from './renderer-lifetime-abort' + +export function registerAgentSessionAttachmentUploadHandlers(): void { + ipcMain.handle( + 'fs:uploadPathsToAgentSessionAttachments', + async ( + event, + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ): Promise => { + const userDataPath = app.getPath('userData') + // Why: a reload or close must stop the transfer; the server sweeps the part file it leaves. + const lifetime = abortWhenRendererGone(event.sender) + try { + return await uploadExternalPathsToAgentSessionAttachments( + { + ...args, + // The manual-disconnect check keys on the resolved id, not whatever selector came in. + environmentId: resolveEnvironment(userDataPath, args.environmentId).id, + userDataPath, + signal: lifetime.signal + }, + args.paths + ) + } finally { + lifetime.dispose() + } + } + ) +} diff --git a/src/main/ipc/agent-session-attachment-upload.test.ts b/src/main/ipc/agent-session-attachment-upload.test.ts new file mode 100644 index 000000000000..dc896169eab5 --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' + +type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } +type Call = { + method: string + params: Record + revision?: number + options?: CallOptions +} + +const calls: Call[] = [] +let serverStore: AgentSessionAttachmentStore +let failMethod: string | null = null + +// The paired server, played by a real store: every call answers as the RPC handler would. +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: async ( + _userDataPath: string, + _environmentId: string, + method: string, + params: Record, + _timeoutMs?: number, + revision?: number, + _envelope?: unknown, + options?: CallOptions + ) => { + calls.push({ method, params, revision, options }) + if (method === failMethod) { + return { ok: false, error: { code: 'internal', message: `${method} failed` } } + } + const callerKey = 'client-a' + try { + const result = + method === 'agentSessionAttachment.uploadStart' + ? await serverStore.startUpload({ callerKey, ...(params as never) }) + : method === 'agentSessionAttachment.uploadAppend' + ? await serverStore.appendChunk({ callerKey, ...(params as never) }) + : method === 'agentSessionAttachment.uploadCommit' + ? await serverStore.commitUpload({ callerKey, ...(params as never) }) + : await serverStore.abortUpload({ callerKey, ...(params as never) }) + return { ok: true, result } + } catch (error) { + return { ok: false, error: { code: 'internal', message: (error as Error).message } } + } + } +})) +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) + +const { uploadBufferToAgentSessionAttachments, uploadExternalPathsToAgentSessionAttachments } = + await import('./agent-session-attachment-upload') + +let workDir: string +const context = () => ({ + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a', + userDataPath: join(workDir, 'client-user-data') +}) + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-attachment-upload-')) + serverStore = new AgentSessionAttachmentStore( + join(workDir, 'server', 'agent-session-attachments') + ) + calls.length = 0 + failMethod = null +}) + +afterEach(async () => { + serverStore.clearInFlightForTests() + await rm(workDir, { recursive: true, force: true }) +}) + +describe('uploadExternalPathsToAgentSessionAttachments', () => { + it('uploads dropped files in order and returns only server paths', async () => { + const big = Buffer.alloc(1024 * 1024, 7) + await writeFile(join(workDir, 'big.bin'), big) + await writeFile(join(workDir, 'notes.md'), '# hi') + + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'big.bin'), + join(workDir, 'notes.md') + ]) + + expect(result.skipped).toEqual([]) + expect(result.failed).toEqual([]) + expect(result.uploaded.map(({ sourcePath }) => sourcePath)).toEqual([ + join(workDir, 'big.bin'), + join(workDir, 'notes.md') + ]) + for (const { path } of result.uploaded) { + expect(path.startsWith(serverStore.sessionDirectory('session-1'))).toBe(true) + } + expect(await readFile(result.uploaded[0].path)).toEqual(big) + expect(await readFile(result.uploaded[1].path, 'utf8')).toBe('# hi') + }) + + it('pins every call to the pairing and every call to the server process', async () => { + await writeFile(join(workDir, 'a.txt'), 'a') + await uploadExternalPathsToAgentSessionAttachments(context(), [join(workDir, 'a.txt')]) + expect(calls.map((call) => call.method)).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + for (const call of calls) { + expect(call.revision).toBe(7) + expect(call.options?.expectedEnvironmentRuntimeId).toBe('runtime-a') + } + }) + + it('skips a folder as unsupported instead of uploading its contents', async () => { + await mkdir(join(workDir, 'folder')) + await writeFile(join(workDir, 'folder', 'inside.txt'), 'x') + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'folder') + ]) + expect(result.skipped).toEqual([{ sourcePath: join(workDir, 'folder'), reason: 'unsupported' }]) + expect(calls).toEqual([]) + }) + + it('aborts a failed upload on the server and reports the file as failed', async () => { + await writeFile(join(workDir, 'a.txt'), 'a') + failMethod = 'agentSessionAttachment.uploadCommit' + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'a.txt') + ]) + expect(result.uploaded).toEqual([]) + expect(result.failed).toEqual([ + { sourcePath: join(workDir, 'a.txt'), reason: 'agentSessionAttachment.uploadCommit failed' } + ]) + expect(calls.at(-1)?.method).toBe('agentSessionAttachment.uploadAbort') + }) +}) + +describe('uploadBufferToAgentSessionAttachments', () => { + it('stores a pasted image under the chat', async () => { + const png = Buffer.alloc(900 * 1024, 3) + const stored = await uploadBufferToAgentSessionAttachments(context(), 'orca-paste-1.png', png) + expect(stored.name).toBe('orca-paste-1.png') + expect(await readFile(stored.path)).toEqual(png) + // 900 KiB in 384 KiB slices. + expect( + calls.filter((call) => call.method === 'agentSessionAttachment.uploadAppend') + ).toHaveLength(3) + }) +}) diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts new file mode 100644 index 000000000000..17fb42734c37 --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -0,0 +1,177 @@ +// Client side of chat attachments on a paired server: main owns the file handles, the clipboard and +// the runtime socket, so it pumps the bytes into the server's attachment store and hands the +// renderer only the server path. + +import { basename } from 'node:path' +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES, + AGENT_SESSION_ATTACHMENT_MAX_BYTES, + type AgentSessionAttachmentPathUploadResult, + type AgentSessionAttachmentUploadCommitResult, + type AgentSessionAttachmentUploadStartResult, + type AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' +import { + isRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} from './runtime-environment-manual-disconnect' +import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { streamExternalFileSlices } from './runtime-upload-file-stream' +import { formatByteCeiling } from './runtime-import-limits' + +const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 + +type UploadContext = AgentSessionAttachmentUploadTarget & { + userDataPath: string + signal?: AbortSignal +} + +async function callAttachmentMethod( + context: UploadContext, + method: string, + params: unknown +): Promise { + if (isRuntimeEnvironmentManuallyDisconnected(context.environmentId)) { + throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE) + } + const response = await callRuntimeEnvironment( + context.userDataPath, + context.environmentId, + method, + params, + ATTACHMENT_CALL_TIMEOUT_MS, + context.expectedEnvironmentPairingRevision, + undefined, + { + expectedEnvironmentRuntimeId: context.expectedEnvironmentRuntimeId, + signal: context.signal + } + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } + return response.result as TResult +} + +/** start → body → commit; any failure aborts, so the server frees the slot and its part file. */ +async function uploadAttachment( + context: UploadContext, + file: { name: string; byteLength: number }, + writeBody: ( + appendSlice: (contentBase64: string, offset: number) => Promise + ) => Promise +): Promise { + const { uploadId } = await callAttachmentMethod( + context, + 'agentSessionAttachment.uploadStart', + { sessionId: context.sessionId, name: file.name, byteLength: file.byteLength } + ) + try { + await writeBody((contentBase64, offset) => + callAttachmentMethod(context, 'agentSessionAttachment.uploadAppend', { + uploadId, + offset, + contentBase64 + }) + ) + return await callAttachmentMethod( + context, + 'agentSessionAttachment.uploadCommit', + { uploadId } + ) + } catch (error) { + await callAttachmentMethod( + { ...context, signal: undefined }, + 'agentSessionAttachment.uploadAbort', + { uploadId } + ).catch(() => {}) + throw error + } +} + +export function uploadBufferToAgentSessionAttachments( + context: UploadContext, + name: string, + buffer: Buffer +): Promise { + return uploadAttachment(context, { name, byteLength: buffer.byteLength }, async (appendSlice) => { + if (buffer.byteLength === 0) { + await appendSlice('', 0) + return + } + for ( + let offset = 0; + offset < buffer.byteLength; + offset += AGENT_SESSION_ATTACHMENT_CHUNK_BYTES + ) { + context.signal?.throwIfAborted() + const slice = buffer.subarray(offset, offset + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES) + await appendSlice(slice.toString('base64'), offset) + } + }) +} + +/** + * Upload dropped or picked client-local files, in input order. A folder is skipped as + * unsupported: a chat attachment is a file. + */ +export async function uploadExternalPathsToAgentSessionAttachments( + context: UploadContext, + paths: readonly string[] +): Promise { + const result: AgentSessionAttachmentPathUploadResult = { uploaded: [], skipped: [], failed: [] } + for (const sourcePath of paths) { + context.signal?.throwIfAborted() + const staged = await stageOneSourceForRuntimeUpload(sourcePath) + if (staged.status === 'skipped') { + result.skipped.push({ sourcePath, reason: staged.reason }) + continue + } + if (staged.status === 'failed') { + result.failed.push({ sourcePath, reason: staged.reason }) + continue + } + const entry = staged.entries[0] + if (staged.kind !== 'file' || !entry || entry.kind !== 'file') { + result.skipped.push({ sourcePath, reason: 'unsupported' }) + continue + } + if (entry.byteLength > AGENT_SESSION_ATTACHMENT_MAX_BYTES) { + result.failed.push({ + sourcePath, + reason: + `'${basename(sourcePath)}' is ${formatByteCeiling(entry.byteLength)}, over the ` + + `${formatByteCeiling(AGENT_SESSION_ATTACHMENT_MAX_BYTES)} chat attachment limit` + }) + continue + } + try { + const stored = await uploadAttachment( + context, + { name: basename(sourcePath), byteLength: entry.byteLength }, + async (appendSlice) => { + await streamExternalFileSlices({ + sourceRootPath: sourcePath, + entryRelativePath: '', + expected: entry, + signal: context.signal, + maxFileBytes: AGENT_SESSION_ATTACHMENT_MAX_BYTES, + limitLabel: 'chat attachment limit', + writeSlice: appendSlice + }) + } + ) + result.uploaded.push({ sourcePath, path: stored.path }) + } catch (error) { + if (context.signal?.aborted) { + throw error + } + result.failed.push({ + sourcePath, + reason: error instanceof Error ? error.message : String(error) + }) + } + } + return result +} diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 07d4de5b56a9..a6dec0f6ff94 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -7,6 +7,7 @@ import { tryDeleteWslUncPath } from '../../wsl-unc-delete' import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import { registerAgentSessionAttachmentUploadHandlers } from '../agent-session-attachment-upload-ipc' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { @@ -84,6 +85,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) + registerAgentSessionAttachmentUploadHandlers() ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { authorizeExternalPath(args.targetPath) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 28271774ce09..50c01800d321 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -36,6 +36,38 @@ export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { */ export async function streamExternalFileToRuntime( args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + return streamExternalFileSlices({ + sourceRootPath: args.sourceRootPath, + entryRelativePath: args.entryRelativePath, + expected: args.expected, + signal: args.signal, + maxFileBytes: REMOTE_IMPORT_MAX_FILE_BYTES, + limitLabel: 'per-file remote import limit', + writeSlice: (contentBase64, offset) => sendChunk(args, contentBase64, offset > 0) + }) +} + +export type ExternalFileSliceStreamArgs = { + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + expected: StagedRuntimeUploadFileIdentity + signal?: AbortSignal + maxFileBytes: number + /** Names the ceiling in the refusal, e.g. "per-file remote import limit". */ + limitLabel: string + /** Sends one base64 slice; `offset` is in source bytes. An empty file sends one empty slice. */ + writeSlice: (contentBase64: string, offset: number) => Promise +} + +/** + * Read a staged client-local file in slices and hand each to `writeSlice`, refusing a source that + * was swapped or changed since staging. The destination decides what a slice becomes. + */ +export async function streamExternalFileSlices( + args: ExternalFileSliceStreamArgs ): Promise<{ byteLength: number }> { const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) @@ -76,16 +108,16 @@ export async function streamExternalFileToRuntime( const totalBytes = openedStat.size // Why: enforced again where the bytes actually move. Staging is a separate // call, so the ceiling only holds here if this boundary checks it too. - if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + if (totalBytes > args.maxFileBytes) { throw new Error( `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + - `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + `${formatByteCeiling(args.maxFileBytes)} ${args.limitLabel}` ) } if (totalBytes === 0) { // Why: a zero-byte source produces no slices, but the destination still // has to exist before commitUpload renames it into place. - await sendChunk(args, '', false) + await args.writeSlice('', 0) } else { const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) let offset = 0 @@ -97,7 +129,7 @@ export async function streamExternalFileToRuntime( if (bytesRead === 0) { throw new Error(`File truncated during upload: '${displayPath}'`) } - await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + await args.writeSlice(buffer.subarray(0, bytesRead).toString('base64'), offset) offset += bytesRead } } diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts new file mode 100644 index 000000000000..1a6b4b733930 --- /dev/null +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -0,0 +1,117 @@ +// A structured chat on a paired server keeps pasted images in that server's attachment store, not +// in its temp directory: a temp path has no owner and nothing ever removes it. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + readImage: vi.fn(), + callRuntimeEnvironment: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/client-user-data') }, + clipboard: { readImage: mocks.readImage }, + ipcMain: { removeHandler: vi.fn(), handle: mocks.handle }, + nativeImage: { createFromBuffer: vi.fn() } +})) +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_userDataPath: string, selector: string) => ({ id: selector }) +})) +vi.mock('./clipboard-remote-file-copy', () => ({ + cleanupExpiredRemoteClipboardFiles: vi.fn(async () => {}), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), + writeRemoteFileToClipboard: vi.fn() +})) +vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) + +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from './clipboard-ipc-handlers' + +function saveHandler(): (...args: unknown[]) => Promise { + const entry = mocks.handle.mock.calls.find( + ([channel]) => channel === 'clipboard:saveImageAsTempFile' + ) + if (!entry) { + throw new Error('save handler was not registered') + } + return entry[1] as (...args: unknown[]) => Promise +} + +const event = { + sender: { + id: 17, + getType: () => 'window', + getURL: () => 'file:///orca/index.html', + isDestroyed: () => false + } +} + +beforeEach(() => { + vi.clearAllMocks() + setTrustedClipboardRendererWebContentsId(17) + mocks.readImage.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => Buffer.from('png') + }) + mocks.callRuntimeEnvironment.mockImplementation(async (_userData, _env, method: string) => { + if (method === 'agentSessionAttachment.uploadStart') { + return { ok: true, result: { uploadId: 'upload-1' } } + } + if (method === 'agentSessionAttachment.uploadAppend') { + return { ok: true, result: { receivedBytes: 3 } } + } + if (method === 'agentSessionAttachment.uploadCommit') { + return { + ok: true, + result: { path: '/srv/agent-session-attachments/s/upload-1/p.png', name: 'p.png' } + } + } + throw new Error(`unexpected method: ${method}`) + }) +}) + +describe('clipboard:saveImageAsTempFile for a structured chat on a paired server', () => { + it("stores the image in the chat's attachment store, pinned to the pairing and server", async () => { + registerClipboardHandlers({} as never) + + await expect( + saveHandler()(event, { + runtimeEnvironmentId: 'remote-host-1', + agentSessionAttachment: { + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + ).resolves.toBe('/srv/agent-session-attachments/s/upload-1/p.png') + + const methods = mocks.callRuntimeEnvironment.mock.calls.map((call) => call[2]) + expect(methods).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + expect(mocks.callRuntimeEnvironment.mock.calls[0][3]).toMatchObject({ + sessionId: 'session-1', + name: expect.stringMatching(/^orca-paste-.+\.png$/), + byteLength: 3 + }) + for (const call of mocks.callRuntimeEnvironment.mock.calls) { + expect(call[5]).toBe(7) + expect(call[7]).toMatchObject({ expectedEnvironmentRuntimeId: 'runtime-a' }) + } + }) + + it('keeps the server temp directory for a paste that names no chat', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ ok: true, result: { uploadId: 'u' } }) + registerClipboardHandlers({} as never) + await saveHandler()(event, { runtimeEnvironmentId: 'remote-host-1' }).catch(() => {}) + expect(mocks.callRuntimeEnvironment.mock.calls[0][2]).toBe('clipboard.startImageUpload') + }) +}) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 0024c4b6d1f8..ea1eb6eb4382 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -7,10 +7,13 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' import { authorizeExternalPath } from '../ipc/filesystem-auth' +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null runtimeEnvironmentId?: string | null + /** With `runtimeEnvironmentId`: store the image as an attachment of this structured chat. */ + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget } const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index e81140d734a6..146000fef076 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -7,6 +7,7 @@ import { type WebContents } from 'electron' import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -38,6 +39,9 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +import { uploadBufferToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' +import { agentSessionPastedImageName } from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' @@ -57,6 +61,20 @@ async function saveClipboardImageBufferForTarget( ): Promise { assertClipboardImageByteLengthWithinLimit(buffer.byteLength) const runtimeEnvironmentId = args?.runtimeEnvironmentId?.trim() + // A structured chat on a paired server keeps its pasted images in that server's store. + if (runtimeEnvironmentId && args?.agentSessionAttachment) { + const userDataPath = app.getPath('userData') + const stored = await uploadBufferToAgentSessionAttachments( + { + ...args.agentSessionAttachment, + environmentId: resolveEnvironment(userDataPath, runtimeEnvironmentId).id, + userDataPath + }, + agentSessionPastedImageName(Date.now(), randomUUID()), + buffer + ) + return stored.path + } // Why (#17679): with a runtime owner, a connectionId names one of the RUNTIME's SSH // connections (nested Remote Server -> SSH), not one this process dialed. Looking it up // in the local provider registry can only miss, so the runtime must perform the save. diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index d8b74cd93043..9063c41b8787 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -25,6 +25,10 @@ import type { PythonEnvironments } from '../../shared/notebook-kernel-types' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' export type ExportApi = { htmlToPdf: (args: { @@ -162,6 +166,10 @@ export type FilesystemApi = { connectionId?: string } & SshMutationExpectation ) => Promise + /** Uploads client-local files into a paired server's store for one structured chat. */ + uploadPathsToAgentSessionAttachments: ( + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ) => Promise watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index d58dd17ff8f9..fcf1d2973271 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -2,6 +2,10 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import type { @@ -174,6 +178,10 @@ export const fsApi = { } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + uploadPathsToAgentSessionAttachments: ( + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ): Promise => + ipcRenderer.invoke('fs:uploadPathsToAgentSessionAttachments', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 51957a5eec5a..fb80df81622b 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { ipcRenderer, webFrame } from 'electron' import type { RuntimeMobileMarkdownRequest, @@ -93,6 +94,7 @@ export const uiClipboardAndWindowControlsApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), clipboardHasImage: (): Promise => ipcRenderer.invoke('clipboard:hasImage'), readClipboardFilePaths: (): Promise => ipcRenderer.invoke('clipboard:readFilePaths'), diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index c36896a2481d..f04ef2ec2f3b 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import type { NativeFileDropPayload } from '../../shared/native-file-drop' @@ -12,6 +13,7 @@ export type UiWindowApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget }) => Promise clipboardHasImage: () => Promise /** Paths of files a file manager copied; empty when there are none or the host cannot list them. */ diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts new file mode 100644 index 000000000000..b671ad803b1e --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts @@ -0,0 +1,62 @@ +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + agentSessionPastedImageName, + type AgentSessionAttachmentClipboardTarget, + type AgentSessionAttachmentUploadCommitResult +} from '../../../../shared/agent-session-attachments' +import { callRuntimeResult } from './web-runtime-calls' +import { createBrowserUuid } from '@/lib/browser-uuid' + +const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 + +/** The browser client's paste into a structured chat: the same store the desktop client uses. */ +export async function saveClipboardImageAsWebAgentSessionAttachment( + contentBase64: string, + target: AgentSessionAttachmentClipboardTarget +): Promise { + const padding = contentBase64.endsWith('==') ? 2 : contentBase64.endsWith('=') ? 1 : 0 + const byteLength = (contentBase64.length / 4) * 3 - padding + const { uploadId } = await callRuntimeResult<{ uploadId: string }>( + 'agentSessionAttachment.uploadStart', + { + sessionId: target.sessionId, + name: agentSessionPastedImageName(Date.now(), createBrowserUuid()), + byteLength + }, + ATTACHMENT_CALL_TIMEOUT_MS + ) + try { + // Chunks are a multiple of 4 base64 characters, so each starts on a byte boundary. + for ( + let charOffset = 0; + charOffset < Math.max(contentBase64.length, 1); + charOffset += AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) { + await callRuntimeResult( + 'agentSessionAttachment.uploadAppend', + { + uploadId, + offset: (charOffset / 4) * 3, + contentBase64: contentBase64.slice( + charOffset, + charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) + }, + ATTACHMENT_CALL_TIMEOUT_MS + ) + } + const stored = await callRuntimeResult( + 'agentSessionAttachment.uploadCommit', + { uploadId }, + ATTACHMENT_CALL_TIMEOUT_MS + ) + return stored.path + } catch (error) { + await callRuntimeResult( + 'agentSessionAttachment.uploadAbort', + { uploadId }, + ATTACHMENT_CALL_TIMEOUT_MS + ).catch(() => {}) + throw error + } +} diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index 889e89b5103c..7c188199f7c7 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -132,6 +132,7 @@ export function createFileApi(): NonNullable['fs']> { throw new Error('Uploading local files is not supported in the web client') }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), + uploadPathsToAgentSessionAttachments: async () => ({ uploaded: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), onFsChanged: () => noopUnsubscribe diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index c92f741ba386..8aad5ee70d55 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -1,3 +1,5 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../../../shared/agent-session-attachments' +import { saveClipboardImageAsWebAgentSessionAttachment } from './web-agent-session-attachment-upload' import { createWebExplorerRootSync } from './web-explorer-root-sync' import type { PreloadApi } from '../../../../preload/api-types' import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text' @@ -149,6 +151,7 @@ export function createWebUiApi(): NonNullable['ui']> { saveClipboardImageAsTempFile: async (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget }) => { if (!requireActiveEnvironmentOrNull()) { return null @@ -157,6 +160,12 @@ export function createWebUiApi(): NonNullable['ui']> { if (!contentBase64) { return null } + if (args?.agentSessionAttachment) { + return saveClipboardImageAsWebAgentSessionAttachment( + contentBase64, + args.agentSessionAttachment + ) + } return saveClipboardImageAsTempFileInRuntime(contentBase64, args) }, clipboardHasImage, From 9e25d1fc8d5571670dc2f490e90cecd2b2a91f36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:37:02 -0700 Subject: [PATCH 03/40] feat(native-chat): attach files to a structured chat on a paired server Pasted images, files dropped from Finder/Explorer and the file picker no longer refuse with "Local attachments are not available for remote sessions." in a structured chat on a paired server: they upload into that server's chat store and the chat gets only the stored path. Dropped files show as pending chips at once so Send waits for them; every attached item carries the server, pairing and chat it was stored for, and a send to anywhere else drops it with the existing "changed hosts" notice. Chips and transcript images read stored files back through the server, never from this machine's disk. An older server gets an update notice instead of an upload. The terminal-backed chat keeps refusing. --- .../native-chat/NativeChatComposer.tsx | 5 + .../native-chat/NativeChatComposerField.tsx | 6 + .../NativeChatImageAttachmentPreview.test.tsx | 32 ++- .../NativeChatImageAttachmentPreview.tsx | 29 ++- .../native-chat-attachment-destination.ts | 99 ++++++++ .../native-chat-attachment-upload.test.ts | 69 +++++- .../native-chat-attachment-upload.ts | 130 ++++++++++- .../native-chat-clipboard-image-save.ts | 73 ++++++ .../native-chat-composer-drop-scope.test.tsx | 1 + .../native-chat-resolved-path-ownership.ts | 22 +- .../native-chat-session-attachment-drop.ts | 104 +++++++++ ...ession-withdrawn-attachment-owner.test.tsx | 83 +++++++ ...agent-session-withdrawn-message-restore.ts | 35 ++- ...native-chat-attachment-send-guard.test.tsx | 96 ++++++++ .../use-native-chat-attachment-send-guard.ts | 66 ++++++ .../use-native-chat-composer-attachments.ts | 84 +++++-- ...omposer-paste-session-attachments.test.tsx | 216 ++++++++++++++++++ .../use-native-chat-composer-paste.ts | 74 +++--- .../use-native-chat-composer-submit.test.tsx | 3 + .../use-native-chat-composer-submit.ts | 16 +- ...-native-chat-external-attachments.test.tsx | 167 +++++++++++++- .../use-native-chat-external-attachments.ts | 85 +++++-- ...e-native-chat-resolved-path-attachments.ts | 39 +++- .../use-structured-agent-session-outbox.ts | 6 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/runtime/runtime-file-client.test.ts | 29 +++ .../src/runtime/runtime-file-read-client.ts | 16 ++ 32 files changed, 1483 insertions(+), 108 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-attachment-destination.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..94ee17dd205f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -192,8 +192,10 @@ const NativeChatComposerPane = forwardRef { mocks.useLocalImageSrc.mockReturnValue(undefined) renderPreview({ id: 'a1', path: '', previewUrl: 'blob:clipboard-1', pending: true }) - expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined) + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined, undefined) + }) + + // The path names a file on the paired server; this machine's disk must never be asked for it. + it('reads a stored chip back through the server that holds it', () => { + mocks.useLocalImageSrc.mockReturnValue('blob:from-server') + const path = '/srv/agent-session-attachments/s/u1/shot.png' + renderPreview({ + id: 'a1', + path, + hostOwner: { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } + }) + + for (const call of mocks.useLocalImageSrc.mock.calls) { + expect(call[3]).toEqual({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: null, + worktreePath: null + }) + } + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(path, path, undefined, expect.anything()) + expect(screen.getByRole('img', { name: 'shot.png' }).getAttribute('src')).toBe( + 'blob:from-server' + ) + }) + + it('labels a dropped file while it uploads', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '', pending: true, pendingName: 'notes.md' }) + + expect(screen.getByRole('button', { name: /^Uploading .* file\(s\) to remote…$/ })).toBeTruthy() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 52e9a5f7c3a1..01940b7d8911 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Image as ImageIcon, Loader2, X } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' @@ -42,10 +42,24 @@ export function NativeChatImageAttachmentPreview({ return () => observer.disconnect() }, []) const isPending = attachment.pending === true + // A stored attachment is a path on the paired server, read back through that server. + const hostEnvironmentId = attachment.hostOwner?.environmentId + const hostReadContext = useMemo( + () => + hostEnvironmentId + ? { + settings: { activeRuntimeEnvironmentId: hostEnvironmentId }, + worktreeId: null, + worktreePath: null + } + : undefined, + [hostEnvironmentId] + ) const localSrc = useLocalImageSrc( !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, attachment.path, - attachment.connectionId + attachment.connectionId, + hostReadContext ) // The clipboard thumbnail is already in this process, so it renders with no // round-trip; the on-disk file only wins for the full-size dialog. @@ -54,10 +68,13 @@ export function NativeChatImageAttachmentPreview({ const filename = isNativeChatPastedImagePath(attachment.path) ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') : basename(attachment.path) - const pendingLabel = translate( - 'components.native-chat.composer.imageSaving', - 'Saving pasted image…' - ) + const pendingLabel = attachment.pendingName + ? translate( + 'components.native-chat.composer.uploadingAttachments', + 'Uploading {{value0}} file(s) to remote…', + { value0: 1 } + ) + : translate('components.native-chat.composer.imageSaving', 'Saving pasted image…') const label = isPending ? pendingLabel : filename return ( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-destination.ts b/src/renderer/src/components/native-chat/native-chat-attachment-destination.ts new file mode 100644 index 000000000000..9f816091b976 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-attachment-destination.ts @@ -0,0 +1,99 @@ +// What a send checks about attachments uploaded into a paired server's store: each one was stored +// for one server and one chat, so a composer whose chat now runs elsewhere must not send it. + +import type { NativeChatAttachmentHostOwner } from './native-chat-attachment-upload' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { nativeChatAttachmentHostOwnerMatches } from './native-chat-resolved-path-ownership' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + +/** An `@path` a stored non-image upload put into the draft. */ +export type NativeChatHostOwnedReference = { + reference: string + hostOwner: NativeChatAttachmentHostOwner +} + +/** Where a send goes: the chat's server, pairing and id, or null for a chat on this machine. */ +export type NativeChatAttachmentDestination = { + environmentId: string + /** Undefined once the server is no longer paired: nothing stored for it can match. */ + pairingRevision: number | undefined + sessionId: string +} | null + +// Per composer scope, like the draft the references sit in, so they survive the same remounts. +const referenceCache = new Map() + +export function recordNativeChatHostOwnedReferences( + scopeKey: string, + references: readonly NativeChatHostOwnedReference[] +): void { + if (references.length === 0) { + return + } + setBoundedScopeCacheEntry(referenceCache, scopeKey, [ + ...(referenceCache.get(scopeKey) ?? []), + ...references + ]) +} + +export function readNativeChatHostOwnedReferences( + scopeKey: string +): readonly NativeChatHostOwnedReference[] { + return referenceCache.get(scopeKey) ?? [] +} + +export function clearNativeChatHostOwnedReferences(scopeKey: string): void { + referenceCache.delete(scopeKey) +} + +function ownerFitsDestination( + hostOwner: NativeChatAttachmentHostOwner, + destination: NativeChatAttachmentDestination +): boolean { + return ( + destination !== null && + destination.pairingRevision !== undefined && + nativeChatAttachmentHostOwnerMatches(hostOwner, { + environmentId: destination.environmentId, + pairingRevision: destination.pairingRevision, + sessionId: destination.sessionId + }) + ) +} + +/** Stored attachments a send to `destination` must not carry. Attachments with no store owner + * (this machine's files, SSH uploads, workspace files) keep the checks they already have. */ +export function nativeChatAttachmentsForeignToDestination(args: { + chips: readonly NativeChatComposerImageAttachment[] + references: readonly NativeChatHostOwnedReference[] + draft: string + destination: NativeChatAttachmentDestination +}): { chipIds: string[]; references: string[] } { + return { + chipIds: args.chips + .filter((chip) => chip.hostOwner && !ownerFitsDestination(chip.hostOwner, args.destination)) + .map((chip) => chip.id), + references: args.references + .filter( + ({ reference, hostOwner }) => + args.draft.includes(reference) && !ownerFitsDestination(hostOwner, args.destination) + ) + .map(({ reference }) => reference) + } +} + +/** The draft without these references, including the space inserted after each. */ +export function stripNativeChatFileReferences( + draft: string, + references: readonly string[] +): string { + let next = draft + for (const reference of references) { + next = next.split(`${reference} `).join('').split(reference).join('') + } + return next +} + +export function clearNativeChatHostOwnedReferencesForTests(): void { + referenceCache.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index c477f343ca20..76e9be071a2c 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -7,9 +7,12 @@ const mocks = vi.hoisted(() => ({ toastDismiss: vi.fn(), toastError: vi.fn(), toastMessage: vi.fn(), - resolveDroppedPathsForAgent: vi.fn() + resolveDroppedPathsForAgent: vi.fn(), + callRuntimeRpc: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) + vi.mock('sonner', () => ({ toast: { loading: mocks.toastLoading, @@ -24,10 +27,14 @@ vi.mock('@/i18n/i18n', () => ({ })) import { + prepareNativeChatSessionAttachmentUpload, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, + resolveNativeChatRuntimeSessionAttachmentOwner, uploadNativeChatAttachmentPaths } from './native-chat-attachment-upload' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' function terminalTab(overrides: Partial = {}): TerminalTab { return { @@ -225,3 +232,63 @@ describe('uploadNativeChatAttachmentPaths', () => { expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') }) }) + +describe('a structured chat on a paired server', () => { + const owner = { + kind: 'runtime-session' as const, + environmentId: 'env-1', + pairingRevision: 7, + sessionId: 'session-1' + } + + beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) + mocks.callRuntimeRpc.mockReset() + }) + + it('owns its attachments by the server it runs on and that pairing', () => { + expect( + resolveNativeChatRuntimeSessionAttachmentOwner({ + sessionId: 'session-1', + runtimeEnvironmentId: 'env-1' + }) + ).toEqual(owner) + expect( + resolveNativeChatRuntimeSessionAttachmentOwner({ + sessionId: 'session-1', + runtimeEnvironmentId: 'env-gone' + }) + ).toEqual({ kind: 'not-ready' }) + }) + + it('asks the server, pinned to the pairing, and uploads only where it keeps a store', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + runtimeId: 'runtime-a', + capabilities: [AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY] + }) + await expect(prepareNativeChatSessionAttachmentUpload(owner)).resolves.toEqual({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + expect.objectContaining({ expectedEnvironmentPairingRevision: 7 }) + ) + }) + + it('tells the user to update an older server instead of uploading', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ runtimeId: 'runtime-a', capabilities: [] }) + await expect(prepareNativeChatSessionAttachmentUpload(owner)).resolves.toEqual({ + ok: false, + notice: + 'Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.' + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index b7564ba7d9ca..d70688813ce3 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -1,13 +1,19 @@ -// SSH-aware resolution for composer attachments (STA-1465). The composer's +// Host-aware resolution for composer attachments (STA-1465). The composer's // attach surfaces (file drop, file picker, image paste) receive client-local // paths, but an SSH worktree's agent runs on the remote host — local paths must -// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). +// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). A +// structured chat on a paired server uploads into that server's attachment store. import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { getConnectionIdFromState } from '@/lib/connection-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report' import { @@ -25,11 +31,25 @@ export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { worktreePath: string } +/** Which paired server's store holds an uploaded attachment, and for which chat. Carried by each + * attached item so a send can refuse one meant for another host. */ +export type NativeChatAttachmentHostOwner = { + environmentId: string + pairingRevision: number + sessionId: string +} + +/** A structured chat on a paired server: files upload into that server's attachment store. */ +export type NativeChatRuntimeSessionAttachmentOwner = NativeChatAttachmentHostOwner & { + kind: 'runtime-session' +} + export type NativeChatAttachmentOwner = | { kind: 'local' } | NativeChatSshAttachmentOwner - /** Runtime-owned (`remote:`) panes keep the composer's existing - * local-attachment block; runtime upload support is a separate seam. */ + | NativeChatRuntimeSessionAttachmentOwner + /** A terminal-backed chat on a paired server: its agent may run on that server's SSH or WSL + * host, which the server's store cannot reach, so it keeps refusing client files. */ | { kind: 'runtime' } /** Store not hydrated / worktree unknown. Callers must not attach local * paths in this window — the worktree may turn out to be remote, and the @@ -96,6 +116,40 @@ export function resolveNativeChatAttachmentOwnerForWorktree( } } +/** The chat behind a structured composer, as the attachment owner needs it. */ +export type NativeChatStructuredAttachmentSession = { + sessionId: string + /** Null: the chat runs on this machine. */ + runtimeEnvironmentId: string | null +} + +/** A structured chat on a paired server owns its attachments by where it runs, not by worktree. */ +export function resolveNativeChatRuntimeSessionAttachmentOwner(session: { + sessionId: string + runtimeEnvironmentId: string +}): NativeChatAttachmentOwner { + const pairingRevision = getRuntimeEnvironmentRevision(session.runtimeEnvironmentId) + if (pairingRevision === undefined) { + return { kind: 'not-ready' } + } + return { + kind: 'runtime-session', + environmentId: session.runtimeEnvironmentId, + pairingRevision, + sessionId: session.sessionId + } +} + +export function nativeChatAttachmentHostOwner( + owner: NativeChatRuntimeSessionAttachmentOwner +): NativeChatAttachmentHostOwner { + return { + environmentId: owner.environmentId, + pairingRevision: owner.pairingRevision, + sessionId: owner.sessionId + } +} + export function nativeChatWorktreeNotReadyNotice(): string { return translate( 'components.native-chat.composer.worktreeNotReady', @@ -117,6 +171,13 @@ export function nativeChatAttachmentUnreadableNotice(): string { ) } +export function nativeChatAttachmentsNeedNewerServerNotice(): string { + return translate( + 'components.native-chat.composer.attachmentsNeedNewerServer', + 'Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.' + ) +} + export function nativeChatLocalAttachmentUnsupportedNotice(): string { return translate( 'components.native-chat.composer.localAttachmentUnsupported', @@ -159,3 +220,64 @@ export async function uploadNativeChatAttachmentPaths( toast.dismiss(pending) } } + +/** + * Ask the chat's server, right before uploading, whether it keeps chat attachments, and which + * server process will receive the bytes. An older server gets the update notice instead of an + * upload: a path from this machine would mean nothing to its agent. + */ +export async function prepareNativeChatSessionAttachmentUpload( + owner: NativeChatRuntimeSessionAttachmentOwner +): Promise< + { ok: true; target: AgentSessionAttachmentUploadTarget } | { ok: false; notice: string } +> { + const status = await callRuntimeRpc( + { kind: 'environment', environmentId: owner.environmentId }, + 'status.get', + undefined, + { timeoutMs: 15_000, expectedEnvironmentPairingRevision: owner.pairingRevision } + ) + if (!status.capabilities?.includes(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY)) { + return { ok: false, notice: nativeChatAttachmentsNeedNewerServerNotice() } + } + return { + ok: true, + target: { + environmentId: owner.environmentId, + sessionId: owner.sessionId, + expectedEnvironmentPairingRevision: owner.pairingRevision, + expectedEnvironmentRuntimeId: status.runtimeId + } + } +} + +/** + * Upload client-local files into the chat's store on its paired server. Returns the stored paths + * by source path, or null when the upload IPC itself failed; per-file skips/failures surface + * through the shared drop toasts, as SSH uploads do. + */ +export async function uploadNativeChatSessionAttachmentPaths( + paths: string[], + target: AgentSessionAttachmentUploadTarget +): Promise | null> { + const pending = toast.loading( + translate( + 'components.native-chat.composer.uploadingAttachments', + 'Uploading {{value0}} file(s) to remote…', + { value0: paths.length } + ) + ) + try { + const { uploaded, skipped, failed } = await window.api.fs.uploadPathsToAgentSessionAttachments({ + ...target, + paths + }) + reportTerminalDropUploadSkipsAndFailures(skipped, failed) + return new Map(uploaded.map(({ sourcePath, path }) => [sourcePath, path])) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + return null + } finally { + toast.dismiss(pending) + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts new file mode 100644 index 000000000000..b6dc0ff96ea6 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -0,0 +1,73 @@ +import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { + nativeChatLocalAttachmentUnsupportedNotice, + prepareNativeChatSessionAttachmentUpload, + type NativeChatAttachmentOwner +} from './native-chat-attachment-upload' + +type ClipboardImageOwner = Extract< + NativeChatAttachmentOwner, + { kind: 'local' | 'ssh' | 'runtime-session' } +> + +/** Owners whose attachment path is a file this client can write right now. */ +export function ownerAcceptsClipboardImage( + owner: NativeChatAttachmentOwner +): owner is ClipboardImageOwner { + return owner.kind === 'local' || owner.kind === 'ssh' || owner.kind === 'runtime-session' +} + +/** Where the clipboard image is saved: this machine, the SSH host, or the chat's store on its + * paired server, after asking that server whether it keeps one. */ +async function clipboardImageSaveArgs( + owner: ClipboardImageOwner +): Promise< + | { ok: true; args: Parameters[0] } + | { ok: false; notice: string } +> { + if (owner.kind === 'local') { + return { ok: true, args: undefined } + } + if (owner.kind === 'ssh') { + return { ok: true, args: { connectionId: owner.connectionId } } + } + const prepared = await prepareNativeChatSessionAttachmentUpload(owner) + if (!prepared.ok) { + return prepared + } + const { environmentId, ...agentSessionAttachment } = prepared.target + return { ok: true, args: { runtimeEnvironmentId: environmentId, agentSessionAttachment } } +} + +/** Save the clipboard image where the owner's agent can read it. Every failure is reported. */ +export async function saveNativeChatClipboardImage( + owner: NativeChatAttachmentOwner, + report: { setNotice: (notice: string) => void } +): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> { + if (!ownerAcceptsClipboardImage(owner)) { + report.setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return { status: 'failed' } + } + try { + // SSH panes save the image on the remote host (SFTP) so the attached + // path is readable by the remote agent, matching terminal image paste. + const target = await clipboardImageSaveArgs(owner) + if (!target.ok) { + report.setNotice(target.notice) + return { status: 'failed' } + } + const tempPath = await window.api.ui.saveClipboardImageAsTempFile(target.args) + return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } + } catch (error) { + // A failed save must be visible: over SSH it fails whenever the + // connection drops, and a silent no-op reads as a broken paste. + report.setNotice( + extractIpcErrorMessage( + error, + translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') + ) + ) + return { status: 'failed' } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 508a7c20e5ea..57f0996b18b6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -72,6 +72,7 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea terminalTabId: pane, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, + pendingChips: attachments.pendingChips, setNotice }) useNativeChatFileAttachmentActions(pane, attachExternalPaths) diff --git a/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts b/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts index 86290338167d..4654f512b63f 100644 --- a/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts +++ b/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts @@ -1,9 +1,26 @@ import { translate } from '@/i18n/i18n' -import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import type { + NativeChatAttachmentHostOwner, + NativeChatAttachmentOwner +} from './native-chat-attachment-upload' export type NativeChatResolvedPathOptions = { /** Revalidates internal path ownership when an IME-delayed attachment is applied. */ targetOwnerIsCurrent?: () => boolean + /** The paired server store these paths were uploaded into; checked again at send. */ + hostOwner?: NativeChatAttachmentHostOwner +} + +/** Same server, same pairing, same chat: a reconnect or server restart keeps all three. */ +export function nativeChatAttachmentHostOwnerMatches( + a: NativeChatAttachmentHostOwner, + b: NativeChatAttachmentHostOwner +): boolean { + return ( + a.environmentId === b.environmentId && + a.pairingRevision === b.pairingRevision && + a.sessionId === b.sessionId + ) } export function nativeChatWorkspaceAttachmentMismatchNotice(): string { @@ -22,6 +39,9 @@ export function nativeChatAttachmentOwnerUnchanged( if (captured.kind !== current.kind || captured.kind === 'not-ready') { return false } + if (captured.kind === 'runtime-session' && current.kind === 'runtime-session') { + return nativeChatAttachmentHostOwnerMatches(captured, current) + } if (captured.kind !== 'ssh' || current.kind !== 'ssh') { return true } diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts new file mode 100644 index 000000000000..6caeca79c5b6 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -0,0 +1,104 @@ +import { toast } from 'sonner' +import { basename } from '@/lib/path' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' +import { + nativeChatAttachmentHostOwner, + nativeChatAttachmentOwnerChangedNotice, + nativeChatAttachmentUnreadableNotice, + prepareNativeChatSessionAttachmentUpload, + uploadNativeChatSessionAttachmentPaths, + type NativeChatAttachmentHostOwner, + type NativeChatRuntimeSessionAttachmentOwner +} from './native-chat-attachment-upload' +import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' + +/** The composer's pending-chip controls, as an upload drives them. */ +export type NativeChatPendingAttachmentChips = { + begin: (previewUrl?: string, pendingName?: string) => string | null + resolve: ( + id: string, + path: string, + connectionId?: string | null, + hostOwner?: NativeChatAttachmentHostOwner + ) => void + drop: (id: string) => void +} + +/** + * Drop or pick files into a structured chat on a paired server: upload them into the chat's store + * there, then attach the stored paths. Each file shows as a pending chip at once, and Send waits + * for pending chips, so a message never leaves without a file the user attached. + */ +export async function attachNativeChatSessionAttachmentPaths(args: { + paths: string[] + owner: NativeChatRuntimeSessionAttachmentOwner + chips: NativeChatPendingAttachmentChips + /** The composer was disabled or torn down meanwhile. */ + isAbandoned: () => boolean + ownerStillCurrent: () => boolean + attachResolvedPaths: ( + paths: string[], + connectionId?: string | null, + options?: NativeChatResolvedPathOptions + ) => void + setNotice: (notice: string | null) => void +}): Promise { + const pending = args.paths.map((path) => ({ + path, + chipId: args.chips.begin(undefined, basename(path)) + })) + const dropAll = (): void => { + for (const { chipId } of pending) { + if (chipId) { + args.chips.drop(chipId) + } + } + } + let stored: Map | null + try { + const prepared = await prepareNativeChatSessionAttachmentUpload(args.owner) + if (!prepared.ok) { + dropAll() + args.setNotice(prepared.notice) + return + } + stored = await uploadNativeChatSessionAttachmentPaths(args.paths, prepared.target) + } catch (error) { + dropAll() + toast.error(extractIpcErrorMessage(error, 'Failed to upload files.')) + return + } + if (args.isAbandoned() || !stored) { + // A failed upload already said so in its toast. + dropAll() + return + } + if (!args.ownerStillCurrent()) { + dropAll() + args.setNotice(nativeChatAttachmentOwnerChangedNotice()) + return + } + if (stored.size === 0) { + dropAll() + args.setNotice(nativeChatAttachmentUnreadableNotice()) + return + } + const hostOwner = nativeChatAttachmentHostOwner(args.owner) + const unchipped: string[] = [] + for (const { path, chipId } of pending) { + const storedPath = stored.get(path) + if (storedPath && chipId && isNativeChatImageAttachmentPath(storedPath)) { + args.chips.resolve(chipId, storedPath, null, hostOwner) + continue + } + if (chipId) { + args.chips.drop(chipId) + } + if (storedPath) { + unchipped.push(storedPath) + } + } + // Other files become `@path` references, as every attach does; images without a chip get one. + args.attachResolvedPaths(unchipped, null, { hostOwner }) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx new file mode 100644 index 000000000000..bb3a5e3dee77 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx @@ -0,0 +1,83 @@ +// A withdrawn message's images go back into the composer. On a paired server they are files in that +// server's attachment store, so the chips must keep that owner: their previews read from the server +// and a later send is checked against it, never against this machine's disk. + +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { writeOutbox } from './structured-agent-session-outbox-storage' +import { useStructuredAgentSessionWithdrawnRestore } from './structured-agent-session-withdrawn-message-restore' +import { + clearNativeChatAttachmentCacheForTests, + readNativeChatAttachmentCache +} from './use-native-chat-composer-attachments' + +const SESSION = 'session-1' +const PANE = 'tab-1::session-1' +const STORED = '/srv/orca/agent-session-attachments/abc/u1/shot.png' +const WORKSPACE_IMAGE = '/remote/repo/diagram.png' + +function withdrawn(): StructuredAgentSessionOutboxEntry { + return { + clientMessageId: 'message-1', + sessionId: SESSION, + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'image-ref', path: STORED }, + { type: 'image-ref', path: WORKSPACE_IMAGE }, + { type: 'text', text: 'look' } + ] + }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } +} + +beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) + writeOutbox(SESSION, [withdrawn()]) +}) + +afterEach(() => { + writeOutbox(SESSION, []) + clearNativeChatAttachmentCacheForTests() +}) + +describe('withdrawn images from a chat on a paired server', () => { + it('come back owned by the server store that holds them', () => { + const { result } = renderHook(() => + useStructuredAgentSessionWithdrawnRestore( + SESSION, + { kind: 'environment', environmentId: 'env-1' }, + PANE + ) + ) + result.current.byStop([withdrawn()]) + + const chips = readNativeChatAttachmentCache(PANE) + expect(chips.find((chip) => chip.path === STORED)?.hostOwner).toEqual({ + environmentId: 'env-1', + pairingRevision: 7, + sessionId: SESSION + }) + // A workspace file keeps the checks it already had. + expect(chips.find((chip) => chip.path === WORKSPACE_IMAGE)?.hostOwner).toBeUndefined() + }) + + it('come back unowned from a chat on this machine', () => { + const { result } = renderHook(() => + useStructuredAgentSessionWithdrawnRestore(SESSION, { kind: 'local' }, PANE) + ) + result.current.byStop([withdrawn()]) + + expect(readNativeChatAttachmentCache(PANE).every((chip) => !chip.hostOwner)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 40e69565ef07..61dd07acbf25 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -5,6 +5,9 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' /** * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, @@ -14,6 +17,7 @@ import { appendNativeChatAttachmentCache } from './use-native-chat-composer-atta */ function restoreWithdrawnMessages( sessionId: string, + target: RuntimeClientTarget, composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] ): void { @@ -35,17 +39,33 @@ function restoreWithdrawnMessages( ) appendNativeChatAttachmentCache( composerScopeKey, - blocks.flatMap((block, index) => - block.type === 'image-ref' && block.path - ? [{ id: `withdrawn-${entry.clientMessageId}-${index}`, path: block.path }] - : [] - ) + blocks.flatMap((block, index) => { + if (block.type !== 'image-ref' || !block.path) { + return [] + } + const id = `withdrawn-${entry.clientMessageId}-${index}` + if (target.kind === 'local' || !isAgentSessionAttachmentStorePath(block.path)) { + return [{ id, path: block.path }] + } + // Stored on the paired server: the chip keeps its owner, so it is read and sent there only. + const pairingRevision = getRuntimeEnvironmentRevision(target.environmentId) + return pairingRevision === undefined + ? [] + : [ + { + id, + path: block.path, + hostOwner: { environmentId: target.environmentId, pairingRevision, sessionId } + } + ] + }) ) } } export function useStructuredAgentSessionWithdrawnRestore( sessionId: string, + target: RuntimeClientTarget, /** Absent where no composer shows this session; the entries are then only dropped. */ composerScopeKey: string | undefined ): { @@ -72,12 +92,13 @@ export function useStructuredAgentSessionWithdrawnRestore( ) restoreWithdrawnMessages( sessionId, + target, composerScopeKey, entries.filter((entry) => withdrawn.has(entry.clientMessageId)) ) }, - byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) + byStop: (entries) => restoreWithdrawnMessages(sessionId, target, composerScopeKey, entries) }), - [composerScopeKey, sessionId] + [composerScopeKey, sessionId, target] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx new file mode 100644 index 000000000000..0bfa3f4456b4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { + clearNativeChatHostOwnedReferencesForTests, + recordNativeChatHostOwnedReferences +} from './native-chat-attachment-destination' +import { useNativeChatAttachmentSendGuard } from './use-native-chat-attachment-send-guard' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const SCOPE = 'pane-1' +const OWNER = { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } +const STORED_IMAGE = '/srv/agent-session-attachments/s/u1/shot.png' +const STORED_FILE_REF = '@/srv/agent-session-attachments/s/u2/notes.md' + +function transport(runtimeEnvironmentId: string | null): NativeChatStructuredComposerTransport { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guard reads only sessionId and runtimeEnvironmentId. + return { sessionId: 'session-1', runtimeEnvironmentId } as NativeChatStructuredComposerTransport +} + +const storedChip: NativeChatComposerImageAttachment = { + id: 'chip-1', + path: STORED_IMAGE, + hostOwner: OWNER +} +const workspaceChip: NativeChatComposerImageAttachment = { id: 'chip-2', path: '/wt/a.png' } + +function harness(args: { runtimeEnvironmentId: string | null; draft: string }) { + const calls = { + sendStructured: vi.fn(), + setDraft: vi.fn(), + removeImageAttachment: vi.fn(), + setNotice: vi.fn() + } + const { result } = renderHook(() => + useNativeChatAttachmentSendGuard({ + scopeKey: SCOPE, + structuredTransport: transport(args.runtimeEnvironmentId), + draft: args.draft, + ...calls + }) + ) + return { send: result.current, calls } +} + +beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) + recordNativeChatHostOwnedReferences(SCOPE, [{ reference: STORED_FILE_REF, hostOwner: OWNER }]) +}) + +afterEach(() => { + clearNativeChatHostOwnedReferencesForTests() +}) + +describe('useNativeChatAttachmentSendGuard', () => { + it('sends attachments stored for this chat on this server', () => { + const draft = `see ${STORED_FILE_REF} ` + const { send, calls } = harness({ runtimeEnvironmentId: 'env-1', draft }) + send(draft, [storedChip, workspaceChip]) + expect(calls.sendStructured).toHaveBeenCalledExactlyOnceWith(draft, [storedChip, workspaceChip]) + expect(calls.setNotice).not.toHaveBeenCalled() + }) + + it('drops stored attachments and holds the send after the server was re-paired', () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 8 }]) + const draft = `see ${STORED_FILE_REF} please` + const { send, calls } = harness({ runtimeEnvironmentId: 'env-1', draft }) + send(draft, [storedChip, workspaceChip]) + expect(calls.sendStructured).not.toHaveBeenCalled() + expect(calls.removeImageAttachment).toHaveBeenCalledExactlyOnceWith('chip-1') + expect(calls.setDraft).toHaveBeenCalledExactlyOnceWith('see please') + expect(calls.setNotice).toHaveBeenCalledWith( + 'This workspace changed hosts while attaching — drop the files again.' + ) + }) + + it('never sends a stored attachment to a chat on this machine', () => { + const { send, calls } = harness({ runtimeEnvironmentId: null, draft: 'x' }) + send('x', [storedChip]) + expect(calls.sendStructured).not.toHaveBeenCalled() + expect(calls.removeImageAttachment).toHaveBeenCalledExactlyOnceWith('chip-1') + }) + + it('ignores a stored reference the user already deleted from the draft', () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 8 }]) + const { send, calls } = harness({ runtimeEnvironmentId: 'env-1', draft: 'plain text' }) + send('plain text', [workspaceChip]) + expect(calls.sendStructured).toHaveBeenCalledExactlyOnceWith('plain text', [workspaceChip]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts b/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts new file mode 100644 index 000000000000..7037f3d0e8d7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { nativeChatAttachmentOwnerChangedNotice } from './native-chat-attachment-upload' +import { + nativeChatAttachmentsForeignToDestination, + readNativeChatHostOwnedReferences, + stripNativeChatFileReferences +} from './native-chat-attachment-destination' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +/** + * Right before a structured send, every attachment stored on a paired server is checked against + * where this chat runs now. One stored for another server, pairing or chat is dropped, and the + * message waits for the user to attach the files again; a reconnect to the same server keeps them. + */ +export function useNativeChatAttachmentSendGuard(args: { + scopeKey: string + structuredTransport?: NativeChatStructuredComposerTransport + draft: string + setDraft: (value: string) => void + removeImageAttachment: (id: string) => void + setNotice: (notice: string | null) => void + sendStructured: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void +}): (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void { + const { scopeKey, structuredTransport, draft, setDraft, removeImageAttachment, setNotice } = args + const { sendStructured } = args + return useCallback( + (text, attachments) => { + const environmentId = structuredTransport?.runtimeEnvironmentId ?? null + const foreign = nativeChatAttachmentsForeignToDestination({ + chips: attachments, + references: readNativeChatHostOwnedReferences(scopeKey), + draft, + destination: + environmentId && structuredTransport + ? { + environmentId, + pairingRevision: getRuntimeEnvironmentRevision(environmentId), + sessionId: structuredTransport.sessionId + } + : null + }) + if (foreign.chipIds.length === 0 && foreign.references.length === 0) { + sendStructured(text, attachments) + return + } + for (const chipId of foreign.chipIds) { + removeImageAttachment(chipId) + } + if (foreign.references.length > 0) { + setDraft(stripNativeChatFileReferences(draft, foreign.references)) + } + setNotice(nativeChatAttachmentOwnerChangedNotice()) + }, + [ + draft, + removeImageAttachment, + scopeKey, + sendStructured, + setDraft, + setNotice, + structuredTransport + ] + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 341e242901fa..80a88d0881d7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,6 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' -import { translate } from '@/i18n/i18n' +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget @@ -9,6 +8,16 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' +import { + nativeChatLocalAttachmentUnsupportedNotice, + type NativeChatAttachmentHostOwner +} from './native-chat-attachment-upload' +import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import { + clearNativeChatHostOwnedReferences, + recordNativeChatHostOwnedReferences, + type NativeChatHostOwnedReference +} from './native-chat-attachment-destination' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -44,9 +53,15 @@ export function useNativeChatComposerAttachments({ clearImageAttachments: () => void flushPendingAttachments: () => void removeImageAttachment: (id: string) => void - beginPendingImageAttachment: (previewUrl?: string) => string | null - resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null + resolvePendingImageAttachment: ( + id: string, + path: string, + connectionId?: string | null, + hostOwner?: NativeChatAttachmentHostOwner + ) => void dropPendingImageAttachment: (id: string) => void + pendingChips: NativeChatPendingAttachmentChips } { const [imageAttachments, setImageAttachments] = useState( () => readNativeChatAttachmentCache(attachmentScopeKey) @@ -95,31 +110,39 @@ export function useNativeChatComposerAttachments({ ) const noteAttachmentTargetBlocked = useCallback(() => { - setNotice( - translate( - 'components.native-chat.composer.localAttachmentUnsupported', - 'Local attachments are not available for remote sessions.' - ) - ) + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) }, [setNotice]) const appendImageAttachments = useCallback( - (paths: { path: string; connectionId?: string | null }[]) => { + ( + paths: { + path: string + connectionId?: string | null + hostOwner?: NativeChatAttachmentHostOwner + }[] + ) => { if (paths.length === 0) { return } updateImageAttachments((prev) => [ ...prev, - ...paths.map(({ path, connectionId }) => ({ + ...paths.map(({ path, connectionId, hostOwner }) => ({ id: nextAttachmentId(), path, - connectionId: connectionId ?? undefined + connectionId: connectionId ?? undefined, + ...(hostOwner ? { hostOwner } : {}) })) ]) }, [nextAttachmentId, updateImageAttachments] ) + const noteHostOwnedReferences = useCallback( + (references: NativeChatHostOwnedReference[]) => + recordNativeChatHostOwnedReferences(attachmentScopeKey, references), + [attachmentScopeKey] + ) + const { attachResolvedPaths, disabledRef, flushPendingAttachments } = useNativeChatResolvedPathAttachments({ appendImageAttachments, @@ -128,6 +151,7 @@ export function useNativeChatComposerAttachments({ disabled, isComposing, noteAttachmentTargetBlocked, + noteHostOwnedReferences, setCaret, setDraft, setNotice, @@ -137,7 +161,7 @@ export function useNativeChatComposerAttachments({ // Placeholder chip shown the instant a paste starts, so a clipboard image that // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( - (previewUrl?: string): string | null => { + (previewUrl?: string, pendingName?: string): string | null => { if (disabledRef.current) { return null } @@ -146,7 +170,10 @@ export function useNativeChatComposerAttachments({ return null } const id = nextAttachmentId() - updateImageAttachments((prev) => [...prev, { id, path: '', previewUrl, pending: true }]) + updateImageAttachments((prev) => [ + ...prev, + { id, path: '', previewUrl, pending: true, ...(pendingName ? { pendingName } : {}) } + ]) return id }, [ @@ -159,7 +186,12 @@ export function useNativeChatComposerAttachments({ ) const resolvePendingImageAttachment = useCallback( - (id: string, path: string, connectionId?: string | null) => { + ( + id: string, + path: string, + connectionId?: string | null, + hostOwner?: NativeChatAttachmentHostOwner + ) => { updateImageAttachments((prev) => prev.map((attachment) => attachment.id === id @@ -167,7 +199,9 @@ export function useNativeChatComposerAttachments({ ...attachment, path, connectionId: connectionId ?? undefined, - pending: undefined + pending: undefined, + pendingName: undefined, + ...(hostOwner ? { hostOwner } : {}) } : attachment ) @@ -183,14 +217,26 @@ export function useNativeChatComposerAttachments({ [updateImageAttachments] ) + const pendingChips = useMemo( + () => ({ + begin: beginPendingImageAttachment, + resolve: resolvePendingImageAttachment, + drop: dropPendingImageAttachment + }), + [beginPendingImageAttachment, dropPendingImageAttachment, resolvePendingImageAttachment] + ) + return { + pendingChips, imageAttachments, attachResolvedPaths, - clearImageAttachments: () => + clearImageAttachments: () => { + clearNativeChatHostOwnedReferences(attachmentScopeKey) updateImageAttachments((prev) => { prev.forEach(releaseAttachmentPreview) return [] - }), + }) + }, flushPendingAttachments, removeImageAttachment: (id) => updateImageAttachments((prev) => removeAttachmentById(prev, id)), beginPendingImageAttachment, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx new file mode 100644 index 000000000000..4f52537d90aa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +// Pastes into a structured chat on a paired server land in that server's attachment store, on +// every paste path, and the chip keeps the store's owner. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { + NativeChatAttachmentHostOwner, + NativeChatAttachmentOwner +} from './native-chat-attachment-upload' + +const mocks = vi.hoisted(() => ({ + saveClipboardImageAsTempFile: vi.fn(), + readClipboardText: vi.fn(), + readClipboardImageThumbnail: vi.fn(), + clipboardHasImage: vi.fn(), + readClipboardFilePaths: vi.fn(), + prepareNativeChatSessionAttachmentUpload: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./native-chat-composer-target', () => ({ + NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES: 1024 +})) + +vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', + nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.', + nativeChatAttachmentHostOwner: (owner: NativeChatAttachmentHostOwner) => ({ + environmentId: owner.environmentId, + pairingRevision: owner.pairingRevision, + sessionId: owner.sessionId + }), + prepareNativeChatSessionAttachmentUpload: mocks.prepareNativeChatSessionAttachmentUpload +})) + +vi.stubGlobal('window', { + api: { + ui: { + saveClipboardImageAsTempFile: mocks.saveClipboardImageAsTempFile, + readClipboardText: mocks.readClipboardText, + readClipboardImageThumbnail: mocks.readClipboardImageThumbnail, + clipboardHasImage: mocks.clipboardHasImage, + readClipboardFilePaths: mocks.readClipboardFilePaths + } + } +}) +vi.stubGlobal('URL', { + createObjectURL: () => 'blob:clipboard-image', + revokeObjectURL: () => {} +}) + +import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' + +type HookApi = ReturnType +type Chip = { + id: string + path: string + pending: boolean + hostOwner?: NativeChatAttachmentHostOwner +} + +const sessionOwner: NativeChatAttachmentOwner = { + kind: 'runtime-session', + environmentId: 'env-1', + pairingRevision: 7, + sessionId: 'session-1' +} +const hostOwner = { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } +const storeArgs = { + runtimeEnvironmentId: 'env-1', + agentSessionAttachment: { + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } +} +const storedPath = '/srv/agent-session-attachments/s/u1/orca-paste-1.png' + +let root: Root | null = null + +async function renderPaste(args: { + attachResolvedPaths?: (...args: unknown[]) => void + insertTypedText?: (text: string) => boolean + setNotice?: (notice: string | null) => void +}): Promise<{ api: () => HookApi; chips: Chip[] }> { + const chips: Chip[] = [] + let counter = 0 + let api: HookApi | null = null + function Probe(): null { + api = useNativeChatComposerPaste({ + targetKey: 'session-1', + agent: 'claude', + disabled: false, + caret: 0, + setCaret: () => {}, + resolveAttachmentOwner: () => sessionOwner, + attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), + beginPendingImageAttachment: () => { + counter += 1 + chips.push({ id: `chip-${counter}`, path: '', pending: true }) + return `chip-${counter}` + }, + resolvePendingImageAttachment: (id, path, _connectionId, owner) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + Object.assign(chip, { path, pending: false, hostOwner: owner }) + } + }, + dropPendingImageAttachment: (id) => { + chips.splice( + chips.findIndex((candidate) => candidate.id === id), + 1 + ) + }, + insertTypedText: args.insertTypedText ?? (() => true), + setNotice: args.setNotice ?? (() => {}) + }) + return null + } + const container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root?.render(createElement(Probe))) + return { + api: () => { + if (!api) { + throw new Error('Probe did not render') + } + return api + }, + chips + } +} + +function imagePasteEvent(text = ''): ClipboardEvent { + const data = new DataTransfer() + data.items.add(new File(['image'], 'image.png', { type: 'image/png' })) + if (text) { + data.setData('text/plain', text) + } + return new ClipboardEvent('paste', { clipboardData: data, cancelable: true }) +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.readClipboardText.mockResolvedValue('') + mocks.readClipboardImageThumbnail.mockResolvedValue(null) + mocks.clipboardHasImage.mockResolvedValue(false) + mocks.readClipboardFilePaths.mockResolvedValue([]) + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + mocks.saveClipboardImageAsTempFile.mockResolvedValue(storedPath) +}) + +afterEach(() => { + root?.unmount() + root = null +}) + +describe('pasting into a structured chat on a paired server', () => { + it('saves a pasted image into the chat store and settles the chip with its owner', async () => { + const probe = await renderPaste({}) + await act(async () => probe.api().handlePaste(imagePasteEvent())) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false, hostOwner }]) + }) + + it('keeps the image of a paste that also carries text', async () => { + const insertTypedText = vi.fn(() => true) + const probe = await renderPaste({ insertTypedText }) + await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) + expect(insertTypedText).toHaveBeenCalledWith('caption') + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) + expect(probe.chips).toMatchObject([{ path: storedPath, pending: false }]) + }) + + it('pastes from the button into the chat store', async () => { + mocks.readClipboardImageThumbnail.mockResolvedValue({ dataUrl: 'data:image/png;base64,AA' }) + const probe = await renderPaste({}) + await act(async () => probe.api().pasteFromClipboard()) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false, hostOwner }]) + }) + + it('attaches with its owner when no placeholder chip was shown', async () => { + const attachResolvedPaths = vi.fn() + const probe = await renderPaste({ attachResolvedPaths }) + await act(async () => probe.api().pasteFromClipboard()) + expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([storedPath], null, { hostOwner }) + }) + + it('refuses on a server without the attachment store and saves nothing', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: false, + notice: 'needs newer server' + }) + const setNotice = vi.fn() + const probe = await renderPaste({ setNotice }) + await act(async () => probe.api().handlePaste(imagePasteEvent())) + expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled() + expect(setNotice).toHaveBeenLastCalledWith('needs newer server') + expect(probe.chips).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 6899ee76905e..82354c2a323f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -1,7 +1,9 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' -import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' +import { + nativeChatAttachmentOwnerUnchanged, + type NativeChatResolvedPathOptions +} from './native-chat-resolved-path-ownership' import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text' -import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { AgentType } from '../../../../shared/agent-status-types' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' @@ -13,10 +15,16 @@ import { type ClipboardEventLike } from './native-chat-clipboard-payload' import { + nativeChatAttachmentHostOwner, nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, + type NativeChatAttachmentHostOwner, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { + ownerAcceptsClipboardImage, + saveNativeChatClipboardImage +} from './native-chat-clipboard-image-save' export type UseNativeChatComposerPasteArgs = { targetKey?: string @@ -28,22 +36,24 @@ export type UseNativeChatComposerPasteArgs = { /** Resolved at paste time: SSH panes must save the clipboard image on the * remote host, or the attached path names a file the agent cannot read. */ resolveAttachmentOwner: () => NativeChatAttachmentOwner - attachResolvedPaths: (paths: string[], connectionId?: string | null) => void + attachResolvedPaths: ( + paths: string[], + connectionId?: string | null, + options?: NativeChatResolvedPathOptions + ) => void beginPendingImageAttachment: (previewUrl?: string) => string | null - resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + resolvePendingImageAttachment: ( + id: string, + path: string, + connectionId?: string | null, + hostOwner?: NativeChatAttachmentHostOwner + ) => void dropPendingImageAttachment: (id: string) => void insertTypedText: (text: string) => boolean setCaret: (caret: number) => void setNotice: (notice: string | null) => void } -/** Owners whose attachment path is a file this client can write right now. */ -function ownerAcceptsClipboardImage( - owner: NativeChatAttachmentOwner -): owner is Extract { - return owner.kind === 'local' || owner.kind === 'ssh' -} - export function useNativeChatComposerPaste({ targetKey, disabled, @@ -93,34 +103,14 @@ export function useNativeChatComposerPaste({ // Image failures do not decide whether text can be inserted. const saveClipboardImageForOwner = useCallback( - async ( - owner: NativeChatAttachmentOwner - ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => { - if (owner.kind === 'runtime') { - setNotice(nativeChatLocalAttachmentUnsupportedNotice()) - return { status: 'failed' } - } - try { - // SSH panes save the image on the remote host (SFTP) so the attached - // path is readable by the remote agent, matching terminal image paste. - const tempPath = await window.api.ui.saveClipboardImageAsTempFile( - owner.kind === 'ssh' ? { connectionId: owner.connectionId } : undefined - ) - return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } - } catch (error) { - // A failed save must be visible: over SSH it fails whenever the - // connection drops, and a silent no-op reads as a broken paste. - if (canPaste()) { - setNotice( - extractIpcErrorMessage( - error, - translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') - ) - ) + (owner: NativeChatAttachmentOwner) => + saveNativeChatClipboardImage(owner, { + setNotice: (notice) => { + if (canPaste()) { + setNotice(notice) + } } - return { status: 'failed' } - } - }, + }), [canPaste, setNotice] ) @@ -137,11 +127,15 @@ export function useNativeChatComposerPaste({ return } const connectionId = originalOwner.kind === 'ssh' ? originalOwner.connectionId : null + const hostOwner = + originalOwner.kind === 'runtime-session' + ? nativeChatAttachmentHostOwner(originalOwner) + : undefined if (pendingId) { lifetime.pending.delete(pendingId) - resolvePendingImageAttachment(pendingId, path, connectionId) + resolvePendingImageAttachment(pendingId, path, connectionId, hostOwner) } else { - attachResolvedPaths([path], connectionId) + attachResolvedPaths([path], connectionId, ...(hostOwner ? [{ hostOwner }] : [])) } }, [ diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx index bbbf99d2a5d2..a6c472a8e31d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -37,6 +37,8 @@ function harness(options: { const calls = { sendPty: vi.fn(), sendStructured: vi.fn(), + removeImageAttachment: vi.fn(), + setNotice: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn(), setHistory: vi.fn() @@ -49,6 +51,7 @@ function harness(options: { caret: props.caret, imageAttachments: options.imageAttachments ?? [], disabled: false, + attachmentScopeKey: 'pane-1', ...calls }), { initialProps: { draft: options.draft, caret: options.caret ?? options.draft.length } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts index 7bf8ea7f8705..36f257d2786d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -4,6 +4,7 @@ import { applyPickerSuggestion, type NativeChatPickerItem } from './native-chat- import { pushHistory, type HistoryState } from './native-chat-composer-state' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { useNativeChatAttachmentSendGuard } from './use-native-chat-attachment-send-guard' import { isBareStructuredAgentSessionGoalCommand, structuredAgentSessionGoalObjective @@ -34,12 +35,25 @@ export function useNativeChatComposerSubmit(args: { disabled: boolean sendPty: () => void sendStructured: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void + /** Scope of the composer's attachments, for the host check right before a structured send. */ + attachmentScopeKey: string + removeImageAttachment: (id: string) => void + setNotice: (notice: string | null) => void setDraft: (value: string) => void setCaret: (caret: number) => void setHistory: (updater: (previous: HistoryState) => HistoryState) => void }): { send: () => void; goalMode: NativeChatComposerGoalMode } { - const { caret, disabled, draft, imageAttachments, sendPty, sendStructured } = args + const { caret, disabled, draft, imageAttachments, sendPty } = args const { setCaret, setDraft, setHistory, structuredTransport } = args + const sendStructured = useNativeChatAttachmentSendGuard({ + scopeKey: args.attachmentScopeKey, + structuredTransport, + draft, + setDraft, + removeImageAttachment: args.removeImageAttachment, + setNotice: args.setNotice, + sendStructured: args.sendStructured + }) const threadGoal = structuredTransport?.threadGoal const [entered, setEntered] = useState(false) const active = entered && threadGoal !== undefined diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 1d8fa94eeb2d..d73696545abf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => ({ authorizeExternalPath: vi.fn(), resolveNativeChatAttachmentOwner: vi.fn(), resolveNativeChatAttachmentOwnerForWorktree: vi.fn(), - uploadNativeChatAttachmentPaths: vi.fn() + uploadNativeChatAttachmentPaths: vi.fn(), + prepareNativeChatSessionAttachmentUpload: vi.fn(), + uploadNativeChatSessionAttachmentPaths: vi.fn() })) vi.mock('@/store', () => ({ @@ -21,10 +23,14 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ ...(await importOriginal()), resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree: mocks.resolveNativeChatAttachmentOwnerForWorktree, - uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths + uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths, + prepareNativeChatSessionAttachmentUpload: mocks.prepareNativeChatSessionAttachmentUpload, + uploadNativeChatSessionAttachmentPaths: mocks.uploadNativeChatSessionAttachmentPaths })) import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' +import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' type HookApi = ReturnType @@ -36,16 +42,24 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { return { promise, resolve } } +function noPendingChips(): NativeChatPendingAttachmentChips { + return { begin: vi.fn(() => null), resolve: vi.fn(), drop: vi.fn() } +} + function Probe({ disabled, structuredWorktreeId, + structuredSession, attachResolvedPaths, + pendingChips, setNotice, onReady }: { disabled: boolean structuredWorktreeId?: string + structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } attachResolvedPaths: (paths: string[]) => void + pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void onReady: (api: HookApi) => void }): null { @@ -53,8 +67,10 @@ function Probe({ useNativeChatExternalAttachments({ terminalTabId: 'tab-1', structuredWorktreeId, + structuredSession, disabled, attachResolvedPaths, + pendingChips, setNotice }) ) @@ -66,7 +82,9 @@ let root: Root | null = null async function renderProbe(args: { disabled?: boolean structuredWorktreeId?: string + structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } attachResolvedPaths: (paths: string[]) => void + pendingChips?: NativeChatPendingAttachmentChips setNotice?: (notice: string | null) => void }): Promise<{ latest: () => HookApi @@ -85,7 +103,9 @@ async function renderProbe(args: { createElement(Probe, { disabled, structuredWorktreeId, + structuredSession: args.structuredSession, attachResolvedPaths: args.attachResolvedPaths, + pendingChips: args.pendingChips ?? noPendingChips(), setNotice: args.setNotice ?? (() => {}), onReady: (next) => { api = next @@ -435,4 +455,147 @@ describe('useNativeChatExternalAttachments', () => { 'This workspace changed hosts while attaching — drop the files again.' ) }) + + describe('a structured chat on a paired server', () => { + const session = { sessionId: 'session-1', runtimeEnvironmentId: 'env-1' } + const hostOwner = { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } + const target = { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + + function trackingChips(): NativeChatPendingAttachmentChips & { begun: string[] } { + const begun: string[] = [] + return { + begun, + begin: vi.fn((_preview?: string, name?: string) => { + begun.push(name ?? '') + return `chip-${begun.length}` + }), + resolve: vi.fn(), + drop: vi.fn() + } + } + + beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) + mocks.prepareNativeChatSessionAttachmentUpload.mockReset().mockResolvedValue({ + ok: true, + target + }) + mocks.uploadNativeChatSessionAttachmentPaths.mockReset() + }) + + it('uploads into the chat store and attaches only server paths, with their owner', async () => { + const upload = deferred>() + mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips + }) + + act(() => probe.latest().attachExternalPaths(['/Users/me/shot.png', '/Users/me/notes.md'])) + // Pending chips appear before the upload settles, so Send waits for both files. + expect(chips.begun).toEqual(['shot.png', 'notes.md']) + await act(async () => + upload.resolve( + new Map([ + ['/Users/me/shot.png', '/srv/agent-session-attachments/s/u1/shot.png'], + ['/Users/me/notes.md', '/srv/agent-session-attachments/s/u2/notes.md'] + ]) + ) + ) + + expect(mocks.uploadNativeChatSessionAttachmentPaths).toHaveBeenCalledWith( + ['/Users/me/shot.png', '/Users/me/notes.md'], + target + ) + expect(chips.resolve).toHaveBeenCalledExactlyOnceWith( + 'chip-1', + '/srv/agent-session-attachments/s/u1/shot.png', + null, + hostOwner + ) + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') + expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( + ['/srv/agent-session-attachments/s/u2/notes.md'], + null, + { hostOwner } + ) + expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() + }) + + it('refuses on a server without the attachment store, uploading nothing', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValueOnce({ + ok: false, + notice: 'needs newer server' + }) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const notices: (string | null)[] = [] + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips, + setNotice: (notice) => notices.push(notice) + }) + + await act(async () => probe.latest().attachExternalPaths(['/Users/me/shot.png'])) + + expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled() + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') + expect(attachResolvedPaths).not.toHaveBeenCalled() + expect(notices.at(-1)).toBe('needs newer server') + }) + + it('drops the upload when the server is re-paired meanwhile', async () => { + const upload = deferred>() + mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const notices: (string | null)[] = [] + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips, + setNotice: (notice) => notices.push(notice) + }) + + act(() => probe.latest().attachExternalPaths(['/Users/me/shot.png'])) + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 8 }]) + await act(async () => + upload.resolve( + new Map([['/Users/me/shot.png', '/srv/agent-session-attachments/s/u1/shot.png']]) + ) + ) + + expect(chips.resolve).not.toHaveBeenCalled() + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') + expect(attachResolvedPaths).not.toHaveBeenCalled() + expect(notices.at(-1)).toBe( + 'This workspace changed hosts while attaching — drop the files again.' + ) + }) + + it('keeps a local structured chat on the worktree owner', async () => { + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: { sessionId: 'session-1', runtimeEnvironmentId: null }, + attachResolvedPaths + }) + await act(async () => probe.latest().attachExternalPaths(['/local/a.txt'])) + expect(mocks.resolveNativeChatAttachmentOwnerForWorktree).toHaveBeenCalled() + expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() + expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt']) + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 98d3bc81446d..50b5949b2eb3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -1,6 +1,9 @@ import { useCallback, useLayoutEffect, useRef } from 'react' import { useAppStore } from '@/store' -import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' +import { + nativeChatAttachmentOwnerUnchanged, + type NativeChatResolvedPathOptions +} from './native-chat-resolved-path-ownership' import { nativeChatAttachmentOwnerChangedNotice, nativeChatAttachmentUnreadableNotice, @@ -8,26 +11,47 @@ import { nativeChatWorktreeNotReadyNotice, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, + resolveNativeChatRuntimeSessionAttachmentOwner, uploadNativeChatAttachmentPaths, - type NativeChatAttachmentOwner + type NativeChatAttachmentOwner, + type NativeChatStructuredAttachmentSession } from './native-chat-attachment-upload' +import { + attachNativeChatSessionAttachmentPaths, + type NativeChatPendingAttachmentChips +} from './native-chat-session-attachment-drop' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string structuredWorktreeId?: string + /** The structured chat behind this composer; decides where its uploads are stored. */ + structuredSession?: NativeChatStructuredAttachmentSession /** Live composer-disabled state; read at await-resume via a ref so a flip * mid-upload doesn't attach into a guarded composer. */ disabled: boolean - attachResolvedPaths: (paths: string[], connectionId?: string | null) => void + attachResolvedPaths: ( + paths: string[], + connectionId?: string | null, + options?: NativeChatResolvedPathOptions + ) => void + /** Chips shown while a file uploads, so Send waits for it. */ + pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void } -type ComposerWorkspace = { structuredWorktreeId?: string; terminalTabId: string } +type ComposerWorkspace = { + structuredWorktreeId?: string + terminalTabId: string + structuredSession?: NativeChatStructuredAttachmentSession +} function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerWorkspace): boolean { return ( captured.structuredWorktreeId === current.structuredWorktreeId && - captured.terminalTabId === current.terminalTabId + captured.terminalTabId === current.terminalTabId && + captured.structuredSession?.sessionId === current.structuredSession?.sessionId && + captured.structuredSession?.runtimeEnvironmentId === + current.structuredSession?.runtimeEnvironmentId ) } @@ -39,8 +63,10 @@ function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerW export function useNativeChatExternalAttachments({ terminalTabId, structuredWorktreeId, + structuredSession, disabled, attachResolvedPaths, + pendingChips, setNotice }: UseNativeChatExternalAttachmentsArgs): { attachExternalPaths: (paths: string[]) => void @@ -54,19 +80,36 @@ export function useNativeChatExternalAttachments({ // The post-await gate asks which workspace this composer serves now, so it // reads the pane through a ref. Resolving through the render closure would // re-ask the workspace the upload started in — a comparison with itself. - const workspaceRef = useRef({ structuredWorktreeId, terminalTabId }) + const sessionId = structuredSession?.sessionId + const runtimeEnvironmentId = structuredSession?.runtimeEnvironmentId ?? null + const workspaceRef = useRef({ + structuredWorktreeId, + terminalTabId, + structuredSession + }) + useLayoutEffect(() => { + workspaceRef.current = { + structuredWorktreeId, + terminalTabId, + structuredSession: sessionId ? { sessionId, runtimeEnvironmentId } : undefined + } + }, [runtimeEnvironmentId, sessionId, structuredWorktreeId, terminalTabId]) + const pendingChipsRef = useRef(pendingChips) useLayoutEffect(() => { - workspaceRef.current = { structuredWorktreeId, terminalTabId } - }, [structuredWorktreeId, terminalTabId]) + pendingChipsRef.current = pendingChips + }, [pendingChips]) const resolveAttachmentOwner = useCallback(() => { - const workspace = workspaceRef.current - return workspace.structuredWorktreeId - ? resolveNativeChatAttachmentOwnerForWorktree( - useAppStore.getState(), - workspace.structuredWorktreeId - ) - : resolveNativeChatAttachmentOwner(useAppStore.getState(), workspace.terminalTabId) + const { structuredWorktreeId, structuredSession, terminalTabId } = workspaceRef.current + if (structuredWorktreeId && structuredSession?.runtimeEnvironmentId) { + return resolveNativeChatRuntimeSessionAttachmentOwner({ + sessionId: structuredSession.sessionId, + runtimeEnvironmentId: structuredSession.runtimeEnvironmentId + }) + } + return structuredWorktreeId + ? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId) + : resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId) }, []) const attachExternalPaths = useCallback( @@ -92,6 +135,18 @@ export function useNativeChatExternalAttachments({ const ownerStillCurrent = (): boolean => isSameComposerWorkspace(capturedWorkspace, workspaceRef.current) && nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner()) + if (owner.kind === 'runtime-session') { + void attachNativeChatSessionAttachmentPaths({ + paths, + owner, + chips: pendingChipsRef.current, + isAbandoned: () => disabledRef.current, + ownerStillCurrent, + attachResolvedPaths, + setNotice + }) + return + } if (owner.kind !== 'ssh') { void (async () => { const authorizedPaths: string[] = [] diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 7bc6a042abcd..2c7ca942676a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -8,15 +8,26 @@ import { nativeChatWorkspaceAttachmentMismatchNotice, type NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' +import type { NativeChatAttachmentHostOwner } from './native-chat-attachment-upload' +import type { NativeChatHostOwnedReference } from './native-chat-attachment-destination' type ResolvedAttachmentPath = { path: string connectionId?: string | null targetOwnerIsCurrent?: () => boolean + hostOwner?: NativeChatAttachmentHostOwner } type Args = { - appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void + appendImageAttachments: ( + paths: { + path: string + connectionId?: string | null + hostOwner?: NativeChatAttachmentHostOwner + }[] + ) => void + /** Records `@path` references a paired server's store owns, so a send can recheck them. */ + noteHostOwnedReferences: (references: NativeChatHostOwnedReference[]) => void attachmentTargetBlocked: (targetOwned?: boolean) => boolean caret: number disabled: boolean @@ -35,6 +46,7 @@ export function useNativeChatResolvedPathAttachments({ disabled, isComposing, noteAttachmentTargetBlocked, + noteHostOwnedReferences, setCaret, setDraft, setNotice, @@ -61,8 +73,13 @@ export function useNativeChatResolvedPathAttachments({ }, [disabled]) const insertFileReferences = useCallback( - (paths: string[]) => { - const references = paths.map(formatNativeChatFileReference).join(' ') + (paths: ResolvedAttachmentPath[]) => { + noteHostOwnedReferences( + paths.flatMap(({ path, hostOwner }) => + hostOwner ? [{ reference: formatNativeChatFileReference(path), hostOwner }] : [] + ) + ) + const references = paths.map(({ path }) => formatNativeChatFileReference(path)).join(' ') if (references.length === 0) { return } @@ -75,7 +92,7 @@ export function useNativeChatResolvedPathAttachments({ return before + insertion + after }) }, - [caret, setCaret, setDraft, textareaRef] + [caret, noteHostOwnedReferences, setCaret, setDraft, textareaRef] ) const applyResolvedPaths = useCallback( @@ -108,11 +125,11 @@ export function useNativeChatResolvedPathAttachments({ return } const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path)) - const filePaths = attachable - .filter(({ path }) => !isNativeChatImageAttachmentPath(path)) - .map(({ path }) => path) + const filePaths = attachable.filter(({ path }) => !isNativeChatImageAttachmentPath(path)) // Images ride along on submit so chips and the TUI input cannot diverge. - appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId }))) + appendImageAttachments( + imagePaths.map(({ path, connectionId, hostOwner }) => ({ path, connectionId, hostOwner })) + ) insertFileReferences(filePaths) if (ownedBlocked || clientLocalBlocked) { noteAttachmentTargetBlocked() @@ -167,7 +184,8 @@ export function useNativeChatResolvedPathAttachments({ ...paths.map((path) => ({ path, connectionId, - targetOwnerIsCurrent: options.targetOwnerIsCurrent + targetOwnerIsCurrent: options.targetOwnerIsCurrent, + hostOwner: options.hostOwner })) ) return @@ -176,7 +194,8 @@ export function useNativeChatResolvedPathAttachments({ paths.map((path) => ({ path, connectionId, - targetOwnerIsCurrent: options.targetOwnerIsCurrent + targetOwnerIsCurrent: options.targetOwnerIsCurrent, + hostOwner: options.hostOwner })), true ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 28bf17b8aa78..2f2cb29674c3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -85,7 +85,11 @@ export function useStructuredAgentSessionOutbox(args: { const { capability: queueCapability, enabled: queueEnabled } = queueDelivery // What resends and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) - const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) + const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore( + sessionId, + target, + composerScopeKey + ) // The outbox lives in the session's store, shared with every other writer; this view holds it // open and drains it. Loading maps what a previous owner left mid-send. const load = useCallback( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6c160e408dc3..f4d415a46a2f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17763,6 +17763,7 @@ "skillScopeBuiltIn": "Built-in", "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", + "attachmentsNeedNewerServer": "Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", "imagePasteFailed": "Image paste failed.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3f8a1e2f7034..58d33c0e15b3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15082,6 +15082,7 @@ "stopDictation": "Detener dictado", "noSkills": "Sin habilidades coincidentes", "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", + "attachmentsNeedNewerServer": "Para adjuntar archivos a este chat, el servidor necesita una versión más reciente de Orca. Actualiza Orca en ese equipo y vuelve a intentarlo.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", "imagePasteFailed": "Error al pegar imagen.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 40f29aafcd75..b4def13357e5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17566,6 +17566,7 @@ "skillScopeBuiltIn": "Intégrée", "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", + "attachmentsNeedNewerServer": "Pour joindre des fichiers à ce chat, le serveur doit utiliser une version plus récente d’Orca. Mettez à jour Orca sur cet ordinateur, puis réessayez.", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", "imagePasteFailed": "Échec du collage de l'image.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4870f2cbb29c..29f206ddf3d6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17488,6 +17488,7 @@ "stopDictation": "ディクテーションを停止する", "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", + "attachmentsNeedNewerServer": "このチャットにファイルを添付するには、サーバーの Orca を新しいバージョンにする必要があります。そのコンピューターの Orca を更新してから、もう一度お試しください。", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", "imagePasteFailed": "画像の貼り付けに失敗しました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e04189ae70f1..0eeac6cbe481 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17488,6 +17488,7 @@ "stopDictation": "받아쓰기 중지", "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", + "attachmentsNeedNewerServer": "이 채팅에 파일을 첨부하려면 서버의 Orca를 최신 버전으로 업데이트해야 합니다. 해당 컴퓨터에서 Orca를 업데이트한 후 다시 시도하세요.", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", "imagePasteFailed": "이미지 붙여넣기 실패.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2a9a3ea73b66..d3cf58a3deea 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17505,6 +17505,7 @@ "stopDictation": "停止听写", "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", + "attachmentsNeedNewerServer": "要向此聊天附加文件,服务器上的 Orca 需要更新到较新版本。请在那台电脑上更新 Orca,然后重试。", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", "imagePasteFailed": "图片粘贴失败。", diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 8c7e336fc2de..7f2cd70ca5cd 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -513,6 +513,35 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + // A chat attachment lives in the paired server's store, outside every worktree. + it.each([ + { worktreeId: 'wt-1', worktreePath: '/remote/repo' }, + { worktreeId: null, worktreePath: null } + ])('reads a stored chat attachment back through its server ($worktreeId)', async (owner) => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { content: 'base64', isBinary: true, isImage: true, mimeType: 'image/png' }, + _meta: { runtimeId: 'remote-runtime' } + }) + const storedPath = '/srv/orca/agent-session-attachments/abc/u1/shot.png' + + await expect( + readRuntimeFilePreview( + { settings: { activeRuntimeEnvironmentId: 'env-1' }, ...owner }, + storedPath + ) + ).resolves.toMatchObject({ content: 'base64', mimeType: 'image/png' }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'agentSessionAttachment.read', + params: { path: storedPath }, + timeoutMs: 15_000 + }) + expect(fsReadFile).not.toHaveBeenCalled() + }) + it('routes root directory reads with an empty relative path', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index 40b74611c676..c9d151a01707 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -17,6 +17,7 @@ import { hasRemoteRuntimeOwner } from './runtime-file-routing' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { isAgentSessionAttachmentStorePath } from '../../../shared/agent-session-attachments' const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE = @@ -86,6 +87,21 @@ export async function readRuntimeFilePreview( context.expectedExternalSshTargetId ) const remoteArgs = getRemoteFileArgs(context, filePath) + const runtimeTarget = getActiveRuntimeTarget(context.settings) + // A chat attachment lives in the paired server's store, outside every worktree; that server + // reads it back itself (an older one answers method_not_found, and the preview falls back). + if ( + !remoteArgs && + runtimeTarget.kind === 'environment' && + isAgentSessionAttachmentStorePath(filePath) + ) { + return callRuntimeRpc( + runtimeTarget, + 'agentSessionAttachment.read', + { path: filePath }, + { timeoutMs: 15_000 } + ) + } if (!remoteArgs) { if (hasRemoteRuntimeOwner(context)) { throw new Error('Remote file is outside the owning runtime worktree') From c9529ac23ef2fec8726db46759e8a5957ecff3f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:42:35 -0700 Subject: [PATCH 04/40] test(native-chat): type the attachment test doubles without bare casts --- .../agent-session-attachment-upload.test.ts | 22 ++++++++++++++----- ...ructured-agent-session-attachments.test.ts | 3 +++ ...ard-agent-session-attachment-paste.test.ts | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/agent-session-attachment-upload.test.ts b/src/main/ipc/agent-session-attachment-upload.test.ts index dc896169eab5..fb505253b12f 100644 --- a/src/main/ipc/agent-session-attachment-upload.test.ts +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -33,18 +33,30 @@ vi.mock('./runtime-environment-transport-routing', () => ({ return { ok: false, error: { code: 'internal', message: `${method} failed` } } } const callerKey = 'client-a' + const uploadId = String(params.uploadId) try { const result = method === 'agentSessionAttachment.uploadStart' - ? await serverStore.startUpload({ callerKey, ...(params as never) }) + ? await serverStore.startUpload({ + callerKey, + sessionId: String(params.sessionId), + name: String(params.name), + byteLength: Number(params.byteLength) + }) : method === 'agentSessionAttachment.uploadAppend' - ? await serverStore.appendChunk({ callerKey, ...(params as never) }) + ? await serverStore.appendChunk({ + callerKey, + uploadId, + offset: Number(params.offset), + contentBase64: String(params.contentBase64) + }) : method === 'agentSessionAttachment.uploadCommit' - ? await serverStore.commitUpload({ callerKey, ...(params as never) }) - : await serverStore.abortUpload({ callerKey, ...(params as never) }) + ? await serverStore.commitUpload({ callerKey, uploadId }) + : await serverStore.abortUpload({ callerKey, uploadId }) return { ok: true, result } } catch (error) { - return { ok: false, error: { code: 'internal', message: (error as Error).message } } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: { code: 'internal', message } } } } })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts index 9c4ef0782124..5fdaf6134024 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -43,6 +43,7 @@ async function call( client: { clientId?: string; clientKind?: 'runtime' | 'mobile'; clientCapabilities?: string[] } ): Promise { const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attachment methods read only the host-install hook and runtime id. runtime: { getRuntimeId: () => 'runtime-1', ensureStructuredAgentSessionHost: async () => {} @@ -52,6 +53,7 @@ async function call( const replies: RpcResponse[] = [] await dispatcher.dispatchStreaming( { id: 'request-1', authToken: 'token', method, params }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes serialized RpcResponse frames. (raw) => replies.push(JSON.parse(raw) as RpcResponse), client ) @@ -65,6 +67,7 @@ function result(response: RpcResponse): T { if (!response.ok) { throw new Error(`refused: ${JSON.stringify(response)}`) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result shape its method returns. return response.result as T } diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts index 1a6b4b733930..8db77235abf8 100644 --- a/src/main/window/clipboard-agent-session-attachment-paste.test.ts +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -39,6 +39,7 @@ function saveHandler(): (...args: unknown[]) => Promise { if (!entry) { throw new Error('save handler was not registered') } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle was called with this channel's async handler. return entry[1] as (...args: unknown[]) => Promise } @@ -78,6 +79,7 @@ beforeEach(() => { describe('clipboard:saveImageAsTempFile for a structured chat on a paired server', () => { it("stores the image in the chat's attachment store, pinned to the pairing and server", async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. registerClipboardHandlers({} as never) await expect( @@ -110,6 +112,7 @@ describe('clipboard:saveImageAsTempFile for a structured chat on a paired server it('keeps the server temp directory for a paste that names no chat', async () => { mocks.callRuntimeEnvironment.mockResolvedValue({ ok: true, result: { uploadId: 'u' } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. registerClipboardHandlers({} as never) await saveHandler()(event, { runtimeEnvironmentId: 'remote-host-1' }).catch(() => {}) expect(mocks.callRuntimeEnvironment.mock.calls[0][2]).toBe('clipboard.startImageUpload') From 7f04bf0af0692d18566cff8da78a58cd6c00f861 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:34:28 -0700 Subject: [PATCH 05/40] refactor(native-chat): keep the clipboard RPC on its own upload bookkeeping The chunked upload registry stays for the chat attachment store only, beside it. --- .../agent-session-attachment-store.ts | 2 +- .../chunked-upload-registry.ts | 0 src/main/runtime/rpc/methods/clipboard.ts | 113 ++++++++++++------ 3 files changed, 80 insertions(+), 35 deletions(-) rename src/main/{runtime => native-chat/agent-session-attachments}/chunked-upload-registry.ts (100%) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index 447479edeb2f..7dff24a461a5 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -27,7 +27,7 @@ import { journalPathSegment } from '../agent-session-journal/journal-paths' import { ChunkedUploadRegistry, nextChunkedUploadLength -} from '../../runtime/chunked-upload-registry' +} from './chunked-upload-registry' export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' const UPLOAD_MAX_CONCURRENT = 8 diff --git a/src/main/runtime/chunked-upload-registry.ts b/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts similarity index 100% rename from src/main/runtime/chunked-upload-registry.ts rename to src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index ac3572868228..60ed142455f3 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,6 +1,6 @@ import { defineMethod, type RpcContext } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' -import { ChunkedUploadRegistry, nextChunkedUploadLength } from '../../chunked-upload-registry' +import { randomUUID } from 'node:crypto' import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' import { AbortImageUpload, @@ -15,19 +15,57 @@ export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 type ClipboardImageUpload = { - expectedLength: number + expectedBase64Length: number connectionId?: string | null mobileClientId?: string chunks: string[] - receivedLength: number + receivedBase64Length: number + expiresAt: number + ttlTimer: ReturnType } -const clipboardImageUploads = new ChunkedUploadRegistry({ - maxConcurrent: CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT, - ttlMs: CLIPBOARD_IMAGE_UPLOAD_TTL_MS, - tooManyMessage: 'Too many clipboard image uploads are in progress', - notFoundMessage: 'Clipboard image upload was not found' -}) +const clipboardImageUploads = new Map() + +function pruneExpiredUploads(now = Date.now()): void { + for (const [uploadId, upload] of clipboardImageUploads) { + if (upload.expiresAt <= now) { + deleteUpload(uploadId) + } + } +} + +function scheduleUploadExpiry(uploadId: string): ReturnType { + const timer = setTimeout(() => { + clipboardImageUploads.delete(uploadId) + }, CLIPBOARD_IMAGE_UPLOAD_TTL_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer +} + +function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): void { + clearTimeout(upload.ttlTimer) + upload.expiresAt = Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS + upload.ttlTimer = scheduleUploadExpiry(uploadId) +} + +function deleteUpload(uploadId: string): void { + const upload = clipboardImageUploads.get(uploadId) + if (upload) { + clearTimeout(upload.ttlTimer) + } + clipboardImageUploads.delete(uploadId) +} + +function getUpload(uploadId: string): ClipboardImageUpload { + pruneExpiredUploads() + const upload = clipboardImageUploads.get(uploadId) + if (!upload) { + throw new Error('Clipboard image upload was not found') + } + return upload +} function mobileClientId(ctx: RpcContext): string | undefined { if (ctx.clientKind !== 'mobile') { @@ -73,14 +111,20 @@ export const CLIPBOARD_METHODS = [ name: 'clipboard.startImageUpload', params: StartImageUpload, handler: (params, ctx) => { - const mobileClient = mobileClientId(ctx) - const uploadId = clipboardImageUploads.create(() => ({ - expectedLength: params.expectedBase64Length, + pruneExpiredUploads() + if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) { + throw new Error('Too many clipboard image uploads are in progress') + } + const uploadId = randomUUID() + clipboardImageUploads.set(uploadId, { + expectedBase64Length: params.expectedBase64Length, connectionId: params.connectionId, - mobileClientId: mobileClient, + mobileClientId: mobileClientId(ctx), chunks: [], - receivedLength: 0 - })) + receivedBase64Length: 0, + expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, + ttlTimer: scheduleUploadExpiry(uploadId) + }) return { uploadId } } }), @@ -88,31 +132,29 @@ export const CLIPBOARD_METHODS = [ name: 'clipboard.appendImageUploadChunk', params: AppendImageUploadChunk, handler: (params, ctx) => { - const upload = clipboardImageUploads.require(params.uploadId) + const upload = getUpload(params.uploadId) assertMobileUploadOwner(upload, ctx) - const nextLength = nextChunkedUploadLength( - upload, - params.offset, - params.contentBase64.length, - { - outOfOrder: 'Clipboard image chunk offset is out of order', - exceeded: 'Clipboard image upload exceeded expected size' - } - ) + if (params.offset !== upload.receivedBase64Length) { + throw new Error('Clipboard image chunk offset is out of order') + } + const nextLength = upload.receivedBase64Length + params.contentBase64.length + if (nextLength > upload.expectedBase64Length) { + throw new Error('Clipboard image upload exceeded expected size') + } upload.chunks.push(params.contentBase64) - upload.receivedLength = nextLength - clipboardImageUploads.touch(params.uploadId) - return { receivedBase64Length: upload.receivedLength } + upload.receivedBase64Length = nextLength + refreshUploadExpiry(params.uploadId, upload) + return { receivedBase64Length: upload.receivedBase64Length } } }), defineMethod({ name: 'clipboard.commitImageUpload', params: CommitImageUpload, handler: async (params, ctx) => { - const upload = clipboardImageUploads.require(params.uploadId) + const upload = getUpload(params.uploadId) const clientId = assertMobileUploadOwner(upload, ctx) try { - if (upload.receivedLength !== upload.expectedLength) { + if (upload.receivedBase64Length !== upload.expectedBase64Length) { throw new Error('Clipboard image upload is incomplete') } const path = await saveClipboardImageBufferAsTempFile( @@ -128,7 +170,7 @@ export const CLIPBOARD_METHODS = [ } finally { // Why: failed SSH or filesystem commits must not leave bounded upload // memory pinned until TTL cleanup. - clipboardImageUploads.delete(params.uploadId) + deleteUpload(params.uploadId) } } }), @@ -136,16 +178,19 @@ export const CLIPBOARD_METHODS = [ name: 'clipboard.abortImageUpload', params: AbortImageUpload, handler: (params, ctx) => { - const upload = clipboardImageUploads.peek(params.uploadId) + pruneExpiredUploads() + const upload = clipboardImageUploads.get(params.uploadId) if (upload) { assertMobileUploadOwner(upload, ctx) } - clipboardImageUploads.delete(params.uploadId) + deleteUpload(params.uploadId) return { aborted: true } } }) ] export function resetClipboardImageUploadsForTest(): void { - clipboardImageUploads.clear() + for (const uploadId of clipboardImageUploads.keys()) { + deleteUpload(uploadId) + } } From 98187b309f0a617e9d33202692d6f6a03b8b98a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:46:16 -0700 Subject: [PATCH 06/40] feat(native-chat): claim chat attachments when the host admits a message A message's references into the host's attachment store are claimed in the same journal transaction that makes it durable (a direct send, a queued draft, a /clear carry). The sweep deletes an upload only after marking it in that database while no claim exists, so a send and a sweep can never both win: a client's send that names an expired or foreign upload is refused whole with the new attachmentExpired reason, before anything is recorded. This replaces scanning chat transcripts for paths. The store is now //, refuses uploads for chats the host does not hold, caps stored names in UTF-8 bytes and avoids Windows device names. The preview read goes through the protected bounded read and the request's reply budget, so an image too large for one reply is refused instead of closing the connection. --- .../agent-session-attachment-claims.test.ts | 262 ++++++++++++++++++ .../agent-session-attachment-claims.ts | 198 +++++++++++++ ...sion-attachment-journal-references.test.ts | 96 ------- ...t-session-attachment-journal-references.ts | 32 --- .../agent-session-attachment-references.ts | 93 +++++++ .../agent-session-attachment-store.test.ts | 69 ++++- .../agent-session-attachment-store.ts | 134 ++++----- .../agent-session-attachment-sweep.test.ts | 231 +++++++++------ .../agent-session-attachment-sweep.ts | 145 +++++----- .../agent-session-journal/journal-database.ts | 2 + .../journal-queued-messages.ts | 19 +- .../agent-session-journal/journal-store.ts | 16 +- ...agent-session-attachment-admission.test.ts | 134 +++++++++ ...tructured-agent-session-queued-messages.ts | 25 +- .../structured-agent-session-turns.ts | 15 + ...ructured-agent-session-attachments.test.ts | 67 ++++- .../structured-agent-session-attachments.ts | 4 +- ...uctured-agent-session-attachment-wiring.ts | 20 +- .../agent-session-write-notice-text.ts | 4 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-attachments.ts | 57 ++-- src/shared/agent-session-refusal-details.ts | 2 + src/shared/agent-session-refusal-notice.ts | 1 + src/shared/agent-session-write-notice-copy.ts | 2 + 29 files changed, 1229 insertions(+), 411 deletions(-) create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts delete mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts delete mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts new file mode 100644 index 000000000000..543fcd55c9bc --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -0,0 +1,262 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { formatNativeChatFileReference } from '../../../shared/agent-image-paste' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + isAgentSessionAttachmentExpiredError, + markUnclaimedUploadForSweep +} from './agent-session-attachment-claims' +import { + agentSessionAttachmentReferences, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' + +const UPLOAD_A = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const UPLOAD_B = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' + +function message(...blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +describe('agentSessionAttachmentReferences', () => { + const root = '/srv/orca/agent-session-attachments' + + it('finds image paths and file references in text, however the reference is quoted', () => { + const quoted = formatNativeChatFileReference(`${root}/${UPLOAD_B}/my "notes" and 'more'.txt`) + const references = agentSessionAttachmentReferences( + root, + message( + { type: 'image-ref', path: `${root}/${UPLOAD_A}/shot.png` }, + { type: 'text', text: `read ${quoted} please` } + ), + 'linux' + ) + expect([...references.uploadIds].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + expect(references.foreign).toBe(0) + }) + + it("counts another server's store as foreign, never as this host's upload", () => { + const references = agentSessionAttachmentReferences( + root, + message({ type: 'text', text: `@/home/other/agent-session-attachments/${UPLOAD_A}/a.txt` }), + 'linux' + ) + expect(references.uploadIds.size).toBe(0) + expect(references.foreign).toBe(1) + }) + + it('matches a Windows store with either separator and in any case', () => { + const windowsRoot = 'C:\\Users\\me\\AppData\\Roaming\\orca\\agent-session-attachments' + const references = agentSessionAttachmentReferences( + windowsRoot, + message( + { type: 'image-ref', path: `c:/users/ME/appdata/roaming/orca/agent-session-attachments/${UPLOAD_A}/s.png` }, + { type: 'text', text: `@"${windowsRoot}\\${UPLOAD_B.toUpperCase()}\\a b.txt"` } + ), + 'win32' + ) + expect([...references.uploadIds].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + it('ignores a message with no store reference', () => { + expect( + agentSessionAttachmentReferences(root, message({ type: 'text', text: 'hello' }), 'linux') + ).toEqual({ uploadIds: new Set(), foreign: 0 }) + }) +}) + +describe('claims written with the message', () => { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-c', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + } + let stateDirectory: string + let storeRoot: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function open(sessionId = IDENTITY.sessionId): Promise { + return journals.open({ + identity: { ...IDENTITY, sessionId }, + stateDirectory, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function storeUpload(uploadId: string, name = 'shot.png'): Promise { + await mkdir(join(storeRoot, uploadId), { recursive: true }) + await writeFile(join(storeRoot, uploadId, name), 'bytes') + return join(storeRoot, uploadId, name) + } + + function claims(): { upload_id: string; session_id: string }[] { + return openTestJournalHostDatabase(stateDirectory) + .db.prepare( + 'SELECT upload_id, session_id FROM agent_session_attachment_claims ORDER BY session_id' + ) + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + 'session_id' in row && + typeof row.upload_id === 'string' && + typeof row.session_id === 'string' + ? [{ upload_id: row.upload_id, session_id: row.session_id }] + : [] + ) + } + + function clientSend(journal: AgentSessionJournal, id: string, body: AgentJournalMessageItem) { + return journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body, + fence: 0, + origin: 'client' + }) + } + + beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-claims-')) + storeRoot = agentSessionAttachmentStoreRoot(stateDirectory) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(stateDirectory, { recursive: true, force: true }) + }) + + it("claims a client message's stored uploads in the same write", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + expect(journal.submission('send-1')).toBeTruthy() + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) + }) + + it('refuses the whole client message, writing nothing, when an upload is gone', async () => { + const journal = await open() + const error = await clientSend( + journal, + 'send-1', + message( + { type: 'text', text: 'see the image' }, + { type: 'image-ref', path: join(storeRoot, UPLOAD_A, 'shot.png') } + ) + ).catch((caught: unknown) => caught) + expect(isAgentSessionAttachmentExpiredError(error)).toBe(true) + expect(journal.submission('send-1')).toBeUndefined() + expect(claims()).toEqual([]) + }) + + it('refuses an upload the sweep already marked, even while its file is still on disk', async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + expect(markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1)).toBe(true) + const error = await clientSend(journal, 'send-1', message({ type: 'image-ref', path })).catch( + (caught: unknown) => caught + ) + expect(isAgentSessionAttachmentExpiredError(error)).toBe(true) + }) + + it('a claim landing first keeps the sweep from marking the upload', async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + expect(markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1)).toBe(false) + }) + + it("never refuses the host's own message, and claims what it names", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await journal.appendSubmission({ + clientMessageId: 'host-1', + payloadFingerprint: 'fp', + body: message( + { type: 'image-ref', path }, + { type: 'image-ref', path: join(storeRoot, UPLOAD_B, 'gone.png') } + ), + fence: 0, + origin: 'host' + }) + expect(journal.submission('host-1')).toBeTruthy() + expect(claims().map((claim) => claim.upload_id).sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => { + const journal = await open() + const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` }) + const refused = await journal.queuedMessages + .insert({ messageId: 'draft-0', body: missing, fingerprint: 'fp', hostInstance: 'p', requireAttachments: true }) + .catch((caught: unknown) => caught) + expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) + expect(journal.queuedMessages.list()).toHaveLength(0) + + const path = await storeUpload(UPLOAD_A, 'notes.txt') + const body = message({ type: 'text', text: `read @${path}` }) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + const replacement = await open('session-d') + await replacement.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp-d', + hostInstance: 'p', + carriedFrom: IDENTITY.sessionId + }) + expect(claims()).toEqual([ + { upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }, + { upload_id: UPLOAD_A, session_id: 'session-d' } + ]) + }) + + it("does not refuse a draft's conversion: the draft claimed its uploads when it was written", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A, 'notes.txt') + const body = message({ type: 'text', text: `read @${path}` }) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + await rm(join(storeRoot, UPLOAD_A), { recursive: true }) + await journal.appendSubmission( + { clientMessageId: 'handoff-1', payloadFingerprint: 'fp', body, fence: 0, origin: 'client' }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + expect(journal.submission('handoff-1')).toBeTruthy() + }) + + it('keeps its claims across a host restart', async () => { + let journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + await journals.closeAll() + journal = await open() + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) + expect(markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts new file mode 100644 index 000000000000..e2eda5343f4a --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -0,0 +1,198 @@ +// Which chats hold each stored attachment. A message claims the uploads it references in the same +// host transaction that makes it durable, and the sweep may delete only an upload nobody claims: +// both are synchronous statements on the host's one journal connection, so exactly one wins. + +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + agentSessionAttachmentReferences, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' + +const CLAIM_SAVEPOINT = 'agent_session_attachment_claims' + +/** + * Created at every writable open with no `user_version` bump, like the draft table + * (`ensureQueuedMessagesTable`): an older build ignores them and stays writable. + * Claims are keyed by upload and chat, so a /clear carry adds the replacement as a second holder + * and a chat's deletion frees an upload only when its last holder goes. + */ +export function ensureAgentSessionAttachmentClaimTables(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS agent_session_attachment_claims ( + upload_id TEXT NOT NULL, + session_id TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + PRIMARY KEY (upload_id, session_id) +); +CREATE TABLE IF NOT EXISTS agent_session_attachment_sweeps ( + upload_id TEXT NOT NULL PRIMARY KEY, + started_at INTEGER NOT NULL +); +`) +} + +export class AgentSessionAttachmentExpiredError extends Error { + constructor() { + super('A referenced chat attachment is no longer stored on this host.') + this.name = 'AgentSessionAttachmentExpiredError' + } +} + +export function isAgentSessionAttachmentExpiredError( + error: unknown +): error is AgentSessionAttachmentExpiredError { + return error instanceof AgentSessionAttachmentExpiredError +} + +function uploadIsStored(root: string, uploadId: string): boolean { + try { + return readdirSync(join(root, uploadId)).some( + (entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE + ) + } catch { + return false + } +} + +function isBeingSwept(db: Database.Database, uploadId: string): boolean { + return ( + db.prepare('SELECT 1 FROM agent_session_attachment_sweeps WHERE upload_id = ?').get(uploadId) !== + undefined + ) +} + +function insertClaims( + db: Database.Database, + uploadIds: Iterable, + sessionId: string, + now: number +): void { + const insert = db.prepare( + 'INSERT OR IGNORE INTO agent_session_attachment_claims (upload_id, session_id, claimed_at) VALUES (?, ?, ?)' + ) + for (const uploadId of uploadIds) { + insert.run(uploadId, sessionId, now) + } +} + +export type AgentSessionAttachmentClaim = { + stateDirectory: string + sessionId: string + body: AgentJournalMessageItem + /** A client's own new message: every store reference must still be stored, or the whole message + * is refused. The host's own writes (a /clear carry, a draft it sends) never refuse. */ + required: boolean + now: number +} + +/** + * Runs inside the transaction that writes the message. Required: throws + * `AgentSessionAttachmentExpiredError`, rolling the write back, when a reference names an upload + * this host does not hold or is deleting. Best effort: claims what it can and reports, never + * failing the write it rides on. + */ +export function claimAgentSessionAttachmentsInTransaction( + db: Database.Database, + claim: AgentSessionAttachmentClaim +): void { + const root = agentSessionAttachmentStoreRoot(claim.stateDirectory) + const references = agentSessionAttachmentReferences(root, claim.body) + if (references.uploadIds.size === 0 && references.foreign === 0) { + return + } + if (claim.required) { + if ( + references.foreign > 0 || + [...references.uploadIds].some( + (uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId) + ) + ) { + throw new AgentSessionAttachmentExpiredError() + } + insertClaims(db, references.uploadIds, claim.sessionId, claim.now) + return + } + db.exec(`SAVEPOINT ${CLAIM_SAVEPOINT}`) + try { + insertClaims(db, references.uploadIds, claim.sessionId, claim.now) + db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) + } catch (error) { + db.exec(`ROLLBACK TO ${CLAIM_SAVEPOINT}`) + db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) + console.warn('[agent-session-attachments] claim skipped:', { + sessionId: claim.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** The sweep's side of the race: marks an upload as being deleted only while nobody claims it. + * True means this sweep won and may delete it; every later claim refuses it. */ +export function markUnclaimedUploadForSweep( + db: Database.Database, + uploadId: string, + now: number +): boolean { + const result = db + .prepare( + `INSERT OR IGNORE INTO agent_session_attachment_sweeps (upload_id, started_at) + SELECT ?, ? WHERE NOT EXISTS + (SELECT 1 FROM agent_session_attachment_claims WHERE upload_id = ?)` + ) + .run(uploadId, now, uploadId) + return Number(result.changes) > 0 +} + +export function listUploadsBeingSwept(db: Database.Database): string[] { + return db + .prepare('SELECT upload_id FROM agent_session_attachment_sweeps') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'upload_id' in row && typeof row.upload_id === 'string' + ? [row.upload_id] + : [] + ) +} + +export function finishUploadSweep(db: Database.Database, uploadId: string): void { + db.prepare('DELETE FROM agent_session_attachment_sweeps WHERE upload_id = ?').run(uploadId) +} + +/** + * Claims nothing can use any more: their chat has no record, or their upload is gone from disk + * (removed outside Orca). Re-derived on every sweep, so a claim cannot outlive what it protects. + * Synchronous with the facts it reads, so a claim written meanwhile is judged on today's facts. + */ +export function pruneAgentSessionAttachmentClaims( + db: Database.Database, + facts: { root: string; recordedSessionIds: ReadonlySet | null } +): number { + const rows = db + .prepare('SELECT upload_id, session_id FROM agent_session_attachment_claims') + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + 'session_id' in row && + typeof row.upload_id === 'string' && + typeof row.session_id === 'string' + ? [{ uploadId: row.upload_id, sessionId: row.session_id }] + : [] + ) + const remove = db.prepare( + 'DELETE FROM agent_session_attachment_claims WHERE upload_id = ? AND session_id = ?' + ) + let removed = 0 + for (const { uploadId, sessionId } of rows) { + const chatGone = facts.recordedSessionIds !== null && !facts.recordedSessionIds.has(sessionId) + if (chatGone || !existsSync(join(facts.root, uploadId))) { + removed += Number(remove.run(uploadId, sessionId).changes) + } + } + return removed +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts deleted file mode 100644 index 95f5370fe90f..000000000000 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - closeTestJournalHostDatabase, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import { createAgentSessionAttachmentJournalMentions } from './agent-session-attachment-journal-references' - -// A Windows path is the case that matters: JSON escapes its separators, so a raw-path search misses. -const storedPath = 'C:\\Users\\me\\AppData\\Orca\\agent-session-attachments\\abc\\u1\\shot.png' -const needle = JSON.stringify(storedPath).slice(1, -1) - -let stateDirectory: string - -beforeEach(async () => { - stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-journal-')) -}) - -afterEach(async () => { - closeTestJournalHostDatabase(stateDirectory) - await rm(stateDirectory, { recursive: true, force: true }) -}) - -function insertRow(sessionId: string, seq: number, row: unknown): void { - openTestJournalHostDatabase(stateDirectory) - .db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - .run(sessionId, 'epoch-1', seq, 1, JSON.stringify(row)) -} - -describe('createAgentSessionAttachmentJournalMentions', () => { - it('finds a sent image by its path in the chat journal', () => { - insertRow('session-1', 1, { - kind: 'item', - body: { kind: 'message', blocks: [{ type: 'image-ref', path: storedPath }] } - }) - const mentions = createAgentSessionAttachmentJournalMentions( - () => openTestJournalHostDatabase(stateDirectory).db - ) - expect(mentions('session-1', needle)).toBe(true) - expect(mentions('session-1', JSON.stringify('/other.png').slice(1, -1))).toBe(false) - }) - - it('finds a file referenced in message text', () => { - insertRow('session-1', 1, { - body: { kind: 'message', blocks: [{ type: 'text', text: `look at @${storedPath} please` }] } - }) - const mentions = createAgentSessionAttachmentJournalMentions( - () => openTestJournalHostDatabase(stateDirectory).db - ) - expect(mentions('session-1', needle)).toBe(true) - }) - - it("does not count another chat's journal", () => { - insertRow('session-2', 1, { blocks: [{ type: 'image-ref', path: storedPath }] }) - insertRow('session-1', 1, { blocks: [{ type: 'text', text: 'hello' }] }) - const mentions = createAgentSessionAttachmentJournalMentions( - () => openTestJournalHostDatabase(stateDirectory).db - ) - expect(mentions('session-1', needle)).toBe(false) - }) - - it('counts a host-held queued draft', () => { - insertRow('session-1', 1, { blocks: [{ type: 'text', text: 'hello' }] }) - openTestJournalHostDatabase(stateDirectory) - .db.prepare( - `INSERT INTO queued_messages - (session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - 'session-1', - 'queued-1', - 0, - JSON.stringify({ blocks: [{ type: 'image-ref', path: storedPath }] }), - 'fp', - 1, - 'host-1', - 'queued' - ) - const mentions = createAgentSessionAttachmentJournalMentions( - () => openTestJournalHostDatabase(stateDirectory).db - ) - expect(mentions('session-1', needle)).toBe(true) - }) - - it('says nothing about a chat whose journal holds no rows', () => { - const mentions = createAgentSessionAttachmentJournalMentions( - () => openTestJournalHostDatabase(stateDirectory).db - ) - expect(mentions('session-1', needle)).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts deleted file mode 100644 index 21a681a0f53b..000000000000 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-journal-references.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type Database from '../../sqlite/sync-database' - -/** - * Whether a chat's journal or its host-held queued drafts mention `needle` (a JSON-escaped path). - * Null when the chat has no journal rows at all: an unread or not-yet-written journal is no - * evidence that a file was never sent. - */ -export function createAgentSessionAttachmentJournalMentions( - db: () => Database.Database -): (sessionId: string, needle: string) => boolean | null { - return (sessionId, needle) => { - const connection = db() - const hasRows = connection - .prepare('SELECT 1 FROM journal_rows WHERE session_id = ? LIMIT 1') - .get(sessionId) - if (!hasRows) { - return null - } - const inRows = connection - .prepare('SELECT 1 FROM journal_rows WHERE session_id = ? AND instr(row_json, ?) > 0 LIMIT 1') - .get(sessionId, needle) - if (inRows) { - return true - } - const inQueue = connection - .prepare( - 'SELECT 1 FROM queued_messages WHERE session_id = ? AND instr(body_json, ?) > 0 LIMIT 1' - ) - .get(sessionId, needle) - return Boolean(inQueue) - } -} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts new file mode 100644 index 000000000000..b71772fc4eb0 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts @@ -0,0 +1,93 @@ +// How the host recognizes its own attachment store in what a client sends: a stored file is +// `//`, and only the upload id is needed to know which upload it is. + +import { isAbsolute, join, relative, sep } from 'node:path' +import { AGENT_SESSION_ATTACHMENTS_DIR_NAME } from '../../../shared/agent-session-attachments' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' + +export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' + +const UPLOAD_ID_SOURCE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const UPLOAD_ID = new RegExp(`^${UPLOAD_ID_SOURCE}$`) + +export function agentSessionAttachmentStoreRoot(stateDirectory: string): string { + return join(stateDirectory, AGENT_SESSION_ATTACHMENTS_DIR_NAME) +} + +export function isAgentSessionAttachmentUploadId(value: string): boolean { + return UPLOAD_ID.test(value) +} + +/** The upload a stored file belongs to, or null for anything that is not exactly a committed file + * directly in an upload directory of this store. */ +export function parseAgentSessionAttachmentStorePath( + root: string, + filePath: string +): { uploadId: string; name: string } | null { + if (!isAbsolute(filePath)) { + return null + } + const inside = relative(root, filePath) + const segments = inside.split(sep) + const [uploadId, name] = segments + if ( + inside === '' || + isAbsolute(inside) || + segments.length !== 2 || + !uploadId || + !name || + !isAgentSessionAttachmentUploadId(uploadId) || + name === AGENT_SESSION_ATTACHMENT_PART_FILE + ) { + return null + } + return { uploadId, name } +} + +export type AgentSessionAttachmentReferences = { + /** Uploads in this host's store. */ + uploadIds: Set + /** Store-shaped references whose root is not this host's: another server's, or a stale path. */ + foreign: number +} + +function normalizedRoot(root: string, platform: NodeJS.Platform): string { + const forward = root.replace(/\\/g, '/') + return platform === 'win32' ? forward.toLowerCase() : forward +} + +/** + * Every store reference in a message body: image paths and file references in its text alike. + * Matched on the decoded body by `/`, so quoting or a file name with spaces + * cannot hide one; Windows paths match with either separator and in any case. + */ +export function agentSessionAttachmentReferences( + root: string, + body: AgentJournalMessageItem, + platform: NodeJS.Platform = process.platform +): AgentSessionAttachmentReferences { + const references: AgentSessionAttachmentReferences = { uploadIds: new Set(), foreign: 0 } + const ownRoot = normalizedRoot(root, platform) + const pattern = new RegExp( + `${AGENT_SESSION_ATTACHMENTS_DIR_NAME}[\\\\/](${UPLOAD_ID_SOURCE})(?![0-9a-z-])`, + platform === 'win32' ? 'gi' : 'g' + ) + for (const block of body.blocks) { + const text = + block.type === 'text' ? block.text : block.type === 'image-ref' ? block.path : undefined + if (!text) { + continue + } + for (const match of text.matchAll(pattern)) { + const rootEnd = match.index + AGENT_SESSION_ATTACHMENTS_DIR_NAME.length + const prefix = text.slice(Math.max(0, rootEnd - root.length), rootEnd) + const uploadId = match[1]?.toLowerCase() + if (uploadId && normalizedRoot(prefix, platform) === ownRoot) { + references.uploadIds.add(uploadId) + } else { + references.foreign++ + } + } + } + return references +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts index d3b1b75de035..6c8087d06e1b 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -1,8 +1,10 @@ -import { mkdtemp, readdir, readFile, rm, stat, writeFile, mkdir } from 'node:fs/promises' +import { mkdtemp, readdir, readFile, rm, stat, symlink, writeFile, mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSessionAttachmentStore } from './agent-session-attachment-store' +import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../../shared/remote-rpc-content-budget' +import { isMobileE2EETextPayloadWithinLimit } from '../../runtime/rpc/mobile-e2ee-outbound-admission' let root: string let store: AgentSessionAttachmentStore @@ -10,7 +12,9 @@ const caller = 'client-a' beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-attachments-')) - store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId.startsWith('session-') + }) }) afterEach(async () => { @@ -36,11 +40,12 @@ async function upload(name: string, bytes: Buffer, sessionId = 'session-1') { } describe('AgentSessionAttachmentStore', () => { - it('stores the bytes under the chat, keeping the file name', async () => { + it('stores the bytes in an upload directory of its own, keeping the file name', async () => { const stored = await upload('screen shot.png', Buffer.from('png-bytes')) expect(stored.name).toBe('screen shot.png') expect(stored.byteLength).toBe(9) - expect(stored.path.startsWith(store.sessionDirectory('session-1'))).toBe(true) + expect(await readdir(store.rootDir)).toHaveLength(1) + expect(stored.path.startsWith(store.rootDir)).toBe(true) expect(stored.path.endsWith('screen shot.png')).toBe(true) expect(await readFile(stored.path, 'utf8')).toBe('png-bytes') // The part file is gone once the name holds the whole file. @@ -50,7 +55,14 @@ describe('AgentSessionAttachmentStore', () => { it('keeps only the last path segment of a name, never a path out of the store', async () => { const stored = await upload('../../../etc/passwd', Buffer.from('x')) expect(stored.name).toBe('passwd') - expect(stored.path.startsWith(store.sessionDirectory('session-1'))).toBe(true) + expect(stored.path.startsWith(store.rootDir)).toBe(true) + }) + + it('refuses an upload for a chat this host does not hold', async () => { + await expect( + store.startUpload({ callerKey: caller, sessionId: 'unknown', name: 'a.txt', byteLength: 1 }) + ).rejects.toThrow('not on this host') + await expect(readdir(store.rootDir)).rejects.toThrow() }) it('appends chunks in order and refuses one out of order or past the declared size', async () => { @@ -81,7 +93,7 @@ describe('AgentSessionAttachmentStore', () => { byteLength: 10 }) await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('incomplete') - expect(await readdir(store.sessionDirectory('session-1'))).toEqual([]) + expect(await readdir(store.rootDir)).toEqual([]) }) it('stores an empty file', async () => { @@ -117,7 +129,7 @@ describe('AgentSessionAttachmentStore', () => { }) await store.abortUpload({ callerKey: caller, uploadId }) expect(store.isUploadInFlight(uploadId)).toBe(false) - expect(await readdir(store.sessionDirectory('session-1'))).toEqual([]) + expect(await readdir(store.rootDir)).toEqual([]) }) it('forgets and removes an upload its client abandoned', async () => { @@ -131,7 +143,7 @@ describe('AgentSessionAttachmentStore', () => { vi.advanceTimersByTime(5 * 60 * 1000 + 1) vi.useRealTimers() await vi.waitFor(async () => - expect(await readdir(store.sessionDirectory('session-1'))).toEqual([]) + expect(await readdir(store.rootDir)).toEqual([]) ) expect(store.isUploadInFlight(uploadId)).toBe(false) await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('not found') @@ -152,14 +164,14 @@ describe('AgentSessionAttachmentStore', () => { await expect(store.readPreview(notImage.path)).rejects.toThrow('Not an attachment image') }) - it('reads only a file exactly at //', async () => { + it('reads only a file exactly at /', async () => { const { uploadId } = await store.startUpload({ callerKey: caller, sessionId: 'session-1', name: 'shot.png', byteLength: 3 }) - const uploadDir = join(store.sessionDirectory('session-1'), uploadId) + const uploadDir = join(store.rootDir, uploadId) // Deeper than a stored file: nothing the store wrote lives there. await mkdir(join(uploadDir, 'nested'), { recursive: true }) await writeFile(join(uploadDir, 'nested', 'x.png'), 'x') @@ -167,4 +179,41 @@ describe('AgentSessionAttachmentStore', () => { 'Not an attachment image' ) }) + + it('never follows a link out of the store', async () => { + const stored = await upload('shot.png', Buffer.from('png-bytes')) + const outside = join(root, 'secret.png') + await writeFile(outside, 'secret') + await rm(stored.path) + await symlink(outside, stored.path) + await expect(store.readPreview(stored.path)).rejects.toThrow('Not an attachment image') + }) + + it('refuses an image over the reply budget instead of overflowing the connection', async () => { + const big = Buffer.alloc(3.5 * 1024 * 1024, 1) + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'big.png', + byteLength: big.byteLength + }) + await store.appendChunk({ + callerKey: caller, + uploadId, + offset: 0, + contentBase64: big.toString('base64') + }) + const stored = await store.commitUpload({ callerKey: caller, uploadId }) + // The budget a remote reply gets: what the server's outbound limit leaves after the envelope. + await expect(store.readPreview(stored.path, REMOTE_RPC_MAX_CONTENT_BYTES)).rejects.toThrow( + 'file_too_large' + ) + // A local read keeps the larger preview limit. + await expect(store.readPreview(stored.path)).resolves.toMatchObject({ mimeType: 'image/png' }) + // An image that fits the budget also fits the server's outbound limit once enveloped. + const fits = await upload('fits.png', Buffer.alloc(2.5 * 1024 * 1024, 1)) + const preview = await store.readPreview(fits.path, REMOTE_RPC_MAX_CONTENT_BYTES) + const reply = JSON.stringify({ id: 'request-1', ok: true, result: preview }) + expect(isMobileE2EETextPayloadWithinLimit(reply)).toBe(true) + }) }) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index 7dff24a461a5..7d783abf026e 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -1,52 +1,42 @@ // The server-owned store for files a client attaches to a structured chat. // -// `///`: one directory per upload keeps the -// user's file name intact (agents read meaning into it) without two uploads colliding. Bytes land -// in a hidden part file and are renamed into place on commit, so a stored name is always complete. -// -// Nothing here records what is owed: whether a file may go is re-derived on every sweep from the -// host's chat records and journal (see `sweepAgentSessionAttachments`). +// `//`: one directory per upload keeps the user's file name +// intact (agents read meaning into it) without two uploads colliding. Bytes land in a hidden part +// file and are renamed into place on commit, so a stored name is always complete. Which chats hold +// an upload is recorded by the message that names it (`agent-session-attachment-claims.ts`). +import { appendFile, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { extname, join } from 'node:path' import { - appendFile, - mkdir, - readFile, - realpath, - rename, - rm, - stat, - writeFile -} from 'node:fs/promises' -import { extname, join, relative, isAbsolute, sep } from 'node:path' -import { - AGENT_SESSION_ATTACHMENT_PREVIEW_MAX_BYTES, sanitizeAgentSessionAttachmentName, type AgentSessionAttachmentUploadCommitResult } from '../../../shared/agent-session-attachments' -import { journalPathSegment } from '../agent-session-journal/journal-paths' +import type { RuntimeFilePreviewResult } from '../../../shared/runtime-types' +import { readAuthorizedDocPreviewFile } from '../../../shared/doc-preview-file-access' +import { IMAGE_FILE_MIME_TYPES } from '../../../shared/image-file-extensions' +import { + LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, + assertPreviewWithinTransportBudget, + previewableBinaryByteLimit +} from '../../runtime/runtime-file-commands-mobile-file-list-limit' +import { ChunkedUploadRegistry, nextChunkedUploadLength } from './chunked-upload-registry' import { - ChunkedUploadRegistry, - nextChunkedUploadLength -} from './chunked-upload-registry' + AGENT_SESSION_ATTACHMENT_PART_FILE, + parseAgentSessionAttachmentStorePath +} from './agent-session-attachment-references' -export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' const UPLOAD_MAX_CONCURRENT = 8 const UPLOAD_IDLE_TTL_MS = 5 * 60 * 1000 const NOT_FOUND = 'Attachment upload was not found' +const NOT_AN_IMAGE = 'Not an attachment image' +const PREVIEW_TOO_LARGE = 'file_too_large' -const PREVIEW_IMAGE_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.avif': 'image/avif' +function isTooLarge(error: unknown): boolean { + return error instanceof Error && error.message === PREVIEW_TOO_LARGE } type InFlightUpload = { callerKey: string - sessionId: string uploadDir: string name: string expectedLength: number @@ -54,6 +44,11 @@ type InFlightUpload = { writing: boolean } +export type AgentSessionAttachmentStoreOptions = { + /** Whether this host holds the chat: an upload for any other chat is refused at its start. */ + hasSession: (sessionId: string) => boolean +} + export class AgentSessionAttachmentStore { private readonly uploads = new ChunkedUploadRegistry({ maxConcurrent: UPLOAD_MAX_CONCURRENT, @@ -63,11 +58,10 @@ export class AgentSessionAttachmentStore { onExpire: (upload) => void removeQuietly(upload.uploadDir) }) - constructor(readonly rootDir: string) {} - - sessionDirectory(sessionId: string): string { - return join(this.rootDir, journalPathSegment(sessionId)) - } + constructor( + readonly rootDir: string, + private readonly options: AgentSessionAttachmentStoreOptions + ) {} /** Upload ids whose bytes are still arriving; the sweep leaves their directories alone. */ isUploadInFlight(uploadId: string): boolean { @@ -80,18 +74,19 @@ export class AgentSessionAttachmentStore { name: string byteLength: number }): Promise<{ uploadId: string }> { - const sessionDir = this.sessionDirectory(args.sessionId) + if (!this.options.hasSession(args.sessionId)) { + throw new Error('This chat is not on this host') + } const name = sanitizeAgentSessionAttachmentName(args.name) const uploadId = this.uploads.create((id) => ({ callerKey: args.callerKey, - sessionId: args.sessionId, - uploadDir: join(sessionDir, id), + uploadDir: join(this.rootDir, id), name, expectedLength: args.byteLength, receivedLength: 0, writing: false })) - const uploadDir = join(sessionDir, uploadId) + const uploadDir = join(this.rootDir, uploadId) try { await mkdir(uploadDir, { recursive: true }) await writeFile(join(uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), '', { flag: 'wx' }) @@ -163,36 +158,43 @@ export class AgentSessionAttachmentStore { return { aborted: true } } - /** A stored image's bytes, for a client that shows it. Anything outside the store is refused. */ - async readPreview( - filePath: string - ): Promise<{ content: string; isBinary: true; isImage: true; mimeType: string }> { - const mimeType = PREVIEW_IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - if (!isAbsolute(filePath) || !mimeType) { - throw new Error('Not an attachment image') - } - const [root, resolved] = await Promise.all([realpath(this.rootDir), realpath(filePath)]) - const inside = relative(root, resolved) - const segments = inside.split(sep) - // `//` exactly: never a part file, never anything shallower or deeper. + /** + * A stored image's bytes, for a client that shows it. Anything outside the store is refused, and + * the read is protected and bounded: `maxContentBytes` is the reply's budget on a remote + * connection, and an image over it is refused as too large rather than sent. + */ + async readPreview(filePath: string, maxContentBytes?: number): Promise { if ( - inside === '' || - isAbsolute(inside) || - segments.length !== 3 || - segments.some((segment) => segment === '..') || - segments[2] === AGENT_SESSION_ATTACHMENT_PART_FILE + !IMAGE_FILE_MIME_TYPES[extname(filePath).toLowerCase()] || + !parseAgentSessionAttachmentStorePath(this.rootDir, filePath) ) { - throw new Error('Not an attachment image') + throw new Error(NOT_AN_IMAGE) } - const info = await stat(resolved) - if (!info.isFile()) { - throw new Error('Not an attachment image') + let read: Awaited> + try { + read = await readAuthorizedDocPreviewFile({ + boundaryPath: this.rootDir, + entryPath: filePath, + implicitRootPath: null, + authorizedRootPaths: [], + targetPath: filePath, + maxTextBytes: 0, + maxBinaryBytes: + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + }) + } catch (error) { + // One answer for everything else, so a read says nothing about the server's other files. + throw new Error(isTooLarge(error) ? PREVIEW_TOO_LARGE : NOT_AN_IMAGE) } - if (info.size > AGENT_SESSION_ATTACHMENT_PREVIEW_MAX_BYTES) { - throw new Error('Attachment image is too large to preview') + if (!read.isBinary || !read.mimeType?.startsWith('image/')) { + throw new Error(NOT_AN_IMAGE) } - const content = (await readFile(resolved)).toString('base64') - return { content, isBinary: true, isImage: true, mimeType } + return assertPreviewWithinTransportBudget( + { content: read.content, isBinary: true, isImage: true, mimeType: read.mimeType }, + maxContentBytes + ) } clearInFlightForTests(): void { diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts index 433af38f4863..e414633a66bc 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts @@ -1,37 +1,66 @@ import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { + claimAgentSessionAttachmentsInTransaction, + isAgentSessionAttachmentExpiredError, + markUnclaimedUploadForSweep +} from './agent-session-attachment-claims' import { AGENT_SESSION_ATTACHMENT_PART_FILE, - AgentSessionAttachmentStore -} from './agent-session-attachment-store' -import { sweepAgentSessionAttachments } from './agent-session-attachment-sweep' + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' +import { AgentSessionAttachmentStore } from './agent-session-attachment-store' +import { + sweepAgentSessionAttachments, + type AgentSessionAttachmentSweepFacts +} from './agent-session-attachment-sweep' const HOUR = 60 * 60 * 1000 const NOW = Date.now() +const OLD = 24 * HOUR + 1 +const SENT = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const UNSENT = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' +const CARRIED = '3e1f0a9b-8c7d-4e6f-a5b4-c3d2e1f0a903' -let root: string +let stateDirectory: string let store: AgentSessionAttachmentStore +let db: Database.Database +let recorded: Set | null beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-attachment-sweep-')) - store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments')) + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-sweep-')) + store = new AgentSessionAttachmentStore(agentSessionAttachmentStoreRoot(stateDirectory), { + hasSession: () => true + }) + db = openTestJournalHostDatabase(stateDirectory).db + recorded = new Set(['session-1', 'session-2']) }) afterEach(async () => { store.clearInFlightForTests() - await rm(root, { recursive: true, force: true }) + closeTestJournalHostDatabases() + await rm(stateDirectory, { recursive: true, force: true }) }) +function facts(overrides: Partial = {}) { + return { database: () => db, recordedSessionIds: () => recorded, ...overrides } +} + /** A stored upload (or a bare part file) last touched `ageMs` ago. */ async function seedUpload( - sessionId: string, uploadId: string, ageMs: number, file: string = 'shot.png' ): Promise { - const uploadDir = join(store.sessionDirectory(sessionId), uploadId) + const uploadDir = join(store.rootDir, uploadId) await mkdir(uploadDir, { recursive: true }) await writeFile(join(uploadDir, file), 'bytes') const at = new Date(NOW - ageMs) @@ -39,102 +68,132 @@ async function seedUpload( return join(uploadDir, file) } -async function remaining(sessionId: string): Promise { - return readdir(store.sessionDirectory(sessionId)).catch(() => []) +function claim(sessionId: string, path: string, required = true): void { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path }] + } + db.exec('BEGIN') + try { + claimAgentSessionAttachmentsInTransaction(db, { + stateDirectory, + sessionId, + body, + required, + now: NOW + }) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +function claimedSessions(): string[] { + return db + .prepare('SELECT session_id FROM agent_session_attachment_claims ORDER BY session_id') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'session_id' in row ? [String(row.session_id)] : [] + ) +} + +async function remaining(): Promise { + return (await readdir(store.rootDir).catch(() => [])).sort() } describe('sweepAgentSessionAttachments', () => { it('removes a part file nobody finished after an hour, and keeps a fresh one', async () => { - await seedUpload('session-1', 'stale', HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) - await seedUpload('session-1', 'fresh', HOUR - 1000, AGENT_SESSION_ATTACHMENT_PART_FILE) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds: () => ['session-1'], journalMentions: () => true }, - NOW - ) - expect(await remaining('session-1')).toEqual(['fresh']) + await seedUpload(SENT, HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await seedUpload(UNSENT, HOUR - 1000, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([UNSENT]) }) - it('removes the uploads of a chat that never came to exist, after a day', async () => { - await seedUpload('ghost', 'old', 24 * HOUR + 1) - await seedUpload('ghost', 'new', HOUR) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds: () => [], journalMentions: () => null }, - NOW - ) - expect(await remaining('ghost')).toEqual(['new']) + it('removes an upload nobody claimed after a day, and keeps a claimed one and a fresh one', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + await seedUpload(UNSENT, OLD) + await seedUpload(CARRIED, HOUR) + const { removed } = await sweepAgentSessionAttachments(store, facts(), NOW) + expect(removed).toEqual([join(store.rootDir, UNSENT)]) + expect(await remaining()).toEqual([SENT, CARRIED].sort()) }) - it('judges nothing abandoned while the record list is incomplete', async () => { - await seedUpload('ghost', 'old', 24 * HOUR + 1) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds: () => null, journalMentions: () => null }, - NOW - ) - expect(await remaining('ghost')).toEqual(['old']) + it('keeps an upload any chat still claims: a /clear carry is a second holder', async () => { + const path = await seedUpload(CARRIED, OLD) + claim('session-1', path) + claim('session-2', path, false) + // The source chat goes; the replacement still holds the upload. + recorded = new Set(['session-2']) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([CARRIED]) + expect(claimedSessions()).toEqual(['session-2']) + // Once no chat holds it, it is unclaimed and goes. + recorded = new Set() + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual([]) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([]) }) - it("removes a recorded chat's upload its journal never mentions, and keeps a sent one", async () => { - const sent = await seedUpload('session-1', 'sent', 48 * HOUR) - await seedUpload('session-1', 'unsent', 48 * HOUR) - const journalMentions = vi.fn( - (_sessionId: string, needle: string) => needle === JSON.stringify(sent).slice(1, -1) - ) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds: () => ['session-1'], journalMentions }, - NOW - ) - expect(await remaining('session-1')).toEqual(['sent']) - expect(journalMentions).toHaveBeenCalledWith('session-1', expect.any(String)) + it('a send after the sweep marked an upload is refused, never sent without its file', async () => { + const path = await seedUpload(UNSENT, OLD) + // The sweep wins the race: its mark lands before the claim. + expect(markUnclaimedUploadForSweep(db, UNSENT, NOW)).toBe(true) + let refused: unknown + try { + claim('session-1', path) + } catch (error) { + refused = error + } + expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) + expect(claimedSessions()).toEqual([]) }) - it('keeps a recorded chat upload while its journal holds no rows yet', async () => { - await seedUpload('session-1', 'unknown', 48 * HOUR) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds: () => ['session-1'], journalMentions: () => null }, - NOW - ) - expect(await remaining('session-1')).toEqual(['unknown']) + it('finishes a delete a crashed sweep left marked, then forgets the mark', async () => { + await seedUpload(UNSENT, HOUR) + // Marked, then the process died before the files went. + expect(markUnclaimedUploadForSweep(db, UNSENT, NOW)).toBe(true) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([]) + expect(db.prepare('SELECT COUNT(*) AS n FROM agent_session_attachment_sweeps').get()).toEqual({ + n: 0 + }) + }) + + it('judges no claim orphaned while the record list is incomplete', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + recorded = null + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual(['session-1']) + expect(await remaining()).toEqual([SENT]) }) - it('does not rescan a file already proven sent', async () => { - await seedUpload('session-1', 'sent', 48 * HOUR) - const journalMentions = vi.fn(() => true) - const known = new Set() - const facts = { recordedSessionIds: () => ['session-1'], journalMentions } - await sweepAgentSessionAttachments(store, facts, NOW, known) - await sweepAgentSessionAttachments(store, facts, NOW, known) - expect(journalMentions).toHaveBeenCalledTimes(1) + it('drops a claim whose upload was removed outside Orca', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + await rm(join(store.rootDir, SENT), { recursive: true }) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual([]) + }) + + it('removes only part files when it cannot write claims (a newer Orca wrote the journal)', async () => { + await seedUpload(UNSENT, OLD) + await seedUpload(SENT, HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments(store, facts({ database: () => null }), NOW) + expect(await remaining()).toEqual([UNSENT]) }) it('leaves an upload in flight alone however old its directory is', async () => { const { uploadId } = await store.startUpload({ callerKey: 'client-a', - sessionId: 'ghost', - name: 'a.txt', + sessionId: 'session-1', + name: 'a.png', byteLength: 1 }) - const at = new Date(NOW - 48 * HOUR) - await utimes(join(store.sessionDirectory('ghost'), uploadId), at, at) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds: () => [], journalMentions: () => null }, - NOW - ) - expect(await remaining('ghost')).toEqual([uploadId]) - }) - - it('reads no facts when nothing is stored', async () => { - const recordedSessionIds = vi.fn(() => []) - await sweepAgentSessionAttachments( - store, - { recordedSessionIds, journalMentions: () => null }, - NOW - ) - expect(recordedSessionIds).not.toHaveBeenCalled() + const at = new Date(NOW - OLD) + await utimes(join(store.rootDir, uploadId), at, at) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([uploadId]) }) }) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts index 09d58faa08f8..1c85f5a22199 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts @@ -1,30 +1,35 @@ -// When a stored attachment may go, re-derived from what the host already holds. +// When a stored attachment may go, re-derived on every sweep from the disk and the claim table. // // - A part file nobody is writing (the uploader crashed or vanished) goes after an hour. -// - A chat the host has no record of never came to exist: its uploads go after a day. -// - A recorded chat's upload that its journal never mentions after a day was never sent (the chip -// was removed, the draft abandoned): it goes too. A chat with no journal rows yet is no evidence. -// - Everything a chat's journal mentions lives as long as that chat. The host never deletes a chat +// - An upload no message ever claimed goes after a day: its chip was removed or its draft +// abandoned. The sweep first marks it in the same database the claims live in, and only if no +// claim exists; a send that names it afterwards is refused instead of losing its file. +// - A claimed upload stays while any chat holding it has a record. The host never deletes a chat // today, so that is as long as its transcript. +// - A mark left by a sweep that died mid-delete is finished by the next one. -import { readdir, rmdir, stat } from 'node:fs/promises' +import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' -import { journalPathSegment } from '../agent-session-journal/journal-paths' +import type Database from '../../sqlite/sync-database' import { - AGENT_SESSION_ATTACHMENT_PART_FILE, - removeQuietly, - type AgentSessionAttachmentStore -} from './agent-session-attachment-store' + finishUploadSweep, + listUploadsBeingSwept, + markUnclaimedUploadForSweep, + pruneAgentSessionAttachmentClaims +} from './agent-session-attachment-claims' +import { AGENT_SESSION_ATTACHMENT_PART_FILE } from './agent-session-attachment-references' +import { removeQuietly, type AgentSessionAttachmentStore } from './agent-session-attachment-store' export const ABANDONED_PART_MAX_AGE_MS = 60 * 60 * 1000 -export const UNSENT_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000 +export const UNCLAIMED_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000 export type AgentSessionAttachmentSweepFacts = { + /** The host's journal connection, or null when it cannot take writes (closed, or written by a + * newer Orca): without it nothing claimed can be told apart, so only part files go. */ + database: () => Database.Database | null /** Every chat this host holds a record for; null while that list is incomplete (records still - * owed their import), when no upload can be judged abandoned. */ - recordedSessionIds: () => Iterable | null - /** Whether the chat's journal mentions `needle`; null when the journal holds no rows yet. */ - journalMentions: (sessionId: string, needle: string) => boolean | null + * owed their import), when no chat's claims can be judged orphaned. */ + recordedSessionIds: () => ReadonlySet | null } export type AgentSessionAttachmentSweepResult = { removed: string[] } @@ -32,82 +37,57 @@ export type AgentSessionAttachmentSweepResult = { removed: string[] } export async function sweepAgentSessionAttachments( store: AgentSessionAttachmentStore, facts: AgentSessionAttachmentSweepFacts, - now = Date.now(), - /** Verdicts already proven this process: a sent file stays sent, so it is not rescanned. */ - knownReferenced = new Set() + now = Date.now() ): Promise { const removed: string[] = [] - const sessionSegments = await listDirectories(store.rootDir) - if (sessionSegments.length === 0) { - return { removed } + // Read again at every statement: the host may close the connection while the sweep awaits disk. + const { database } = facts + for (const uploadId of withDatabase(database, listUploadsBeingSwept) ?? []) { + await removeQuietly(join(store.rootDir, uploadId)) + withDatabase(database, (db) => finishUploadSweep(db, uploadId)) } - const recorded = facts.recordedSessionIds() - const sessionIdBySegment = new Map() - for (const sessionId of recorded ?? []) { - sessionIdBySegment.set(journalPathSegment(sessionId), sessionId) - } - for (const sessionSegment of sessionSegments) { - const sessionDir = join(store.rootDir, sessionSegment) - const sessionId = sessionIdBySegment.get(sessionSegment) ?? null - const recordsComplete = recorded !== null - for (const uploadId of await listDirectories(sessionDir)) { - if (store.isUploadInFlight(uploadId)) { - continue - } - const uploadDir = join(sessionDir, uploadId) - const verdict = await uploadVerdict(uploadDir, { - sessionId, - recordsComplete, - facts, - now, - knownReferenced - }) - if (verdict === 'remove') { + for (const uploadId of await listDirectories(store.rootDir)) { + if (store.isUploadInFlight(uploadId)) { + continue + } + const uploadDir = join(store.rootDir, uploadId) + const stored = (await listEntries(uploadDir)).some( + (entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE + ) + const ageMs = now - (await modifiedAt(uploadDir)) + if (!stored) { + if (ageMs > ABANDONED_PART_MAX_AGE_MS) { await removeQuietly(uploadDir) removed.push(uploadDir) } - // Each scan can read a long journal; let other work run between uploads. - await new Promise((resolve) => setImmediate(resolve)) + continue + } + // A claim landing first keeps the upload; this mark landing first refuses that claim. + if ( + ageMs <= UNCLAIMED_ATTACHMENT_MAX_AGE_MS || + withDatabase(database, (db) => markUnclaimedUploadForSweep(db, uploadId, now)) !== true + ) { + continue } - // Non-recursive, so an upload that starts in this chat meanwhile keeps its directory. - await rmdir(sessionDir).catch(() => {}) + await removeQuietly(uploadDir) + withDatabase(database, (db) => finishUploadSweep(db, uploadId)) + removed.push(uploadDir) } + withDatabase(database, (db) => + pruneAgentSessionAttachmentClaims(db, { + root: store.rootDir, + recordedSessionIds: facts.recordedSessionIds() + }) + ) return { removed } } -async function uploadVerdict( - uploadDir: string, - context: { - sessionId: string | null - recordsComplete: boolean - facts: AgentSessionAttachmentSweepFacts - now: number - knownReferenced: Set - } -): Promise<'keep' | 'remove'> { - const { sessionId, recordsComplete, facts, now, knownReferenced } = context - const entries = await listEntries(uploadDir) - const stored = entries.filter((entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE) - const ageMs = now - (await modifiedAt(uploadDir)) - if (stored.length === 0) { - return ageMs > ABANDONED_PART_MAX_AGE_MS ? 'remove' : 'keep' - } - if (ageMs <= UNSENT_ATTACHMENT_MAX_AGE_MS) { - return 'keep' - } - if (sessionId === null) { - return recordsComplete ? 'remove' : 'keep' - } - const path = join(uploadDir, stored[0]) - if (knownReferenced.has(path)) { - return 'keep' - } - // Journal rows are JSON, so the path appears there escaped. - const mentioned = facts.journalMentions(sessionId, JSON.stringify(path).slice(1, -1)) - if (mentioned === true) { - knownReferenced.add(path) - } - return mentioned === false ? 'remove' : 'keep' +function withDatabase( + database: AgentSessionAttachmentSweepFacts['database'], + run: (db: Database.Database) => T +): T | undefined { + const db = database() + return db ? run(db) : undefined } async function listEntries(dir: string): Promise { @@ -150,7 +130,6 @@ export function startAgentSessionAttachmentSweeps( onError: (error: unknown) => void } ): AgentSessionAttachmentSweeper { - const knownReferenced = new Set() let running = false let stopped = false const run = (): void => { @@ -158,7 +137,7 @@ export function startAgentSessionAttachmentSweeps( return } running = true - void sweepAgentSessionAttachments(store, facts, Date.now(), knownReferenced) + void sweepAgentSessionAttachments(store, facts) .catch(options.onError) .finally(() => { running = false diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index a306ad6e564e..b3c6d80842c0 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -15,6 +15,7 @@ import { } from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' +import { ensureAgentSessionAttachmentClaimTables } from '../agent-session-attachments/agent-session-attachment-claims' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ @@ -130,6 +131,7 @@ export function openJournalDatabase( // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) + ensureAgentSessionAttachmentClaimTables(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true return { db: probe, readOnly: false, legacyRecordImportOwed } diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a66d43697f64..58e304eff0c6 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -41,6 +41,7 @@ import { settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' +import { claimAgentSessionAttachmentsInTransaction } from '../agent-session-attachments/agent-session-attachment-claims' import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ @@ -101,15 +102,19 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. + * `requireAttachments`: a client's own draft, refused whole when an attachment it names is no + * longer stored; the host's own writes (the carry) claim best effort. */ insert(input: { messageId: string body: AgentJournalMessageItem fingerprint: string hostInstance: string carriedFrom?: string + requireAttachments?: true }): Promise { const { sessionId } = this.deps + const { requireAttachments, ...draft } = input let inserted = false return this.transact( (db) => { @@ -119,13 +124,21 @@ export class JournalQueuedMessages { // gets here, so an existing row is the same accept landing twice. return existing } + const now = this.deps.now() + claimAgentSessionAttachmentsInTransaction(db, { + stateDirectory: this.deps.database().stateDirectory, + sessionId, + body: input.body, + required: requireAttachments === true, + now + }) inserted = true const { epoch, lastSequence } = this.deps.state() return insertQueuedMessage(db, { - ...input, + ...draft, sessionId, queuedAt: { epoch, sequence: lastSequence }, - now: this.deps.now() + now }) }, () => inserted diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155dec..281d862d6f6c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -58,6 +58,7 @@ import type { ResolveDispatchInput } from './journal-store-contracts' import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import { claimAgentSessionAttachmentsInTransaction } from '../agent-session-attachments/agent-session-attachment-claims' import { journalQueueResumeRowBuilder, journalStopEventRowBuilder @@ -342,9 +343,22 @@ export class AgentSessionJournal { * state transition commits in the SAME transaction — exactly-once consume. */ consume?: JournalSubmissionConsume ): Promise { + const consumeHook = + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) return this.rowWriter.append( journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + (db, row) => { + consumeHook?.(db, row) + // A client's new message must name only attachments still stored; a draft's conversion + // was claimed when the draft was written. + claimAgentSessionAttachmentsInTransaction(db, { + stateDirectory: this.database.stateDirectory, + sessionId: this.identity.sessionId, + body: input.body, + required: input.origin === 'client' && !consume, + now: this.now() + }) + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts new file mode 100644 index 000000000000..c2fa1104be8b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -0,0 +1,134 @@ +// A message naming a chat attachment the host no longer stores is refused whole at admission, +// with a reason the client's existing rejected-send row puts into words, on the direct path and +// the queued one alike; a stored one is claimed, and a /clear carries the claim to its new chat. + +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { agentSessionAttachmentStoreRoot } from '../agent-session-attachments/agent-session-attachment-references' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' + +const UPLOAD = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const EXPIRED = { + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'attachmentExpired' } } +} + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function storedPath(name: string): string { + return join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD, name) +} + +async function storeUpload(name: string): Promise { + await mkdir(join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD), { recursive: true }) + await writeFile(storedPath(name), 'bytes') + return storedPath(name) +} + +function clientSend(body: AgentJournalMessageItem, delivery?: 'queue-if-active') { + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + return rig.host.send(CALLER, { + envelope: rig.envelope(fields, 'agentSession.send', clientOperationId), + body, + ...(delivery ? { delivery } : {}), + userSend: true + }) +} + +function claimedSessions(): string[] { + return openTestJournalHostDatabase(rig.root) + .db.prepare('SELECT session_id FROM agent_session_attachment_claims ORDER BY session_id') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'session_id' in row ? [String(row.session_id)] : [] + ) +} + +function textWith(path: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `read @${path}` }] } +} + +describe('attachments at send admission', () => { + it('refuses a send naming an expired image before anything is recorded', async () => { + const result = await clientSend({ + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path: storedPath('shot.png') }] + }) + expect(result).toMatchObject(EXPIRED) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(0) + expect(rig.dispatch).not.toHaveBeenCalled() + }) + + it('refuses a queued send naming an expired file, and queues nothing', async () => { + await rig.workingSend() + expect(await clientSend(textWith(storedPath('notes.txt')), 'queue-if-active')).toMatchObject( + EXPIRED + ) + expect(await rig.drafts()).toHaveLength(0) + }) + + it('claims a stored upload, and the /clear carry claims it for the replacement chat', async () => { + const working = await rig.workingSend() + const path = await storeUpload('notes.txt') + expect(await clientSend(textWith(path), 'queue-if-active')).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + expect(claimedSessions()).toEqual([SESSION]) + await rig.stop() + await rig.settleAccepted(working, 'a') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + expect(replacementId).toBeTruthy() + expect(claimedSessions()).toEqual([SESSION, replacementId].sort()) + }) + + it('replays an admitted send under the same id without judging its attachments again', async () => { + const path = await storeUpload('shot.png') + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path }] + } + const clientOperationId = hostTestOperationId() + const params = { + envelope: rig.envelope({ body }, 'agentSession.send', clientOperationId), + body, + userSend: true as const + } + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + // The upload goes; the outbox's resend of the same operation still answers as sent. + await rm(join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD), { recursive: true }) + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + }) + + it("never refuses a host-side send (orchestration mail, a restart's continuation)", async () => { + const body = textWith(storedPath('notes.txt')) + const clientOperationId = hostTestOperationId() + const result = await rig.host.send(CALLER, { + envelope: rig.envelope({ body }, 'agentSession.send', clientOperationId), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac1fdbb0617c..b6d217cf94c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -32,6 +32,8 @@ import { } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' +import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -194,6 +196,8 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + /** A client's own send: every attachment it names must still be stored. */ + userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -231,12 +235,21 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert({ - messageId: clientMessageId, - body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }) + let row: QueuedMessageRow + try { + row = await ctx.journal.queuedMessages.insert({ + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance(), + ...(params.userSend ? { requireAttachments: true } : {}) + }) + } catch (error) { + if (isAgentSessionAttachmentExpiredError(error)) { + return agentSessionAttachmentExpiredRefusal() + } + throw error + } return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index fa600a229dcf..3f53b3855c39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -42,6 +42,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -77,6 +78,17 @@ function invalid( return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } +/** A message naming an attachment this host no longer stores is refused whole, before delivery. */ +export function agentSessionAttachmentExpiredRefusal(): { + ok: false + refusal: AgentSessionWireRefusal +} { + return invalid( + 'attachmentExpired', + 'A chat attachment in this message is no longer stored on the host, so it was not sent.' + ) +} + /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` * rather than as a rejection — unless the child had not proven its start. Such a child has * accepted nothing (input is written only after it initializes), so a dispatch it could not @@ -144,6 +156,9 @@ export async function performSend( try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch (error) { + if (isAgentSessionAttachmentExpiredError(error)) { + return agentSessionAttachmentExpiredRefusal() + } // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the // journal already classified (a copy that did not verify). diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts index 5fdaf6134024..25205d5ce3bc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -14,6 +14,7 @@ import { setAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' +import { isMobileE2EETextPayloadWithinLimit } from '../mobile-e2ee-outbound-admission' import { clearStructuredHostStub, installStructuredHostStub, @@ -25,7 +26,9 @@ let store: AgentSessionAttachmentStore beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-attachment-rpc-')) - store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId === 'session-alpha' + }) setAgentSessionAttachmentStore(store) installStructuredHostStub() }) @@ -73,6 +76,33 @@ function result(response: RpcResponse): T { const CLIENT_A = { ...STRUCTURED_CLIENT, clientId: 'client-a' } +async function uploadBytes(name: string, bytes: Buffer): Promise { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name, byteLength: bytes.byteLength }, + CLIENT_A + ) + ) + const chunk = 384 * 1024 + for (let offset = 0; offset < bytes.byteLength; offset += chunk) { + result( + await call( + 'agentSessionAttachment.uploadAppend', + { + uploadId, + offset, + contentBase64: bytes.subarray(offset, offset + chunk).toString('base64') + }, + CLIENT_A + ) + ) + } + return result<{ path: string }>( + await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) + ).path +} + describe('agentSessionAttachment.*', () => { it('is advertised, so a client can tell a host with the store from an older one', () => { expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY) @@ -97,7 +127,7 @@ describe('agentSessionAttachment.*', () => { await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) ) expect(stored.name).toBe('shot.png') - expect(stored.path.startsWith(store.sessionDirectory('session-alpha'))).toBe(true) + expect(stored.path.startsWith(store.rootDir)).toBe(true) expect(await readFile(stored.path, 'utf8')).toBe('png') const preview = await call('agentSessionAttachment.read', { path: stored.path }, CLIENT_A) @@ -144,4 +174,37 @@ describe('agentSessionAttachment.*', () => { expect(response).toMatchObject({ ok: false }) expect(store.isUploadInFlight(uploadId)).toBe(true) }) + + it('refuses an upload for a chat this host does not hold', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-elsewhere', name: 'a.txt', byteLength: 1 }, + CLIENT_A + ) + expect(response).toMatchObject({ ok: false, error: { message: expect.stringContaining('not on this host') } }) + }) + + it('answers a remote preview of an image over 3 MiB with a small refusal, not an oversized reply', async () => { + const path = await uploadBytes('big.png', Buffer.alloc(3.5 * 1024 * 1024, 7)) + const replies: string[] = [] + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attachment methods read only the host-install hook and runtime id. + runtime: { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => {} + } as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS + }) + await dispatcher.dispatchStreaming( + { id: 'request-big', authToken: 'token', method: 'agentSessionAttachment.read', params: { path } }, + (raw) => replies.push(raw), + { ...CLIENT_A, clientKind: 'runtime' } + ) + expect(replies).toHaveLength(1) + expect(isMobileE2EETextPayloadWithinLimit(replies[0] ?? '')).toBe(true) + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('file_too_large') } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts index 68295c287c18..655869ebb2ff 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -16,6 +16,7 @@ import { AttachmentUploadIdParams, AttachmentUploadStartParams } from '../../../../shared/rpc-contract/agent-session-attachment-params' +import { remoteFileContentBudget } from './files-remote-content-budget' import { getAgentSessionAttachmentStore, type AgentSessionAttachmentStore @@ -70,7 +71,8 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ params: AttachmentReadParams, handler: async (params, ctx) => { const { store } = await requireStore(ctx) - return store.readPreview(params.path) + // A remote reply must fit the connection's outbound budget, or the server closes the socket. + return store.readPreview(params.path, remoteFileContentBudget(ctx.clientKind, ctx.requestId)) } }) ] diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts index 1ef6f309495e..226d34ad3d6a 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -1,8 +1,6 @@ // Installs the chat attachment store beside the structured host, and its sweeps. The store lives in -// the same state directory as the journal, and the sweeps read the host's records and journal. +// the same state directory as the journal, whose database holds the claims the sweeps honor. -import { join } from 'node:path' -import { AGENT_SESSION_ATTACHMENTS_DIR_NAME } from '../../shared/agent-session-attachments' import { AgentSessionAttachmentStore, setAgentSessionAttachmentStore @@ -11,7 +9,7 @@ import { startAgentSessionAttachmentSweeps, type AgentSessionAttachmentSweeper } from '../native-chat/agent-session-attachments/agent-session-attachment-sweep' -import { createAgentSessionAttachmentJournalMentions } from '../native-chat/agent-session-attachments/agent-session-attachment-journal-references' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -29,16 +27,22 @@ export function installAgentSessionAttachments(deps: { }): void { stopAgentSessionAttachments() const attachments = new AgentSessionAttachmentStore( - join(deps.stateDirectory, AGENT_SESSION_ATTACHMENTS_DIR_NAME) + agentSessionAttachmentStoreRoot(deps.stateDirectory), + { hasSession: (sessionId) => deps.store.getRecord(sessionId) !== null } ) setAgentSessionAttachmentStore(attachments) sweeper = startAgentSessionAttachmentSweeps( attachments, { - // Records still owed their import are missing from the list, so nothing reads as abandoned. + database: () => + deps.journalDatabase.readOnly || deps.journalDatabase.isClosed + ? null + : deps.journalDatabase.db, + // Records still owed their import are missing from the list, so no claim reads as orphaned. recordedSessionIds: () => - deps.journalDatabase.legacyRecordImportOwed ? null : deps.store.listRecordedSessionIds(), - journalMentions: createAgentSessionAttachmentJournalMentions(() => deps.journalDatabase.db) + deps.journalDatabase.legacyRecordImportOwed + ? null + : new Set(deps.store.listRecordedSessionIds()) }, { initialDelayMs: FIRST_SWEEP_DELAY_MS, diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 462c63182b3f..cae7b0abb0ee 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -64,6 +64,10 @@ const SENTENCES: Record string> = { unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => translate('components.native-chat.writeNotice.recordFailed', COPY.recordFailed), + attachmentExpired: () => + translate('components.native-chat.writeNotice.attachmentExpired', COPY.attachmentExpired), + reattachFile: () => + translate('components.native-chat.writeNotice.reattachFile', COPY.reattachFile), conversationCleared: () => translate('components.native-chat.writeNotice.conversationCleared', COPY.conversationCleared), openCurrentConversation: () => diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f4d415a46a2f..a792d393b563 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17671,6 +17671,8 @@ "notAvailable": "This isn't available in this chat.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't save this to the chat's history.", + "attachmentExpired": "This attachment expired.", + "reattachFile": "Remove it and attach it again.", "conversationCleared": "This conversation has been cleared.", "openCurrentConversation": "Open the current conversation to continue.", "clearUnfinished": "The last /clear didn't finish.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 58d33c0e15b3..696f615f64b3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15041,6 +15041,8 @@ "notAvailable": "Esto no está disponible en este chat.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo guardar esto en el historial del chat.", + "attachmentExpired": "Este archivo adjunto caducó.", + "reattachFile": "Quítelo y vuelva a adjuntarlo.", "conversationCleared": "Esta conversación se borró.", "openCurrentConversation": "Abra la conversación actual para continuar.", "clearUnfinished": "El último /clear no terminó.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index b4def13357e5..641957edcbee 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17510,6 +17510,8 @@ "notAvailable": "Ceci n'est pas disponible dans ce chat.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu enregistrer ceci dans l'historique du chat.", + "attachmentExpired": "Cette pièce jointe a expiré.", + "reattachFile": "Retirez-la et joignez-la à nouveau.", "conversationCleared": "Cette conversation a été effacée.", "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", "clearUnfinished": "Le dernier /clear ne s'est pas terminé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 29f206ddf3d6..686a2ad8fb3d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17447,6 +17447,8 @@ "notAvailable": "このチャットでは利用できません。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこれをチャットの履歴に保存できませんでした。", + "attachmentExpired": "この添付ファイルは期限切れです。", + "reattachFile": "削除してから、もう一度添付してください。", "conversationCleared": "この会話はクリアされました。", "openCurrentConversation": "続けるには、現在の会話を開いてください。", "clearUnfinished": "前回の /clear は完了しませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0eeac6cbe481..c4162294283b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17447,6 +17447,8 @@ "notAvailable": "이 채팅에서는 사용할 수 없습니다.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 내용을 채팅 기록에 저장하지 못했습니다.", + "attachmentExpired": "이 첨부 파일이 만료되었습니다.", + "reattachFile": "제거한 후 다시 첨부하세요.", "conversationCleared": "이 대화는 지워졌습니다.", "openCurrentConversation": "계속하려면 현재 대화를 여세요.", "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d3cf58a3deea..eb819646230c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17464,6 +17464,8 @@ "notAvailable": "此聊天中无法使用此功能。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将此内容保存到聊天历史记录中。", + "attachmentExpired": "此附件已过期。", + "reattachFile": "请将其移除后重新附加。", "conversationCleared": "此对话已被清除。", "openCurrentConversation": "请打开当前对话以继续。", "clearUnfinished": "上一次 /clear 未完成。", diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index f28cb837f5ca..471e45ea0421 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -15,17 +15,7 @@ export const AGENT_SESSION_ATTACHMENT_CHUNK_BYTES = 384 * 1024 export const AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS = (AGENT_SESSION_ATTACHMENT_CHUNK_BYTES / 3) * 4 -/** Previews are images read whole into one reply, so they stay well under the frame limit. */ -export const AGENT_SESSION_ATTACHMENT_PREVIEW_MAX_BYTES = 10 * 1024 * 1024 - -export const AGENT_SESSION_ATTACHMENT_NAME_MAX_LENGTH = 200 - -export type AgentSessionAttachmentUploadStartResult = { uploadId: string } -export type AgentSessionAttachmentUploadCommitResult = { - path: string - name: string - byteLength: number -} +export const AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES = 200 /** Whether `filePath` names a file in some server's attachment store. Only routes a preview read; * the server re-checks the real path before reading anything. */ @@ -33,24 +23,57 @@ export function isAgentSessionAttachmentStorePath(filePath: string): boolean { return filePath.split(/[\\/]/).includes(AGENT_SESSION_ATTACHMENTS_DIR_NAME) } +// Windows refuses these as a file's name whatever its extension, and drops trailing dots and spaces. +const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i + +function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).byteLength +} + +/** Whole characters from the start of `text` that fit in `maxBytes` of UTF-8. */ +function truncateUtf8(text: string, maxBytes: number): string { + let kept = '' + let bytes = 0 + for (const character of text) { + bytes += utf8ByteLength(character) + if (bytes > maxBytes) { + break + } + kept += character + } + return kept +} + /** * A stored file keeps the user's file name, since agents read meaning into names and extensions. - * Only the last path segment survives, without control characters or separators. + * Only the last path segment survives, without control characters or separators, short enough in + * bytes for every server's file system and never a name Windows reserves. */ export function sanitizeAgentSessionAttachmentName(name: string): string { const leaf = name.split(/[\\/]/).findLast((part) => part.length > 0) ?? '' // eslint-disable-next-line no-control-regex -- stripping control characters is the point const cleaned = leaf.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_').trim() - const visible = cleaned.replace(/^\.+/, '') + let visible = cleaned.replace(/^\.+/, '').replace(/[. ]+$/, '') if (visible.length === 0) { return 'attachment' } - if (visible.length <= AGENT_SESSION_ATTACHMENT_NAME_MAX_LENGTH) { + const firstDot = visible.indexOf('.') + if (WINDOWS_DEVICE_NAME.test(firstDot > 0 ? visible.slice(0, firstDot) : visible)) { + visible = `_${visible}` + } + if (utf8ByteLength(visible) <= AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES) { return visible } - const dot = visible.lastIndexOf('.') - const extension = dot > 0 && visible.length - dot <= 16 ? visible.slice(dot) : '' - return visible.slice(0, AGENT_SESSION_ATTACHMENT_NAME_MAX_LENGTH - extension.length) + extension + const extensionAt = visible.lastIndexOf('.') + const extension = + extensionAt > 0 && utf8ByteLength(visible.slice(extensionAt)) <= 16 + ? visible.slice(extensionAt) + : '' + const stem = visible.slice(0, visible.length - extension.length) + return ( + truncateUtf8(stem, AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - utf8ByteLength(extension)) + + extension + ) } /** Pins an upload to the server the attachment was meant for: every call re-checks the pairing, diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 70c6121d03f5..0fc387a291f3 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -22,6 +22,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'messageIdReused', 'operationRefusedEarlier', 'journalWriteFailed', + /** The message names a chat attachment the host no longer stores. */ + 'attachmentExpired', // The conversation's state 'conversationCleared', /** Older hosts only: a /clear that never committed; its replacement may not exist. */ diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 93a1409de030..287f64ccb818 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -113,6 +113,7 @@ const REASON_WORDS = { // Settled under that id, so the control's retry goes out under a new one. operationRefusedEarlier: codeWords('retry'), journalWriteFailed: causeWords('recordFailed', 'retry'), + attachmentExpired: causeWords('attachmentExpired', 'actFirst', 'reattachFile'), conversationCleared: causeWords( 'conversationCleared', 'goElsewhere', diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 08fd7a90a60b..4dbf3959e7d5 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -41,6 +41,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { notAvailable: "This isn't available in this chat.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't save this to the chat's history.", + attachmentExpired: 'This attachment expired.', + reattachFile: 'Remove it and attach it again.', conversationCleared: 'This conversation has been cleared.', openCurrentConversation: 'Open the current conversation to continue.', clearUnfinished: "The last /clear didn't finish.", From d10dfafeeaf58d138c70f80701781b6055810331 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:46:22 -0700 Subject: [PATCH 07/40] feat(native-chat): let structured Claude read the host's chat attachment store Attached non-image files live outside the workspace; the store root is passed as an added directory so the agent reads them without asking, the common pattern. --- src/main/claude/claude-structured-launch-resolution.ts | 5 +++++ src/main/runtime/structured-agent-session-runtime.ts | 2 ++ src/main/runtime/structured-claude-runtime-adapter.ts | 5 ++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 29703dfd8eb7..609a3e2655cc 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -47,6 +47,7 @@ export type ClaudeStructuredSdkOptions = Pick< | 'allowDangerouslySkipPermissions' | 'sessionId' | 'resume' + | 'additionalDirectories' > /** @@ -128,6 +129,9 @@ export type ClaudeStructuredLaunchResolverDeps = { authSwitchSettleTimeoutMs?: number /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** The host's chat attachment store: files a client attached live there, outside the workspace, + * and the agent reads them without asking. */ + attachmentDirectory?: string /** Whether Claude wrote a transcript for this id; defaults to the transcript resolver. */ hasTranscript?: (input: { providerSessionId: string @@ -301,6 +305,7 @@ export function createClaudeStructuredLaunchResolver( ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, + ...(deps.attachmentDirectory ? { additionalDirectories: [deps.attachmentDirectory] } : {}), // Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping. ...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId }) }, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 01b09f976915..8aaec494266f 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -42,6 +42,7 @@ import { installAgentSessionAttachments, stopAgentSessionAttachments } from './structured-agent-session-attachment-wiring' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' @@ -297,6 +298,7 @@ async function installOnJournal( readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) } : {}), + attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: (event) => lifecycle.deliver(event), onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503d..c9083c8358d1 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -31,6 +31,8 @@ export type StructuredClaudeRuntimeAdapterDeps = { /** The user's Agent Permissions setting for Claude; absent means prompting. */ resolveClaudePermissionMode?: () => Promise | PermissionMode readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** Where the host stores chat attachments; granted to the agent as a readable directory. */ + attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] @@ -91,7 +93,8 @@ export function createStructuredClaudeRuntimeAdapter( : {}), ...(deps.readClaudeManagedAccountGate ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } - : {}) + : {}), + ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence From 467404cb50666d153f33918ff38c5123c4ddddc7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:46:22 -0700 Subject: [PATCH 08/40] fix(native-chat): skip a dropped folder before staging walks into it --- .../ipc/agent-session-attachment-upload.test.ts | 13 ++++++++----- src/main/ipc/agent-session-attachment-upload.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/agent-session-attachment-upload.test.ts b/src/main/ipc/agent-session-attachment-upload.test.ts index fb505253b12f..6101167d9c19 100644 --- a/src/main/ipc/agent-session-attachment-upload.test.ts +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -77,7 +77,8 @@ const context = () => ({ beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), 'orca-attachment-upload-')) serverStore = new AgentSessionAttachmentStore( - join(workDir, 'server', 'agent-session-attachments') + join(workDir, 'server', 'agent-session-attachments'), + { hasSession: () => true } ) calls.length = 0 failMethod = null @@ -106,7 +107,7 @@ describe('uploadExternalPathsToAgentSessionAttachments', () => { join(workDir, 'notes.md') ]) for (const { path } of result.uploaded) { - expect(path.startsWith(serverStore.sessionDirectory('session-1'))).toBe(true) + expect(path.startsWith(serverStore.rootDir)).toBe(true) } expect(await readFile(result.uploaded[0].path)).toEqual(big) expect(await readFile(result.uploaded[1].path, 'utf8')).toBe('# hi') @@ -126,9 +127,11 @@ describe('uploadExternalPathsToAgentSessionAttachments', () => { } }) - it('skips a folder as unsupported instead of uploading its contents', async () => { - await mkdir(join(workDir, 'folder')) + it('skips a folder as unsupported without walking into it', async () => { + await mkdir(join(workDir, 'folder', 'nested'), { recursive: true }) await writeFile(join(workDir, 'folder', 'inside.txt'), 'x') + // A link inside would make the stager skip the folder as a symlink: never reached. + await symlink(join(workDir, 'folder', 'inside.txt'), join(workDir, 'folder', 'nested', 'link')) const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ join(workDir, 'folder') ]) diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts index 17fb42734c37..54e5b7e9d64c 100644 --- a/src/main/ipc/agent-session-attachment-upload.ts +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -2,6 +2,7 @@ // the runtime socket, so it pumps the bytes into the server's attachment store and hands the // renderer only the server path. +import { lstat } from 'node:fs/promises' import { basename } from 'node:path' import { AGENT_SESSION_ATTACHMENT_CHUNK_BYTES, @@ -22,6 +23,15 @@ import { formatByteCeiling } from './runtime-import-limits' const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 +async function isDirectory(path: string): Promise { + try { + return (await lstat(path)).isDirectory() + } catch { + // The stager reports a path it cannot read with its own reason. + return false + } +} + type UploadContext = AgentSessionAttachmentUploadTarget & { userDataPath: string signal?: AbortSignal @@ -123,6 +133,11 @@ export async function uploadExternalPathsToAgentSessionAttachments( const result: AgentSessionAttachmentPathUploadResult = { uploaded: [], skipped: [], failed: [] } for (const sourcePath of paths) { context.signal?.throwIfAborted() + // Before staging, which would walk and open everything inside a folder only to skip it. + if (await isDirectory(sourcePath)) { + result.skipped.push({ sourcePath, reason: 'unsupported' }) + continue + } const staged = await stageOneSourceForRuntimeUpload(sourcePath) if (staged.status === 'skipped') { result.skipped.push({ sourcePath, reason: staged.reason }) From ac570f01f9af88c79bf665651a1e97efdd81a3c7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:56:30 -0700 Subject: [PATCH 09/40] fix(native-chat): pin the browser client's chat paste to its server Every upload call goes to the environment the paste was meant for and checks its pairing and server process, as the desktop upload does; a re-pair ends the upload instead of storing the image on another server. --- .../native-chat-attachment-destination.ts | 99 ------------------- .../native-chat-resolved-path-ownership.ts | 22 +---- ...ession-withdrawn-attachment-owner.test.tsx | 83 ---------------- ...agent-session-withdrawn-message-restore.ts | 35 ++----- ...native-chat-attachment-send-guard.test.tsx | 96 ------------------ .../use-native-chat-attachment-send-guard.ts | 66 ------------- .../use-native-chat-composer-submit.test.tsx | 3 - .../use-native-chat-composer-submit.ts | 16 +-- ...e-native-chat-resolved-path-attachments.ts | 39 ++------ .../use-structured-agent-session-outbox.ts | 6 +- ...eb-agent-session-attachment-upload.test.ts | 79 +++++++++++++++ .../web-agent-session-attachment-upload.ts | 81 ++++++++++----- .../src/web/preload-api/web-ui-api.ts | 11 ++- 13 files changed, 160 insertions(+), 476 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/native-chat-attachment-destination.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-destination.ts b/src/renderer/src/components/native-chat/native-chat-attachment-destination.ts deleted file mode 100644 index 9f816091b976..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-attachment-destination.ts +++ /dev/null @@ -1,99 +0,0 @@ -// What a send checks about attachments uploaded into a paired server's store: each one was stored -// for one server and one chat, so a composer whose chat now runs elsewhere must not send it. - -import type { NativeChatAttachmentHostOwner } from './native-chat-attachment-upload' -import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { nativeChatAttachmentHostOwnerMatches } from './native-chat-resolved-path-ownership' -import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' - -/** An `@path` a stored non-image upload put into the draft. */ -export type NativeChatHostOwnedReference = { - reference: string - hostOwner: NativeChatAttachmentHostOwner -} - -/** Where a send goes: the chat's server, pairing and id, or null for a chat on this machine. */ -export type NativeChatAttachmentDestination = { - environmentId: string - /** Undefined once the server is no longer paired: nothing stored for it can match. */ - pairingRevision: number | undefined - sessionId: string -} | null - -// Per composer scope, like the draft the references sit in, so they survive the same remounts. -const referenceCache = new Map() - -export function recordNativeChatHostOwnedReferences( - scopeKey: string, - references: readonly NativeChatHostOwnedReference[] -): void { - if (references.length === 0) { - return - } - setBoundedScopeCacheEntry(referenceCache, scopeKey, [ - ...(referenceCache.get(scopeKey) ?? []), - ...references - ]) -} - -export function readNativeChatHostOwnedReferences( - scopeKey: string -): readonly NativeChatHostOwnedReference[] { - return referenceCache.get(scopeKey) ?? [] -} - -export function clearNativeChatHostOwnedReferences(scopeKey: string): void { - referenceCache.delete(scopeKey) -} - -function ownerFitsDestination( - hostOwner: NativeChatAttachmentHostOwner, - destination: NativeChatAttachmentDestination -): boolean { - return ( - destination !== null && - destination.pairingRevision !== undefined && - nativeChatAttachmentHostOwnerMatches(hostOwner, { - environmentId: destination.environmentId, - pairingRevision: destination.pairingRevision, - sessionId: destination.sessionId - }) - ) -} - -/** Stored attachments a send to `destination` must not carry. Attachments with no store owner - * (this machine's files, SSH uploads, workspace files) keep the checks they already have. */ -export function nativeChatAttachmentsForeignToDestination(args: { - chips: readonly NativeChatComposerImageAttachment[] - references: readonly NativeChatHostOwnedReference[] - draft: string - destination: NativeChatAttachmentDestination -}): { chipIds: string[]; references: string[] } { - return { - chipIds: args.chips - .filter((chip) => chip.hostOwner && !ownerFitsDestination(chip.hostOwner, args.destination)) - .map((chip) => chip.id), - references: args.references - .filter( - ({ reference, hostOwner }) => - args.draft.includes(reference) && !ownerFitsDestination(hostOwner, args.destination) - ) - .map(({ reference }) => reference) - } -} - -/** The draft without these references, including the space inserted after each. */ -export function stripNativeChatFileReferences( - draft: string, - references: readonly string[] -): string { - let next = draft - for (const reference of references) { - next = next.split(`${reference} `).join('').split(reference).join('') - } - return next -} - -export function clearNativeChatHostOwnedReferencesForTests(): void { - referenceCache.clear() -} diff --git a/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts b/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts index 4654f512b63f..86290338167d 100644 --- a/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts +++ b/src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts @@ -1,26 +1,9 @@ import { translate } from '@/i18n/i18n' -import type { - NativeChatAttachmentHostOwner, - NativeChatAttachmentOwner -} from './native-chat-attachment-upload' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' export type NativeChatResolvedPathOptions = { /** Revalidates internal path ownership when an IME-delayed attachment is applied. */ targetOwnerIsCurrent?: () => boolean - /** The paired server store these paths were uploaded into; checked again at send. */ - hostOwner?: NativeChatAttachmentHostOwner -} - -/** Same server, same pairing, same chat: a reconnect or server restart keeps all three. */ -export function nativeChatAttachmentHostOwnerMatches( - a: NativeChatAttachmentHostOwner, - b: NativeChatAttachmentHostOwner -): boolean { - return ( - a.environmentId === b.environmentId && - a.pairingRevision === b.pairingRevision && - a.sessionId === b.sessionId - ) } export function nativeChatWorkspaceAttachmentMismatchNotice(): string { @@ -39,9 +22,6 @@ export function nativeChatAttachmentOwnerUnchanged( if (captured.kind !== current.kind || captured.kind === 'not-ready') { return false } - if (captured.kind === 'runtime-session' && current.kind === 'runtime-session') { - return nativeChatAttachmentHostOwnerMatches(captured, current) - } if (captured.kind !== 'ssh' || current.kind !== 'ssh') { return true } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx deleted file mode 100644 index bb3a5e3dee77..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-attachment-owner.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -// A withdrawn message's images go back into the composer. On a paired server they are files in that -// server's attachment store, so the chips must keep that owner: their previews read from the server -// and a later send is checked against it, never against this machine's disk. - -// @vitest-environment happy-dom - -import { renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' -import { writeOutbox } from './structured-agent-session-outbox-storage' -import { useStructuredAgentSessionWithdrawnRestore } from './structured-agent-session-withdrawn-message-restore' -import { - clearNativeChatAttachmentCacheForTests, - readNativeChatAttachmentCache -} from './use-native-chat-composer-attachments' - -const SESSION = 'session-1' -const PANE = 'tab-1::session-1' -const STORED = '/srv/orca/agent-session-attachments/abc/u1/shot.png' -const WORKSPACE_IMAGE = '/remote/repo/diagram.png' - -function withdrawn(): StructuredAgentSessionOutboxEntry { - return { - clientMessageId: 'message-1', - sessionId: SESSION, - body: { - kind: 'message', - role: 'user', - blocks: [ - { type: 'image-ref', path: STORED }, - { type: 'image-ref', path: WORKSPACE_IMAGE }, - { type: 'text', text: 'look' } - ] - }, - previewUris: [], - state: 'queued', - queuedAt: 1, - lastAttemptAt: null, - retryAfterUnknownSubmittedAt: null - } -} - -beforeEach(() => { - replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) - writeOutbox(SESSION, [withdrawn()]) -}) - -afterEach(() => { - writeOutbox(SESSION, []) - clearNativeChatAttachmentCacheForTests() -}) - -describe('withdrawn images from a chat on a paired server', () => { - it('come back owned by the server store that holds them', () => { - const { result } = renderHook(() => - useStructuredAgentSessionWithdrawnRestore( - SESSION, - { kind: 'environment', environmentId: 'env-1' }, - PANE - ) - ) - result.current.byStop([withdrawn()]) - - const chips = readNativeChatAttachmentCache(PANE) - expect(chips.find((chip) => chip.path === STORED)?.hostOwner).toEqual({ - environmentId: 'env-1', - pairingRevision: 7, - sessionId: SESSION - }) - // A workspace file keeps the checks it already had. - expect(chips.find((chip) => chip.path === WORKSPACE_IMAGE)?.hostOwner).toBeUndefined() - }) - - it('come back unowned from a chat on this machine', () => { - const { result } = renderHook(() => - useStructuredAgentSessionWithdrawnRestore(SESSION, { kind: 'local' }, PANE) - ) - result.current.byStop([withdrawn()]) - - expect(readNativeChatAttachmentCache(PANE).every((chip) => !chip.hostOwner)).toBe(true) - }) -}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 61dd07acbf25..40e69565ef07 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -5,9 +5,6 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' -import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' -import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' /** * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, @@ -17,7 +14,6 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' */ function restoreWithdrawnMessages( sessionId: string, - target: RuntimeClientTarget, composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] ): void { @@ -39,33 +35,17 @@ function restoreWithdrawnMessages( ) appendNativeChatAttachmentCache( composerScopeKey, - blocks.flatMap((block, index) => { - if (block.type !== 'image-ref' || !block.path) { - return [] - } - const id = `withdrawn-${entry.clientMessageId}-${index}` - if (target.kind === 'local' || !isAgentSessionAttachmentStorePath(block.path)) { - return [{ id, path: block.path }] - } - // Stored on the paired server: the chip keeps its owner, so it is read and sent there only. - const pairingRevision = getRuntimeEnvironmentRevision(target.environmentId) - return pairingRevision === undefined - ? [] - : [ - { - id, - path: block.path, - hostOwner: { environmentId: target.environmentId, pairingRevision, sessionId } - } - ] - }) + blocks.flatMap((block, index) => + block.type === 'image-ref' && block.path + ? [{ id: `withdrawn-${entry.clientMessageId}-${index}`, path: block.path }] + : [] + ) ) } } export function useStructuredAgentSessionWithdrawnRestore( sessionId: string, - target: RuntimeClientTarget, /** Absent where no composer shows this session; the entries are then only dropped. */ composerScopeKey: string | undefined ): { @@ -92,13 +72,12 @@ export function useStructuredAgentSessionWithdrawnRestore( ) restoreWithdrawnMessages( sessionId, - target, composerScopeKey, entries.filter((entry) => withdrawn.has(entry.clientMessageId)) ) }, - byStop: (entries) => restoreWithdrawnMessages(sessionId, target, composerScopeKey, entries) + byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) }), - [composerScopeKey, sessionId, target] + [composerScopeKey, sessionId] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx deleted file mode 100644 index 0bfa3f4456b4..000000000000 --- a/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @vitest-environment happy-dom -import { renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' -import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' -import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { - clearNativeChatHostOwnedReferencesForTests, - recordNativeChatHostOwnedReferences -} from './native-chat-attachment-destination' -import { useNativeChatAttachmentSendGuard } from './use-native-chat-attachment-send-guard' - -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback -})) - -const SCOPE = 'pane-1' -const OWNER = { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } -const STORED_IMAGE = '/srv/agent-session-attachments/s/u1/shot.png' -const STORED_FILE_REF = '@/srv/agent-session-attachments/s/u2/notes.md' - -function transport(runtimeEnvironmentId: string | null): NativeChatStructuredComposerTransport { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guard reads only sessionId and runtimeEnvironmentId. - return { sessionId: 'session-1', runtimeEnvironmentId } as NativeChatStructuredComposerTransport -} - -const storedChip: NativeChatComposerImageAttachment = { - id: 'chip-1', - path: STORED_IMAGE, - hostOwner: OWNER -} -const workspaceChip: NativeChatComposerImageAttachment = { id: 'chip-2', path: '/wt/a.png' } - -function harness(args: { runtimeEnvironmentId: string | null; draft: string }) { - const calls = { - sendStructured: vi.fn(), - setDraft: vi.fn(), - removeImageAttachment: vi.fn(), - setNotice: vi.fn() - } - const { result } = renderHook(() => - useNativeChatAttachmentSendGuard({ - scopeKey: SCOPE, - structuredTransport: transport(args.runtimeEnvironmentId), - draft: args.draft, - ...calls - }) - ) - return { send: result.current, calls } -} - -beforeEach(() => { - replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) - recordNativeChatHostOwnedReferences(SCOPE, [{ reference: STORED_FILE_REF, hostOwner: OWNER }]) -}) - -afterEach(() => { - clearNativeChatHostOwnedReferencesForTests() -}) - -describe('useNativeChatAttachmentSendGuard', () => { - it('sends attachments stored for this chat on this server', () => { - const draft = `see ${STORED_FILE_REF} ` - const { send, calls } = harness({ runtimeEnvironmentId: 'env-1', draft }) - send(draft, [storedChip, workspaceChip]) - expect(calls.sendStructured).toHaveBeenCalledExactlyOnceWith(draft, [storedChip, workspaceChip]) - expect(calls.setNotice).not.toHaveBeenCalled() - }) - - it('drops stored attachments and holds the send after the server was re-paired', () => { - replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 8 }]) - const draft = `see ${STORED_FILE_REF} please` - const { send, calls } = harness({ runtimeEnvironmentId: 'env-1', draft }) - send(draft, [storedChip, workspaceChip]) - expect(calls.sendStructured).not.toHaveBeenCalled() - expect(calls.removeImageAttachment).toHaveBeenCalledExactlyOnceWith('chip-1') - expect(calls.setDraft).toHaveBeenCalledExactlyOnceWith('see please') - expect(calls.setNotice).toHaveBeenCalledWith( - 'This workspace changed hosts while attaching — drop the files again.' - ) - }) - - it('never sends a stored attachment to a chat on this machine', () => { - const { send, calls } = harness({ runtimeEnvironmentId: null, draft: 'x' }) - send('x', [storedChip]) - expect(calls.sendStructured).not.toHaveBeenCalled() - expect(calls.removeImageAttachment).toHaveBeenCalledExactlyOnceWith('chip-1') - }) - - it('ignores a stored reference the user already deleted from the draft', () => { - replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 8 }]) - const { send, calls } = harness({ runtimeEnvironmentId: 'env-1', draft: 'plain text' }) - send('plain text', [workspaceChip]) - expect(calls.sendStructured).toHaveBeenCalledExactlyOnceWith('plain text', [workspaceChip]) - }) -}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts b/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts deleted file mode 100644 index 7037f3d0e8d7..000000000000 --- a/src/renderer/src/components/native-chat/use-native-chat-attachment-send-guard.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback } from 'react' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' -import { nativeChatAttachmentOwnerChangedNotice } from './native-chat-attachment-upload' -import { - nativeChatAttachmentsForeignToDestination, - readNativeChatHostOwnedReferences, - stripNativeChatFileReferences -} from './native-chat-attachment-destination' -import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' -import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' - -/** - * Right before a structured send, every attachment stored on a paired server is checked against - * where this chat runs now. One stored for another server, pairing or chat is dropped, and the - * message waits for the user to attach the files again; a reconnect to the same server keeps them. - */ -export function useNativeChatAttachmentSendGuard(args: { - scopeKey: string - structuredTransport?: NativeChatStructuredComposerTransport - draft: string - setDraft: (value: string) => void - removeImageAttachment: (id: string) => void - setNotice: (notice: string | null) => void - sendStructured: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void -}): (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void { - const { scopeKey, structuredTransport, draft, setDraft, removeImageAttachment, setNotice } = args - const { sendStructured } = args - return useCallback( - (text, attachments) => { - const environmentId = structuredTransport?.runtimeEnvironmentId ?? null - const foreign = nativeChatAttachmentsForeignToDestination({ - chips: attachments, - references: readNativeChatHostOwnedReferences(scopeKey), - draft, - destination: - environmentId && structuredTransport - ? { - environmentId, - pairingRevision: getRuntimeEnvironmentRevision(environmentId), - sessionId: structuredTransport.sessionId - } - : null - }) - if (foreign.chipIds.length === 0 && foreign.references.length === 0) { - sendStructured(text, attachments) - return - } - for (const chipId of foreign.chipIds) { - removeImageAttachment(chipId) - } - if (foreign.references.length > 0) { - setDraft(stripNativeChatFileReferences(draft, foreign.references)) - } - setNotice(nativeChatAttachmentOwnerChangedNotice()) - }, - [ - draft, - removeImageAttachment, - scopeKey, - sendStructured, - setDraft, - setNotice, - structuredTransport - ] - ) -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx index a6c472a8e31d..bbbf99d2a5d2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -37,8 +37,6 @@ function harness(options: { const calls = { sendPty: vi.fn(), sendStructured: vi.fn(), - removeImageAttachment: vi.fn(), - setNotice: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn(), setHistory: vi.fn() @@ -51,7 +49,6 @@ function harness(options: { caret: props.caret, imageAttachments: options.imageAttachments ?? [], disabled: false, - attachmentScopeKey: 'pane-1', ...calls }), { initialProps: { draft: options.draft, caret: options.caret ?? options.draft.length } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts index 36f257d2786d..7bf8ea7f8705 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -4,7 +4,6 @@ import { applyPickerSuggestion, type NativeChatPickerItem } from './native-chat- import { pushHistory, type HistoryState } from './native-chat-composer-state' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { useNativeChatAttachmentSendGuard } from './use-native-chat-attachment-send-guard' import { isBareStructuredAgentSessionGoalCommand, structuredAgentSessionGoalObjective @@ -35,25 +34,12 @@ export function useNativeChatComposerSubmit(args: { disabled: boolean sendPty: () => void sendStructured: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => void - /** Scope of the composer's attachments, for the host check right before a structured send. */ - attachmentScopeKey: string - removeImageAttachment: (id: string) => void - setNotice: (notice: string | null) => void setDraft: (value: string) => void setCaret: (caret: number) => void setHistory: (updater: (previous: HistoryState) => HistoryState) => void }): { send: () => void; goalMode: NativeChatComposerGoalMode } { - const { caret, disabled, draft, imageAttachments, sendPty } = args + const { caret, disabled, draft, imageAttachments, sendPty, sendStructured } = args const { setCaret, setDraft, setHistory, structuredTransport } = args - const sendStructured = useNativeChatAttachmentSendGuard({ - scopeKey: args.attachmentScopeKey, - structuredTransport, - draft, - setDraft, - removeImageAttachment: args.removeImageAttachment, - setNotice: args.setNotice, - sendStructured: args.sendStructured - }) const threadGoal = structuredTransport?.threadGoal const [entered, setEntered] = useState(false) const active = entered && threadGoal !== undefined diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 2c7ca942676a..7bc6a042abcd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -8,26 +8,15 @@ import { nativeChatWorkspaceAttachmentMismatchNotice, type NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' -import type { NativeChatAttachmentHostOwner } from './native-chat-attachment-upload' -import type { NativeChatHostOwnedReference } from './native-chat-attachment-destination' type ResolvedAttachmentPath = { path: string connectionId?: string | null targetOwnerIsCurrent?: () => boolean - hostOwner?: NativeChatAttachmentHostOwner } type Args = { - appendImageAttachments: ( - paths: { - path: string - connectionId?: string | null - hostOwner?: NativeChatAttachmentHostOwner - }[] - ) => void - /** Records `@path` references a paired server's store owns, so a send can recheck them. */ - noteHostOwnedReferences: (references: NativeChatHostOwnedReference[]) => void + appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void attachmentTargetBlocked: (targetOwned?: boolean) => boolean caret: number disabled: boolean @@ -46,7 +35,6 @@ export function useNativeChatResolvedPathAttachments({ disabled, isComposing, noteAttachmentTargetBlocked, - noteHostOwnedReferences, setCaret, setDraft, setNotice, @@ -73,13 +61,8 @@ export function useNativeChatResolvedPathAttachments({ }, [disabled]) const insertFileReferences = useCallback( - (paths: ResolvedAttachmentPath[]) => { - noteHostOwnedReferences( - paths.flatMap(({ path, hostOwner }) => - hostOwner ? [{ reference: formatNativeChatFileReference(path), hostOwner }] : [] - ) - ) - const references = paths.map(({ path }) => formatNativeChatFileReference(path)).join(' ') + (paths: string[]) => { + const references = paths.map(formatNativeChatFileReference).join(' ') if (references.length === 0) { return } @@ -92,7 +75,7 @@ export function useNativeChatResolvedPathAttachments({ return before + insertion + after }) }, - [caret, noteHostOwnedReferences, setCaret, setDraft, textareaRef] + [caret, setCaret, setDraft, textareaRef] ) const applyResolvedPaths = useCallback( @@ -125,11 +108,11 @@ export function useNativeChatResolvedPathAttachments({ return } const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path)) - const filePaths = attachable.filter(({ path }) => !isNativeChatImageAttachmentPath(path)) + const filePaths = attachable + .filter(({ path }) => !isNativeChatImageAttachmentPath(path)) + .map(({ path }) => path) // Images ride along on submit so chips and the TUI input cannot diverge. - appendImageAttachments( - imagePaths.map(({ path, connectionId, hostOwner }) => ({ path, connectionId, hostOwner })) - ) + appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId }))) insertFileReferences(filePaths) if (ownedBlocked || clientLocalBlocked) { noteAttachmentTargetBlocked() @@ -184,8 +167,7 @@ export function useNativeChatResolvedPathAttachments({ ...paths.map((path) => ({ path, connectionId, - targetOwnerIsCurrent: options.targetOwnerIsCurrent, - hostOwner: options.hostOwner + targetOwnerIsCurrent: options.targetOwnerIsCurrent })) ) return @@ -194,8 +176,7 @@ export function useNativeChatResolvedPathAttachments({ paths.map((path) => ({ path, connectionId, - targetOwnerIsCurrent: options.targetOwnerIsCurrent, - hostOwner: options.hostOwner + targetOwnerIsCurrent: options.targetOwnerIsCurrent })), true ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 2f2cb29674c3..28bf17b8aa78 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -85,11 +85,7 @@ export function useStructuredAgentSessionOutbox(args: { const { capability: queueCapability, enabled: queueEnabled } = queueDelivery // What resends and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) - const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore( - sessionId, - target, - composerScopeKey - ) + const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) // The outbox lives in the session's store, shared with every other writer; this view holds it // open and drains it. Loading maps what a previous owner left mid-send. const load = useCallback( diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts new file mode 100644 index 000000000000..62a34ab39ab5 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + callEnvironmentEnvelope: vi.fn(), + activeEnvironment: { id: 'env-1', createdAt: 1, pairingRevision: 7 as number | undefined } +})) + +vi.mock('./web-runtime-calls', () => ({ callEnvironmentEnvelope: mocks.callEnvironmentEnvelope })) +vi.mock('./web-runtime-session', () => ({ + resolveEnvironment: (selector: string) => { + if (selector !== mocks.activeEnvironment.id) { + throw new Error(`Unknown Orca runtime environment: ${selector}`) + } + return mocks.activeEnvironment + } +})) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'uuid-1' })) + +import { saveClipboardImageAsWebAgentSessionAttachment } from './web-agent-session-attachment-upload' + +const target = { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' +} + +function reply(result: unknown, runtimeId = 'runtime-a') { + return { id: 'r', ok: true, result, _meta: { runtimeId } } +} + +beforeEach(() => { + mocks.callEnvironmentEnvelope.mockReset() + mocks.activeEnvironment = { id: 'env-1', createdAt: 1, pairingRevision: 7 } +}) + +describe('saveClipboardImageAsWebAgentSessionAttachment', () => { + it('uploads to the server the paste was meant for and returns its stored path', async () => { + mocks.callEnvironmentEnvelope.mockImplementation(async (_env: string, method: string) => + method === 'agentSessionAttachment.uploadStart' + ? reply({ uploadId: 'u1' }) + : method === 'agentSessionAttachment.uploadCommit' + ? reply({ path: '/srv/agent-session-attachments/u1/p.png', name: 'p.png', byteLength: 3 }) + : reply({ receivedBytes: 3 }) + ) + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), target) + ).resolves.toBe('/srv/agent-session-attachments/u1/p.png') + for (const call of mocks.callEnvironmentEnvelope.mock.calls) { + expect(call[0]).toBe('env-1') + } + }) + + it('stops when a different server process answers, storing nothing more', async () => { + mocks.callEnvironmentEnvelope.mockResolvedValue(reply({ uploadId: 'u1' }, 'runtime-b')) + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), target) + ).rejects.toThrow('paired Orca server changed') + expect(mocks.callEnvironmentEnvelope).toHaveBeenCalledTimes(1) + }) + + it('refuses before uploading once the server was re-paired', async () => { + mocks.activeEnvironment = { id: 'env-1', createdAt: 1, pairingRevision: 8 } + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), target) + ).rejects.toThrow('paired Orca server changed') + expect(mocks.callEnvironmentEnvelope).not.toHaveBeenCalled() + }) + + it('refuses when the paste was meant for a server this client no longer pairs with', async () => { + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), { + ...target, + environmentId: 'env-other' + }) + ).rejects.toThrow('Unknown Orca runtime environment') + expect(mocks.callEnvironmentEnvelope).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts index b671ad803b1e..b32c0bc63405 100644 --- a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts @@ -1,29 +1,63 @@ import { AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, agentSessionPastedImageName, - type AgentSessionAttachmentClipboardTarget, - type AgentSessionAttachmentUploadCommitResult + type AgentSessionAttachmentUploadCommitResult, + type AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' -import { callRuntimeResult } from './web-runtime-calls' +import { callEnvironmentEnvelope } from './web-runtime-calls' +import { resolveEnvironment } from './web-runtime-session' import { createBrowserUuid } from '@/lib/browser-uuid' const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 +const SERVER_CHANGED = 'The paired Orca server changed while the request was in progress.' + +/** Every call goes to the server the paste was meant for, as its desktop counterpart does: a + * re-pair or a replaced server process ends the upload instead of storing it elsewhere. */ +async function callPinned( + target: AgentSessionAttachmentUploadTarget, + method: string, + params: unknown +): Promise { + const environment = resolveEnvironment(target.environmentId) + if ( + target.expectedEnvironmentPairingRevision !== undefined && + (environment.pairingRevision ?? environment.createdAt) !== + target.expectedEnvironmentPairingRevision + ) { + throw new Error(SERVER_CHANGED) + } + const response = await callEnvironmentEnvelope( + environment.id, + method, + params, + ATTACHMENT_CALL_TIMEOUT_MS + ) + // A failure may come from this side and name no server process; a success always names one. + const runtimeId = response._meta?.runtimeId ?? null + if ((response.ok || runtimeId !== null) && runtimeId !== target.expectedEnvironmentRuntimeId) { + throw new Error(SERVER_CHANGED) + } + if (!response.ok) { + throw new Error(response.error.message || response.error.code) + } + return response.result +} /** The browser client's paste into a structured chat: the same store the desktop client uses. */ export async function saveClipboardImageAsWebAgentSessionAttachment( contentBase64: string, - target: AgentSessionAttachmentClipboardTarget + target: AgentSessionAttachmentUploadTarget ): Promise { const padding = contentBase64.endsWith('==') ? 2 : contentBase64.endsWith('=') ? 1 : 0 const byteLength = (contentBase64.length / 4) * 3 - padding - const { uploadId } = await callRuntimeResult<{ uploadId: string }>( + const { uploadId } = await callPinned<{ uploadId: string }>( + target, 'agentSessionAttachment.uploadStart', { sessionId: target.sessionId, name: agentSessionPastedImageName(Date.now(), createBrowserUuid()), byteLength - }, - ATTACHMENT_CALL_TIMEOUT_MS + } ) try { // Chunks are a multiple of 4 base64 characters, so each starts on a byte boundary. @@ -32,31 +66,24 @@ export async function saveClipboardImageAsWebAgentSessionAttachment( charOffset < Math.max(contentBase64.length, 1); charOffset += AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS ) { - await callRuntimeResult( - 'agentSessionAttachment.uploadAppend', - { - uploadId, - offset: (charOffset / 4) * 3, - contentBase64: contentBase64.slice( - charOffset, - charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS - ) - }, - ATTACHMENT_CALL_TIMEOUT_MS - ) + await callPinned(target, 'agentSessionAttachment.uploadAppend', { + uploadId, + offset: (charOffset / 4) * 3, + contentBase64: contentBase64.slice( + charOffset, + charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) + }) } - const stored = await callRuntimeResult( + const stored = await callPinned( + target, 'agentSessionAttachment.uploadCommit', - { uploadId }, - ATTACHMENT_CALL_TIMEOUT_MS + { uploadId } ) return stored.path } catch (error) { - await callRuntimeResult( - 'agentSessionAttachment.uploadAbort', - { uploadId }, - ATTACHMENT_CALL_TIMEOUT_MS - ).catch(() => {}) + // Only to the same server: an abort a re-pair refuses is left to that server's idle expiry. + await callPinned(target, 'agentSessionAttachment.uploadAbort', { uploadId }).catch(() => {}) throw error } } diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 8aad5ee70d55..22be8b88dcd4 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -161,10 +161,13 @@ export function createWebUiApi(): NonNullable['ui']> { return null } if (args?.agentSessionAttachment) { - return saveClipboardImageAsWebAgentSessionAttachment( - contentBase64, - args.agentSessionAttachment - ) + if (!args.runtimeEnvironmentId) { + throw new Error('A chat attachment needs the server the chat runs on.') + } + return saveClipboardImageAsWebAgentSessionAttachment(contentBase64, { + ...args.agentSessionAttachment, + environmentId: args.runtimeEnvironmentId + }) } return saveClipboardImageAsTempFileInRuntime(contentBase64, args) }, From 8deee88f7515b102b3c714d849a83c47f9799963 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:56:31 -0700 Subject: [PATCH 10/40] refactor(native-chat): let the host's claim be the only attachment check The composer no longer records which server and pairing each attachment came from, and Send no longer strips attachments it judges foreign: the host refuses an expired or unknown stored path when it admits the message, which also covers retries, Stop-restores and queued edits the client check missed. Previews of stored files route through the chat's own server by path. Removing a file's chip while it uploads now keeps its @path out of the draft. A dropped or picked file shows its name and kind on its chip while it uploads, with no separate progress toast, and files that did not attach are named in one notice. A rich-text paste into a chat on an older server no longer shows the update notice beside the pasted text. --- .../native-chat/NativeChatComposer.tsx | 4 +- .../native-chat/NativeChatComposerField.tsx | 10 +- .../NativeChatImageAttachmentPreview.test.tsx | 53 +++++-- .../NativeChatImageAttachmentPreview.tsx | 45 ++++-- .../native-chat-attachment-upload.ts | 64 +++------ .../native-chat-clipboard-image-save.ts | 16 ++- .../native-chat-session-attachment-drop.ts | 91 ++++++------ ...-native-chat-composer-attachments.test.tsx | 25 ++++ .../use-native-chat-composer-attachments.ts | 64 +++------ ...omposer-paste-session-attachments.test.tsx | 64 +++++---- .../use-native-chat-composer-paste.ts | 41 +++--- ...-native-chat-external-attachments.test.tsx | 132 ++++++++++++++---- src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + 18 files changed, 375 insertions(+), 246 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 94ee17dd205f..a7ec22fa4738 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -294,9 +294,6 @@ const NativeChatComposerPane = forwardRef ))} diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 00ed15caf90f..21e772c89f33 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -10,7 +10,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name])) })) vi.mock('@/components/editor/useLocalImageSrc', () => ({ @@ -23,9 +24,18 @@ afterEach(() => { mocks.useLocalImageSrc.mockReset() }) -function renderPreview(attachment: NativeChatComposerImageAttachment): void { +function renderPreview( + attachment: NativeChatComposerImageAttachment, + hostEnvironmentId?: string +): void { vi.stubGlobal('IntersectionObserver', undefined) - render() + render( + + ) } describe('NativeChatImageAttachmentPreview', () => { @@ -56,12 +66,8 @@ describe('NativeChatImageAttachmentPreview', () => { // The path names a file on the paired server; this machine's disk must never be asked for it. it('reads a stored chip back through the server that holds it', () => { mocks.useLocalImageSrc.mockReturnValue('blob:from-server') - const path = '/srv/agent-session-attachments/s/u1/shot.png' - renderPreview({ - id: 'a1', - path, - hostOwner: { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } - }) + const path = '/srv/agent-session-attachments/u1/shot.png' + renderPreview({ id: 'a1', path }, 'env-1') for (const call of mocks.useLocalImageSrc.mock.calls) { expect(call[3]).toEqual({ @@ -76,10 +82,33 @@ describe('NativeChatImageAttachmentPreview', () => { ) }) - it('labels a dropped file while it uploads', () => { + it('keeps any other path on the chat\'s usual read route', () => { mocks.useLocalImageSrc.mockReturnValue(undefined) - renderPreview({ id: 'a1', path: '', pending: true, pendingName: 'notes.md' }) + renderPreview({ id: 'a1', path: '/repo/docs/shot.png' }, 'env-1') - expect(screen.getByRole('button', { name: /^Uploading .* file\(s\) to remote…$/ })).toBeTruthy() + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith( + '/repo/docs/shot.png', + '/repo/docs/shot.png', + undefined, + undefined + ) + }) + + it('shows a dropped file by name and kind while it uploads', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '', pending: true, pendingName: 'report.pdf' }) + + expect(screen.getByRole('button', { name: 'Uploading report.pdf…' })).toBeTruthy() + expect(screen.getByText('report.pdf')).toBeTruthy() + expect(document.querySelector('.lucide-file-text')).toBeTruthy() + expect(document.querySelector('.lucide-image')).toBeFalsy() + }) + + it('shows a dropped image as an image while it uploads', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '', pending: true, pendingName: 'shot.png' }) + + expect(screen.getByText('shot.png')).toBeTruthy() + expect(document.querySelector('.lucide-image')).toBeTruthy() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 01940b7d8911..95b41379ed1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -1,20 +1,27 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Image as ImageIcon, Loader2, X } from 'lucide-react' +import { FileText, Image as ImageIcon, Loader2, X } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { + isNativeChatImageAttachmentPath, + isNativeChatPastedImagePath +} from './native-chat-image-paste' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' type Props = { attachment: NativeChatComposerImageAttachment + /** The paired server the chat runs on; a file stored there is read back through it. */ + hostEnvironmentId?: string onRemove: (id: string) => void } /** Thumbnail for a pending image, with an in-app full-size preview on click. */ export function NativeChatImageAttachmentPreview({ attachment, + hostEnvironmentId, onRemove }: Props): React.JSX.Element { const [isOpen, setIsOpen] = useState(false) @@ -43,17 +50,20 @@ export function NativeChatImageAttachmentPreview({ }, []) const isPending = attachment.pending === true // A stored attachment is a path on the paired server, read back through that server. - const hostEnvironmentId = attachment.hostOwner?.environmentId + const readEnvironmentId = + hostEnvironmentId && isAgentSessionAttachmentStorePath(attachment.path) + ? hostEnvironmentId + : undefined const hostReadContext = useMemo( () => - hostEnvironmentId + readEnvironmentId ? { - settings: { activeRuntimeEnvironmentId: hostEnvironmentId }, + settings: { activeRuntimeEnvironmentId: readEnvironmentId }, worktreeId: null, worktreePath: null } : undefined, - [hostEnvironmentId] + [readEnvironmentId] ) const localSrc = useLocalImageSrc( !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, @@ -69,13 +79,15 @@ export function NativeChatImageAttachmentPreview({ ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') : basename(attachment.path) const pendingLabel = attachment.pendingName - ? translate( - 'components.native-chat.composer.uploadingAttachments', - 'Uploading {{value0}} file(s) to remote…', - { value0: 1 } - ) + ? translate('components.native-chat.composer.uploadingFile', 'Uploading {{name}}…', { + name: attachment.pendingName + }) : translate('components.native-chat.composer.imageSaving', 'Saving pasted image…') - const label = isPending ? pendingLabel : filename + const label = isPending ? (attachment.pendingName ?? pendingLabel) : filename + // A dropped document uploads as a chip too, and becomes an `@` reference once stored. + const isFile = + attachment.pendingName !== undefined && !isNativeChatImageAttachmentPath(attachment.pendingName) + const KindIcon = isFile ? FileText : ImageIcon return ( <> @@ -98,8 +110,15 @@ export function NativeChatImageAttachmentPreview({ alt={label} className={`size-full object-cover${isPending ? ' opacity-50' : ''}`} /> + ) : attachment.pendingName ? ( + + + + {attachment.pendingName} + + ) : ( - + )} {isPending ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index d70688813ce3..075b9ee913ce 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -13,7 +13,10 @@ import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-rev import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report' import { @@ -31,19 +34,15 @@ export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { worktreePath: string } -/** Which paired server's store holds an uploaded attachment, and for which chat. Carried by each - * attached item so a send can refuse one meant for another host. */ -export type NativeChatAttachmentHostOwner = { +/** A structured chat on a paired server: files upload into that server's attachment store, which + * checks every stored path a message names when it admits the message. */ +export type NativeChatRuntimeSessionAttachmentOwner = { + kind: 'runtime-session' environmentId: string pairingRevision: number sessionId: string } -/** A structured chat on a paired server: files upload into that server's attachment store. */ -export type NativeChatRuntimeSessionAttachmentOwner = NativeChatAttachmentHostOwner & { - kind: 'runtime-session' -} - export type NativeChatAttachmentOwner = | { kind: 'local' } | NativeChatSshAttachmentOwner @@ -140,16 +139,6 @@ export function resolveNativeChatRuntimeSessionAttachmentOwner(session: { } } -export function nativeChatAttachmentHostOwner( - owner: NativeChatRuntimeSessionAttachmentOwner -): NativeChatAttachmentHostOwner { - return { - environmentId: owner.environmentId, - pairingRevision: owner.pairingRevision, - sessionId: owner.sessionId - } -} - export function nativeChatWorktreeNotReadyNotice(): string { return translate( 'components.native-chat.composer.worktreeNotReady', @@ -171,6 +160,13 @@ export function nativeChatAttachmentUnreadableNotice(): string { ) } +/** One notice for every file of a drop or pick that did not attach, by name. */ +export function nativeChatAttachFailedNotice(names: readonly string[]): string { + return translate('components.native-chat.composer.attachFailed', "Couldn't attach {{files}}.", { + files: names.join(', ') + }) +} + export function nativeChatAttachmentsNeedNewerServerNotice(): string { return translate( 'components.native-chat.composer.attachmentsNeedNewerServer', @@ -252,32 +248,12 @@ export async function prepareNativeChatSessionAttachmentUpload( } /** - * Upload client-local files into the chat's store on its paired server. Returns the stored paths - * by source path, or null when the upload IPC itself failed; per-file skips/failures surface - * through the shared drop toasts, as SSH uploads do. + * Upload client-local files into the chat's store on its paired server. The chips show progress, + * so nothing else does; the caller reports what did not attach. */ -export async function uploadNativeChatSessionAttachmentPaths( +export function uploadNativeChatSessionAttachmentPaths( paths: string[], target: AgentSessionAttachmentUploadTarget -): Promise | null> { - const pending = toast.loading( - translate( - 'components.native-chat.composer.uploadingAttachments', - 'Uploading {{value0}} file(s) to remote…', - { value0: paths.length } - ) - ) - try { - const { uploaded, skipped, failed } = await window.api.fs.uploadPathsToAgentSessionAttachments({ - ...target, - paths - }) - reportTerminalDropUploadSkipsAndFailures(skipped, failed) - return new Map(uploaded.map(({ sourcePath, path }) => [sourcePath, path])) - } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) - return null - } finally { - toast.dismiss(pending) - } +): Promise { + return window.api.fs.uploadPathsToAgentSessionAttachments({ ...target, paths }) } diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts index b6dc0ff96ea6..123bc545d632 100644 --- a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -24,7 +24,7 @@ async function clipboardImageSaveArgs( owner: ClipboardImageOwner ): Promise< | { ok: true; args: Parameters[0] } - | { ok: false; notice: string } + | { ok: false; notice: string; cause: NativeChatClipboardImageFailureCause } > { if (owner.kind === 'local') { return { ok: true, args: undefined } @@ -34,19 +34,22 @@ async function clipboardImageSaveArgs( } const prepared = await prepareNativeChatSessionAttachmentUpload(owner) if (!prepared.ok) { - return prepared + return { ...prepared, cause: 'serverTooOld' } } const { environmentId, ...agentSessionAttachment } = prepared.target return { ok: true, args: { runtimeEnvironmentId: environmentId, agentSessionAttachment } } } +/** `serverTooOld`: the chat's paired server keeps no attachments, so it could never take one. */ +export type NativeChatClipboardImageFailureCause = 'serverTooOld' | 'failed' + /** Save the clipboard image where the owner's agent can read it. Every failure is reported. */ export async function saveNativeChatClipboardImage( owner: NativeChatAttachmentOwner, - report: { setNotice: (notice: string) => void } + report: { setNotice: (notice: string, cause: NativeChatClipboardImageFailureCause) => void } ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> { if (!ownerAcceptsClipboardImage(owner)) { - report.setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + report.setNotice(nativeChatLocalAttachmentUnsupportedNotice(), 'failed') return { status: 'failed' } } try { @@ -54,7 +57,7 @@ export async function saveNativeChatClipboardImage( // path is readable by the remote agent, matching terminal image paste. const target = await clipboardImageSaveArgs(owner) if (!target.ok) { - report.setNotice(target.notice) + report.setNotice(target.notice, target.cause) return { status: 'failed' } } const tempPath = await window.api.ui.saveClipboardImageAsTempFile(target.args) @@ -66,7 +69,8 @@ export async function saveNativeChatClipboardImage( extractIpcErrorMessage( error, translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') - ) + ), + 'failed' ) return { status: 'failed' } } diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 6caeca79c5b6..c69e48824528 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -1,14 +1,10 @@ -import { toast } from 'sonner' import { basename } from '@/lib/path' -import { extractIpcErrorMessage } from '@/lib/ipc-error' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' import { - nativeChatAttachmentHostOwner, + nativeChatAttachFailedNotice, nativeChatAttachmentOwnerChangedNotice, - nativeChatAttachmentUnreadableNotice, prepareNativeChatSessionAttachmentUpload, uploadNativeChatSessionAttachmentPaths, - type NativeChatAttachmentHostOwner, type NativeChatRuntimeSessionAttachmentOwner } from './native-chat-attachment-upload' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' @@ -16,19 +12,16 @@ import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path- /** The composer's pending-chip controls, as an upload drives them. */ export type NativeChatPendingAttachmentChips = { begin: (previewUrl?: string, pendingName?: string) => string | null - resolve: ( - id: string, - path: string, - connectionId?: string | null, - hostOwner?: NativeChatAttachmentHostOwner - ) => void - drop: (id: string) => void + resolve: (id: string, path: string, connectionId?: string | null) => void + /** Removes the chip; false when the user already removed it, so its file must not attach. */ + drop: (id: string) => boolean } /** * Drop or pick files into a structured chat on a paired server: upload them into the chat's store - * there, then attach the stored paths. Each file shows as a pending chip at once, and Send waits - * for pending chips, so a message never leaves without a file the user attached. + * there, then attach the stored paths. Each file shows as a pending chip with its name at once, and + * Send waits for pending chips, so a message never leaves without a file the user attached. A chip + * the user removes meanwhile attaches nothing. */ export async function attachNativeChatSessionAttachmentPaths(args: { paths: string[] @@ -44,18 +37,24 @@ export async function attachNativeChatSessionAttachmentPaths(args: { ) => void setNotice: (notice: string | null) => void }): Promise { - const pending = args.paths.map((path) => ({ - path, - chipId: args.chips.begin(undefined, basename(path)) - })) + const pending = args.paths.flatMap((path) => { + const chipId = args.chips.begin(undefined, basename(path)) + // No chip: the composer refused the attach and already said why. + return chipId ? [{ path, chipId }] : [] + }) + if (pending.length === 0) { + return + } const dropAll = (): void => { for (const { chipId } of pending) { - if (chipId) { - args.chips.drop(chipId) - } + args.chips.drop(chipId) } } - let stored: Map | null + const failAll = (): void => { + dropAll() + args.setNotice(nativeChatAttachFailedNotice(pending.map(({ path }) => basename(path)))) + } + let stored: Map try { const prepared = await prepareNativeChatSessionAttachmentUpload(args.owner) if (!prepared.ok) { @@ -63,14 +62,18 @@ export async function attachNativeChatSessionAttachmentPaths(args: { args.setNotice(prepared.notice) return } - stored = await uploadNativeChatSessionAttachmentPaths(args.paths, prepared.target) - } catch (error) { - dropAll() - toast.error(extractIpcErrorMessage(error, 'Failed to upload files.')) + const result = await uploadNativeChatSessionAttachmentPaths( + pending.map(({ path }) => path), + prepared.target + ) + stored = new Map(result.uploaded.map(({ sourcePath, path }) => [sourcePath, path])) + } catch { + if (!args.isAbandoned()) { + failAll() + } return } - if (args.isAbandoned() || !stored) { - // A failed upload already said so in its toast. + if (args.isAbandoned()) { dropAll() return } @@ -79,26 +82,28 @@ export async function attachNativeChatSessionAttachmentPaths(args: { args.setNotice(nativeChatAttachmentOwnerChangedNotice()) return } - if (stored.size === 0) { - dropAll() - args.setNotice(nativeChatAttachmentUnreadableNotice()) - return - } - const hostOwner = nativeChatAttachmentHostOwner(args.owner) - const unchipped: string[] = [] + const notAttached: string[] = [] + const references: string[] = [] for (const { path, chipId } of pending) { const storedPath = stored.get(path) - if (storedPath && chipId && isNativeChatImageAttachmentPath(storedPath)) { - args.chips.resolve(chipId, storedPath, null, hostOwner) + if (!storedPath) { + if (args.chips.drop(chipId)) { + notAttached.push(basename(path)) + } continue } - if (chipId) { - args.chips.drop(chipId) + if (isNativeChatImageAttachmentPath(storedPath)) { + args.chips.resolve(chipId, storedPath, null) + continue } - if (storedPath) { - unchipped.push(storedPath) + // Other files become `@path` references, as every attach does, unless their chip was removed. + if (args.chips.drop(chipId)) { + references.push(storedPath) } } - // Other files become `@path` references, as every attach does; images without a chip get one. - args.attachResolvedPaths(unchipped, null, { hostOwner }) + // Attaching clears the notice, so what failed is said after. + args.attachResolvedPaths(references, null) + if (notAttached.length > 0) { + args.setNotice(nativeChatAttachFailedNotice(notAttached)) + } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 6ae8aca5afe3..efc82cd12f0f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -440,6 +440,31 @@ describe('useNativeChatComposerAttachments', () => { act(() => probe.root.unmount()) }) + it('tells a finishing upload that the user removed its chip', async () => { + const probe = await renderProbe('pty-1') + let removedId: string | null = null + let keptId: string | null = null + act(() => { + removedId = probe.latest().pendingChips.begin(undefined, 'report.pdf') + keptId = probe.latest().pendingChips.begin(undefined, 'notes.md') + }) + if (!removedId || !keptId) { + throw new Error('expected pending chips') + } + act(() => probe.latest().removeImageAttachment(removedId as string)) + + let removedStillLive = true + let keptStillLive = false + act(() => { + removedStillLive = probe.latest().pendingChips.drop(removedId as string) + keptStillLive = probe.latest().pendingChips.drop(keptId as string) + }) + expect(removedStillLive).toBe(false) + expect(keptStillLive).toBe(true) + expect(probe.latest().imageAttachments).toEqual([]) + act(() => probe.root.unmount()) + }) + it('excludes a pending chip from the scope cache while a settled chip persists', async () => { const probe = await renderProbe('pty-1') let pendingId: string | null = null diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 80a88d0881d7..7def0d65710f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -8,16 +8,8 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' -import { - nativeChatLocalAttachmentUnsupportedNotice, - type NativeChatAttachmentHostOwner -} from './native-chat-attachment-upload' +import { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-attachment-upload' import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' -import { - clearNativeChatHostOwnedReferences, - recordNativeChatHostOwnedReferences, - type NativeChatHostOwnedReference -} from './native-chat-attachment-destination' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -54,19 +46,16 @@ export function useNativeChatComposerAttachments({ flushPendingAttachments: () => void removeImageAttachment: (id: string) => void beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null - resolvePendingImageAttachment: ( - id: string, - path: string, - connectionId?: string | null, - hostOwner?: NativeChatAttachmentHostOwner - ) => void - dropPendingImageAttachment: (id: string) => void + resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + dropPendingImageAttachment: (id: string) => boolean pendingChips: NativeChatPendingAttachmentChips } { const [imageAttachments, setImageAttachments] = useState( () => readNativeChatAttachmentCache(attachmentScopeKey) ) const imageAttachmentCounter = useRef(0) + // Pending chips the user has not removed, known synchronously so a finishing upload can ask. + const livePendingChipIds = useRef(new Set()) useEffect( () => @@ -114,35 +103,22 @@ export function useNativeChatComposerAttachments({ }, [setNotice]) const appendImageAttachments = useCallback( - ( - paths: { - path: string - connectionId?: string | null - hostOwner?: NativeChatAttachmentHostOwner - }[] - ) => { + (paths: { path: string; connectionId?: string | null }[]) => { if (paths.length === 0) { return } updateImageAttachments((prev) => [ ...prev, - ...paths.map(({ path, connectionId, hostOwner }) => ({ + ...paths.map(({ path, connectionId }) => ({ id: nextAttachmentId(), path, - connectionId: connectionId ?? undefined, - ...(hostOwner ? { hostOwner } : {}) + connectionId: connectionId ?? undefined })) ]) }, [nextAttachmentId, updateImageAttachments] ) - const noteHostOwnedReferences = useCallback( - (references: NativeChatHostOwnedReference[]) => - recordNativeChatHostOwnedReferences(attachmentScopeKey, references), - [attachmentScopeKey] - ) - const { attachResolvedPaths, disabledRef, flushPendingAttachments } = useNativeChatResolvedPathAttachments({ appendImageAttachments, @@ -151,7 +127,6 @@ export function useNativeChatComposerAttachments({ disabled, isComposing, noteAttachmentTargetBlocked, - noteHostOwnedReferences, setCaret, setDraft, setNotice, @@ -170,6 +145,7 @@ export function useNativeChatComposerAttachments({ return null } const id = nextAttachmentId() + livePendingChipIds.current.add(id) updateImageAttachments((prev) => [ ...prev, { id, path: '', previewUrl, pending: true, ...(pendingName ? { pendingName } : {}) } @@ -186,12 +162,8 @@ export function useNativeChatComposerAttachments({ ) const resolvePendingImageAttachment = useCallback( - ( - id: string, - path: string, - connectionId?: string | null, - hostOwner?: NativeChatAttachmentHostOwner - ) => { + (id: string, path: string, connectionId?: string | null) => { + livePendingChipIds.current.delete(id) updateImageAttachments((prev) => prev.map((attachment) => attachment.id === id @@ -200,8 +172,7 @@ export function useNativeChatComposerAttachments({ path, connectionId: connectionId ?? undefined, pending: undefined, - pendingName: undefined, - ...(hostOwner ? { hostOwner } : {}) + pendingName: undefined } : attachment ) @@ -211,8 +182,10 @@ export function useNativeChatComposerAttachments({ ) const dropPendingImageAttachment = useCallback( - (id: string) => { + (id: string): boolean => { + const live = livePendingChipIds.current.delete(id) updateImageAttachments((prev) => removeAttachmentById(prev, id)) + return live }, [updateImageAttachments] ) @@ -231,14 +204,17 @@ export function useNativeChatComposerAttachments({ imageAttachments, attachResolvedPaths, clearImageAttachments: () => { - clearNativeChatHostOwnedReferences(attachmentScopeKey) + livePendingChipIds.current.clear() updateImageAttachments((prev) => { prev.forEach(releaseAttachmentPreview) return [] }) }, flushPendingAttachments, - removeImageAttachment: (id) => updateImageAttachments((prev) => removeAttachmentById(prev, id)), + removeImageAttachment: (id) => { + livePendingChipIds.current.delete(id) + updateImageAttachments((prev) => removeAttachmentById(prev, id)) + }, beginPendingImageAttachment, resolvePendingImageAttachment, dropPendingImageAttachment diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx index 4f52537d90aa..4874b227621b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -1,13 +1,10 @@ // @vitest-environment happy-dom // Pastes into a structured chat on a paired server land in that server's attachment store, on -// every paste path, and the chip keeps the store's owner. +// every paste path; the server checks each stored path again when it admits the message. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' -import type { - NativeChatAttachmentHostOwner, - NativeChatAttachmentOwner -} from './native-chat-attachment-upload' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' const mocks = vi.hoisted(() => ({ saveClipboardImageAsTempFile: vi.fn(), @@ -30,11 +27,6 @@ vi.mock('./native-chat-attachment-upload', () => ({ nativeChatLocalAttachmentUnsupportedNotice: () => 'Local attachments are not available for remote sessions.', nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.', - nativeChatAttachmentHostOwner: (owner: NativeChatAttachmentHostOwner) => ({ - environmentId: owner.environmentId, - pairingRevision: owner.pairingRevision, - sessionId: owner.sessionId - }), prepareNativeChatSessionAttachmentUpload: mocks.prepareNativeChatSessionAttachmentUpload })) @@ -57,12 +49,7 @@ vi.stubGlobal('URL', { import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' type HookApi = ReturnType -type Chip = { - id: string - path: string - pending: boolean - hostOwner?: NativeChatAttachmentHostOwner -} +type Chip = { id: string; path: string; pending: boolean } const sessionOwner: NativeChatAttachmentOwner = { kind: 'runtime-session', @@ -70,7 +57,6 @@ const sessionOwner: NativeChatAttachmentOwner = { pairingRevision: 7, sessionId: 'session-1' } -const hostOwner = { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } const storeArgs = { runtimeEnvironmentId: 'env-1', agentSessionAttachment: { @@ -79,7 +65,7 @@ const storeArgs = { expectedEnvironmentRuntimeId: 'runtime-a' } } -const storedPath = '/srv/agent-session-attachments/s/u1/orca-paste-1.png' +const storedPath = '/srv/agent-session-attachments/u1/orca-paste-1.png' let root: Root | null = null @@ -105,10 +91,10 @@ async function renderPaste(args: { chips.push({ id: `chip-${counter}`, path: '', pending: true }) return `chip-${counter}` }, - resolvePendingImageAttachment: (id, path, _connectionId, owner) => { + resolvePendingImageAttachment: (id, path) => { const chip = chips.find((candidate) => candidate.id === id) if (chip) { - Object.assign(chip, { path, pending: false, hostOwner: owner }) + Object.assign(chip, { path, pending: false }) } }, dropPendingImageAttachment: (id) => { @@ -170,11 +156,11 @@ afterEach(() => { }) describe('pasting into a structured chat on a paired server', () => { - it('saves a pasted image into the chat store and settles the chip with its owner', async () => { + it('saves a pasted image into the chat store and settles the chip with its server path', async () => { const probe = await renderPaste({}) await act(async () => probe.api().handlePaste(imagePasteEvent())) expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) - expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false, hostOwner }]) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) }) it('keeps the image of a paste that also carries text', async () => { @@ -191,14 +177,14 @@ describe('pasting into a structured chat on a paired server', () => { const probe = await renderPaste({}) await act(async () => probe.api().pasteFromClipboard()) expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) - expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false, hostOwner }]) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) }) - it('attaches with its owner when no placeholder chip was shown', async () => { + it('attaches the server path when no placeholder chip was shown', async () => { const attachResolvedPaths = vi.fn() const probe = await renderPaste({ attachResolvedPaths }) await act(async () => probe.api().pasteFromClipboard()) - expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([storedPath], null, { hostOwner }) + expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([storedPath], null) }) it('refuses on a server without the attachment store and saves nothing', async () => { @@ -213,4 +199,32 @@ describe('pasting into a structured chat on a paired server', () => { expect(setNotice).toHaveBeenLastCalledWith('needs newer server') expect(probe.chips).toHaveLength(0) }) + + it('pastes rich text into a chat on an older server without a refusal beside the text', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: false, + notice: 'needs newer server' + }) + const insertTypedText = vi.fn(() => true) + const setNotice = vi.fn() + const probe = await renderPaste({ insertTypedText, setNotice }) + await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) + expect(insertTypedText).toHaveBeenCalledWith('caption') + expect(setNotice).not.toHaveBeenCalledWith('needs newer server') + expect(probe.chips).toHaveLength(0) + }) + + it('pastes rich text from the button into a chat on an older server without a refusal', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: false, + notice: 'needs newer server' + }) + mocks.readClipboardText.mockResolvedValue('caption') + const insertTypedText = vi.fn(() => true) + const setNotice = vi.fn() + const probe = await renderPaste({ insertTypedText, setNotice }) + await act(async () => probe.api().pasteFromClipboard()) + expect(insertTypedText).toHaveBeenCalledWith('caption') + expect(setNotice).not.toHaveBeenCalledWith('needs newer server') + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 82354c2a323f..7bb8747d3533 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -15,10 +15,8 @@ import { type ClipboardEventLike } from './native-chat-clipboard-payload' import { - nativeChatAttachmentHostOwner, nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, - type NativeChatAttachmentHostOwner, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' import { @@ -42,12 +40,7 @@ export type UseNativeChatComposerPasteArgs = { options?: NativeChatResolvedPathOptions ) => void beginPendingImageAttachment: (previewUrl?: string) => string | null - resolvePendingImageAttachment: ( - id: string, - path: string, - connectionId?: string | null, - hostOwner?: NativeChatAttachmentHostOwner - ) => void + resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void dropPendingImageAttachment: (id: string) => void insertTypedText: (text: string) => boolean setCaret: (caret: number) => void @@ -101,14 +94,19 @@ export function useNativeChatComposerPaste({ } }, [lifetime, setNotice]) - // Image failures do not decide whether text can be inserted. + // Image failures do not decide whether text can be inserted. Beside pasted text, a server too old + // to store the image drops only the image rendition, as an owner that takes no images does. const saveClipboardImageForOwner = useCallback( - (owner: NativeChatAttachmentOwner) => + (owner: NativeChatAttachmentOwner, besidePastedText: () => Promise) => saveNativeChatClipboardImage(owner, { - setNotice: (notice) => { - if (canPaste()) { - setNotice(notice) - } + setNotice: (notice, cause) => { + void (cause === 'serverTooOld' ? besidePastedText() : Promise.resolve(false)).then( + (quiet) => { + if (!quiet && canPaste()) { + setNotice(notice) + } + } + ) } }), [canPaste, setNotice] @@ -127,15 +125,11 @@ export function useNativeChatComposerPaste({ return } const connectionId = originalOwner.kind === 'ssh' ? originalOwner.connectionId : null - const hostOwner = - originalOwner.kind === 'runtime-session' - ? nativeChatAttachmentHostOwner(originalOwner) - : undefined if (pendingId) { lifetime.pending.delete(pendingId) - resolvePendingImageAttachment(pendingId, path, connectionId, hostOwner) + resolvePendingImageAttachment(pendingId, path, connectionId) } else { - attachResolvedPaths([path], connectionId, ...(hostOwner ? [{ hostOwner }] : [])) + attachResolvedPaths([path], connectionId) } }, [ @@ -204,7 +198,7 @@ export function useNativeChatComposerPaste({ lifetime.pending.set(pendingId, previewUrl ?? '') } void (async () => { - const saved = await saveClipboardImageForOwner(owner) + const saved = await saveClipboardImageForOwner(owner, async () => Boolean(text)) if (saved.status !== 'saved' || !canPaste()) { if (pendingId) { lifetime.pending.delete(pendingId) @@ -281,7 +275,10 @@ export function useNativeChatComposerPaste({ return } const thumbnailPromise = window.api.ui.readClipboardImageThumbnail().catch(() => null) - const savePromise = saveClipboardImageForOwner(owner) + const savePromise = saveClipboardImageForOwner(owner, async () => { + const read = await textRead + return Boolean(read?.text) && read?.labelsFiles !== true + }) const thumbnail = await thumbnailPromise const pendingId = thumbnail && canPaste() ? beginPendingImageAttachment(thumbnail.dataUrl) : null diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index d73696545abf..84cac18c7b0e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -10,7 +10,12 @@ const mocks = vi.hoisted(() => ({ resolveNativeChatAttachmentOwnerForWorktree: vi.fn(), uploadNativeChatAttachmentPaths: vi.fn(), prepareNativeChatSessionAttachmentUpload: vi.fn(), - uploadNativeChatSessionAttachmentPaths: vi.fn() + uploadNativeChatSessionAttachmentPaths: vi.fn(), + toastLoading: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { loading: mocks.toastLoading, dismiss: vi.fn(), error: vi.fn(), message: vi.fn() } })) vi.mock('@/store', () => ({ @@ -30,6 +35,7 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import type { AgentSessionAttachmentPathUploadResult } from '../../../../shared/agent-session-attachments' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' type HookApi = ReturnType @@ -43,7 +49,7 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { } function noPendingChips(): NativeChatPendingAttachmentChips { - return { begin: vi.fn(() => null), resolve: vi.fn(), drop: vi.fn() } + return { begin: vi.fn(() => null), resolve: vi.fn(), drop: vi.fn(() => true) } } function Probe({ @@ -458,7 +464,6 @@ describe('useNativeChatExternalAttachments', () => { describe('a structured chat on a paired server', () => { const session = { sessionId: 'session-1', runtimeEnvironmentId: 'env-1' } - const hostOwner = { environmentId: 'env-1', pairingRevision: 7, sessionId: 'session-1' } const target = { environmentId: 'env-1', sessionId: 'session-1', @@ -466,16 +471,33 @@ describe('useNativeChatExternalAttachments', () => { expectedEnvironmentRuntimeId: 'runtime-a' } - function trackingChips(): NativeChatPendingAttachmentChips & { begun: string[] } { + /** `removed`: chips the user clicked away, which the composer no longer holds. */ + function trackingChips(): NativeChatPendingAttachmentChips & { + begun: string[] + removed: Set + } { const begun: string[] = [] + const removed = new Set() return { begun, + removed, begin: vi.fn((_preview?: string, name?: string) => { begun.push(name ?? '') return `chip-${begun.length}` }), resolve: vi.fn(), - drop: vi.fn() + drop: vi.fn((id: string) => !removed.has(id)) + } + } + + function uploaded( + pairs: [sourcePath: string, path: string][], + failed: string[] = [] + ): AgentSessionAttachmentPathUploadResult { + return { + uploaded: pairs.map(([sourcePath, path]) => ({ sourcePath, path })), + skipped: [], + failed: failed.map((sourcePath) => ({ sourcePath, reason: 'upload failed' })) } } @@ -488,8 +510,8 @@ describe('useNativeChatExternalAttachments', () => { mocks.uploadNativeChatSessionAttachmentPaths.mockReset() }) - it('uploads into the chat store and attaches only server paths, with their owner', async () => { - const upload = deferred>() + it('uploads into the chat store and attaches only server paths', async () => { + const upload = deferred() mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) const chips = trackingChips() const attachResolvedPaths = vi.fn() @@ -505,9 +527,9 @@ describe('useNativeChatExternalAttachments', () => { expect(chips.begun).toEqual(['shot.png', 'notes.md']) await act(async () => upload.resolve( - new Map([ - ['/Users/me/shot.png', '/srv/agent-session-attachments/s/u1/shot.png'], - ['/Users/me/notes.md', '/srv/agent-session-attachments/s/u2/notes.md'] + uploaded([ + ['/Users/me/shot.png', '/srv/agent-session-attachments/u1/shot.png'], + ['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md'] ]) ) ) @@ -518,19 +540,77 @@ describe('useNativeChatExternalAttachments', () => { ) expect(chips.resolve).toHaveBeenCalledExactlyOnceWith( 'chip-1', - '/srv/agent-session-attachments/s/u1/shot.png', - null, - hostOwner + '/srv/agent-session-attachments/u1/shot.png', + null ) expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( - ['/srv/agent-session-attachments/s/u2/notes.md'], - null, - { hostOwner } + ['/srv/agent-session-attachments/u2/notes.md'], + null ) expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() }) + it('does not insert a file whose chip the user removed while it uploaded', async () => { + const upload = deferred() + mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips + }) + + act(() => probe.latest().attachExternalPaths(['/Users/me/report.pdf', '/Users/me/notes.md'])) + chips.removed.add('chip-1') + await act(async () => + upload.resolve( + uploaded([ + ['/Users/me/report.pdf', '/srv/agent-session-attachments/u1/report.pdf'], + ['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md'] + ]) + ) + ) + + expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( + ['/srv/agent-session-attachments/u2/notes.md'], + null + ) + }) + + it('names, in one notice, each file that did not attach', async () => { + mocks.uploadNativeChatSessionAttachmentPaths.mockResolvedValueOnce( + uploaded( + [['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md']], + ['/Users/me/huge.mov'] + ) + ) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const notices: (string | null)[] = [] + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips, + setNotice: (notice) => notices.push(notice) + }) + + await act(async () => + probe.latest().attachExternalPaths(['/Users/me/huge.mov', '/Users/me/notes.md']) + ) + + expect(chips.drop).toHaveBeenCalledWith('chip-1') + expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( + ['/srv/agent-session-attachments/u2/notes.md'], + null + ) + expect(notices).toEqual(["Couldn't attach huge.mov."]) + expect(mocks.toastLoading).not.toHaveBeenCalled() + }) + it('refuses on a server without the attachment store, uploading nothing', async () => { mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValueOnce({ ok: false, @@ -555,9 +635,11 @@ describe('useNativeChatExternalAttachments', () => { expect(notices.at(-1)).toBe('needs newer server') }) - it('drops the upload when the server is re-paired meanwhile', async () => { - const upload = deferred>() - mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) + it('says which file did not attach when the upload itself fails, such as on a re-pair', async () => { + // Main refuses every chunk once the pairing changes, so the whole upload call fails. + mocks.uploadNativeChatSessionAttachmentPaths.mockRejectedValueOnce( + new Error('Runtime environment changed') + ) const chips = trackingChips() const attachResolvedPaths = vi.fn() const notices: (string | null)[] = [] @@ -569,20 +651,12 @@ describe('useNativeChatExternalAttachments', () => { setNotice: (notice) => notices.push(notice) }) - act(() => probe.latest().attachExternalPaths(['/Users/me/shot.png'])) - replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 8 }]) - await act(async () => - upload.resolve( - new Map([['/Users/me/shot.png', '/srv/agent-session-attachments/s/u1/shot.png']]) - ) - ) + await act(async () => probe.latest().attachExternalPaths(['/Users/me/shot.png'])) expect(chips.resolve).not.toHaveBeenCalled() expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') expect(attachResolvedPaths).not.toHaveBeenCalled() - expect(notices.at(-1)).toBe( - 'This workspace changed hosts while attaching — drop the files again.' - ) + expect(notices).toEqual(["Couldn't attach shot.png."]) }) it('keeps a local structured chat on the worktree owner', async () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a792d393b563..fe02b5807c95 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17766,6 +17766,8 @@ "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "attachmentsNeedNewerServer": "Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.", + "attachFailed": "Couldn't attach {{files}}.", + "uploadingFile": "Uploading {{name}}…", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", "imagePasteFailed": "Image paste failed.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 696f615f64b3..955131046cde 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15085,6 +15085,8 @@ "noSkills": "Sin habilidades coincidentes", "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "attachmentsNeedNewerServer": "Para adjuntar archivos a este chat, el servidor necesita una versión más reciente de Orca. Actualiza Orca en ese equipo y vuelve a intentarlo.", + "attachFailed": "No se pudo adjuntar {{files}}.", + "uploadingFile": "Subiendo {{name}}…", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", "imagePasteFailed": "Error al pegar imagen.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 641957edcbee..429b59fb7178 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17569,6 +17569,8 @@ "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", "attachmentsNeedNewerServer": "Pour joindre des fichiers à ce chat, le serveur doit utiliser une version plus récente d’Orca. Mettez à jour Orca sur cet ordinateur, puis réessayez.", + "attachFailed": "Impossible de joindre {{files}}.", + "uploadingFile": "Envoi de {{name}}…", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", "imagePasteFailed": "Échec du collage de l'image.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 686a2ad8fb3d..55f126d48d18 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17491,6 +17491,8 @@ "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", "attachmentsNeedNewerServer": "このチャットにファイルを添付するには、サーバーの Orca を新しいバージョンにする必要があります。そのコンピューターの Orca を更新してから、もう一度お試しください。", + "attachFailed": "{{files}} を添付できませんでした。", + "uploadingFile": "{{name}} をアップロードしています…", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", "imagePasteFailed": "画像の貼り付けに失敗しました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c4162294283b..d6e4ca34e39c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17491,6 +17491,8 @@ "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "attachmentsNeedNewerServer": "이 채팅에 파일을 첨부하려면 서버의 Orca를 최신 버전으로 업데이트해야 합니다. 해당 컴퓨터에서 Orca를 업데이트한 후 다시 시도하세요.", + "attachFailed": "{{files}}을(를) 첨부할 수 없습니다.", + "uploadingFile": "{{name}} 업로드 중…", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", "imagePasteFailed": "이미지 붙여넣기 실패.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index eb819646230c..f8a4638ccdee 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17508,6 +17508,8 @@ "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", "attachmentsNeedNewerServer": "要向此聊天附加文件,服务器上的 Orca 需要更新到较新版本。请在那台电脑上更新 Orca,然后重试。", + "attachFailed": "无法附加 {{files}}。", + "uploadingFile": "正在上传 {{name}}…", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", "imagePasteFailed": "图片粘贴失败。", From 17067342f9141b2555689a59636603de98065623 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:17:02 -0700 Subject: [PATCH 11/40] chore(native-chat): keep the claim hook beside the draft consume and fit the line budgets The submission's claim runs in the same append hook as a draft's consume, owned by the queued-message collaborator, so the journal store stays within its size limit. Formats the new files and regenerates the runtime-required English catalog. --- .../agent-session-attachment-claims.test.ts | 31 +++++++++--- .../agent-session-attachment-claims.ts | 10 ++-- .../agent-session-attachment-store.test.ts | 4 +- .../journal-queued-messages.ts | 50 +++++++++++++------ .../agent-session-journal/journal-store.ts | 18 +------ ...agent-session-attachment-admission.test.ts | 5 +- ...ructured-agent-session-attachments.test.ts | 12 ++++- .../NativeChatImageAttachmentPreview.test.tsx | 2 +- ...-native-chat-composer-attachments.test.tsx | 21 ++++---- .../src/i18n/en-runtime-required.json | 4 +- ...eb-agent-session-attachment-upload.test.ts | 10 ++-- src/shared/agent-session-attachments.ts | 3 +- src/shared/agent-session-refusal-notice.ts | 7 +-- 13 files changed, 107 insertions(+), 70 deletions(-) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts index 543fcd55c9bc..694281b426ab 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -60,7 +60,10 @@ describe('agentSessionAttachmentReferences', () => { const references = agentSessionAttachmentReferences( windowsRoot, message( - { type: 'image-ref', path: `c:/users/ME/appdata/roaming/orca/agent-session-attachments/${UPLOAD_A}/s.png` }, + { + type: 'image-ref', + path: `c:/users/ME/appdata/roaming/orca/agent-session-attachments/${UPLOAD_A}/s.png` + }, { type: 'text', text: `@"${windowsRoot}\\${UPLOAD_B.toUpperCase()}\\a b.txt"` } ), 'win32' @@ -168,7 +171,9 @@ describe('claims written with the message', () => { it('refuses an upload the sweep already marked, even while its file is still on disk', async () => { const journal = await open() const path = await storeUpload(UPLOAD_A) - expect(markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1)).toBe(true) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(true) const error = await clientSend(journal, 'send-1', message({ type: 'image-ref', path })).catch( (caught: unknown) => caught ) @@ -179,7 +184,9 @@ describe('claims written with the message', () => { const journal = await open() const path = await storeUpload(UPLOAD_A) await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) - expect(markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1)).toBe(false) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(false) }) it("never refuses the host's own message, and claims what it names", async () => { @@ -196,14 +203,24 @@ describe('claims written with the message', () => { origin: 'host' }) expect(journal.submission('host-1')).toBeTruthy() - expect(claims().map((claim) => claim.upload_id).sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + expect( + claims() + .map((claim) => claim.upload_id) + .sort() + ).toEqual([UPLOAD_A, UPLOAD_B].sort()) }) it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => { const journal = await open() const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` }) const refused = await journal.queuedMessages - .insert({ messageId: 'draft-0', body: missing, fingerprint: 'fp', hostInstance: 'p', requireAttachments: true }) + .insert({ + messageId: 'draft-0', + body: missing, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) .catch((caught: unknown) => caught) expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) expect(journal.queuedMessages.list()).toHaveLength(0) @@ -257,6 +274,8 @@ describe('claims written with the message', () => { await journals.closeAll() journal = await open() expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) - expect(markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1)).toBe(false) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(false) }) }) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts index e2eda5343f4a..5b69f9553740 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -60,8 +60,9 @@ function uploadIsStored(root: string, uploadId: string): boolean { function isBeingSwept(db: Database.Database, uploadId: string): boolean { return ( - db.prepare('SELECT 1 FROM agent_session_attachment_sweeps WHERE upload_id = ?').get(uploadId) !== - undefined + db + .prepare('SELECT 1 FROM agent_session_attachment_sweeps WHERE upload_id = ?') + .get(uploadId) !== undefined ) } @@ -152,7 +153,10 @@ export function listUploadsBeingSwept(db: Database.Database): string[] { .prepare('SELECT upload_id FROM agent_session_attachment_sweeps') .all() .flatMap((row) => - typeof row === 'object' && row !== null && 'upload_id' in row && typeof row.upload_id === 'string' + typeof row === 'object' && + row !== null && + 'upload_id' in row && + typeof row.upload_id === 'string' ? [row.upload_id] : [] ) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts index 6c8087d06e1b..ccec8315bde4 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -142,9 +142,7 @@ describe('AgentSessionAttachmentStore', () => { }) vi.advanceTimersByTime(5 * 60 * 1000 + 1) vi.useRealTimers() - await vi.waitFor(async () => - expect(await readdir(store.rootDir)).toEqual([]) - ) + await vi.waitFor(async () => expect(await readdir(store.rootDir)).toEqual([])) expect(store.isUploadInFlight(uploadId)).toBe(false) await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('not found') }) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 58e304eff0c6..d16440f98727 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -14,7 +14,7 @@ import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import type { JournalRowTransactionHook } from './journal-row-writer' -import type { JournalSubmissionConsume } from './journal-store-contracts' +import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, @@ -124,21 +124,14 @@ export class JournalQueuedMessages { // gets here, so an existing row is the same accept landing twice. return existing } - const now = this.deps.now() - claimAgentSessionAttachmentsInTransaction(db, { - stateDirectory: this.deps.database().stateDirectory, - sessionId, - body: input.body, - required: requireAttachments === true, - now - }) + this.claimAttachmentsInTransaction(db, input.body, requireAttachments === true) inserted = true const { epoch, lastSequence } = this.deps.state() return insertQueuedMessage(db, { ...draft, sessionId, queuedAt: { epoch, sequence: lastSequence }, - now + now: this.deps.now() }) }, () => inserted @@ -279,6 +272,22 @@ export class JournalQueuedMessages { this.changeRevision++ } + /** Claims the stored chat attachments a message written here names, in the write's own + * transaction; `required` refuses the whole write when one is no longer stored. */ + claimAttachmentsInTransaction( + db: Database.Database, + body: AgentJournalMessageItem, + required: boolean + ): void { + claimAgentSessionAttachmentsInTransaction(db, { + stateDirectory: this.deps.database().stateDirectory, + sessionId: this.deps.sessionId, + body, + required, + now: this.deps.now() + }) + } + /** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */ settlementOwed(): boolean { return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions) @@ -344,13 +353,24 @@ export class JournalQueuedMessages { } } -/** The per-append hook converting one draft inside the append's own transaction. */ -export function queuedMessageConsumeHook( +/** The submission append's hook, inside its transaction: converts the draft it hands off, if any, + * and claims the stored attachments it names. A client's new message must name only attachments + * still stored; a draft's conversion was claimed when the draft was written. */ +export function journalSubmissionHook( queuedMessages: JournalQueuedMessages, - consumedAs: string, - consume: JournalSubmissionConsume + input: Pick, + consume: JournalSubmissionConsume | undefined ): JournalRowTransactionHook { - return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) + return (db) => { + if (consume) { + queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId }) + } + queuedMessages.claimAttachmentsInTransaction( + db, + input.body, + input.origin === 'client' && !consume + ) + } } export class QueuedMessageNotConsumableError extends Error { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 281d862d6f6c..8f1915a7bd4e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -57,8 +57,7 @@ import type { JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' -import { claimAgentSessionAttachmentsInTransaction } from '../agent-session-attachments/agent-session-attachment-claims' +import { journalSubmissionHook, type JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, journalStopEventRowBuilder @@ -343,22 +342,9 @@ export class AgentSessionJournal { * state transition commits in the SAME transaction — exactly-once consume. */ consume?: JournalSubmissionConsume ): Promise { - const consumeHook = - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) return this.rowWriter.append( journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), - (db, row) => { - consumeHook?.(db, row) - // A client's new message must name only attachments still stored; a draft's conversion - // was claimed when the draft was written. - claimAgentSessionAttachmentsInTransaction(db, { - stateDirectory: this.database.stateDirectory, - sessionId: this.identity.sessionId, - body: input.body, - required: input.origin === 'client' && !consume, - now: this.now() - }) - } + journalSubmissionHook(this.queuedMessages, input, consume) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts index c2fa1104be8b..1ba4f111f1f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -13,7 +13,10 @@ import { QUEUED_RIG_CALLER as CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { HOST_TEST_SESSION as SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' const UPLOAD = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' const EXPIRED = { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts index 25205d5ce3bc..59124ec7e72b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -181,7 +181,10 @@ describe('agentSessionAttachment.*', () => { { sessionId: 'session-elsewhere', name: 'a.txt', byteLength: 1 }, CLIENT_A ) - expect(response).toMatchObject({ ok: false, error: { message: expect.stringContaining('not on this host') } }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('not on this host') } + }) }) it('answers a remote preview of an image over 3 MiB with a small refusal, not an oversized reply', async () => { @@ -196,7 +199,12 @@ describe('agentSessionAttachment.*', () => { methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS }) await dispatcher.dispatchStreaming( - { id: 'request-big', authToken: 'token', method: 'agentSessionAttachment.read', params: { path } }, + { + id: 'request-big', + authToken: 'token', + method: 'agentSessionAttachment.read', + params: { path } + }, (raw) => replies.push(raw), { ...CLIENT_A, clientKind: 'runtime' } ) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 21e772c89f33..a16c8492267a 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -82,7 +82,7 @@ describe('NativeChatImageAttachmentPreview', () => { ) }) - it('keeps any other path on the chat\'s usual read route', () => { + it("keeps any other path on the chat's usual read route", () => { mocks.useLocalImageSrc.mockReturnValue(undefined) renderPreview({ id: 'a1', path: '/repo/docs/shot.png' }, 'env-1') diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index efc82cd12f0f..6e0ccdd2ea17 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -442,25 +442,22 @@ describe('useNativeChatComposerAttachments', () => { it('tells a finishing upload that the user removed its chip', async () => { const probe = await renderProbe('pty-1') - let removedId: string | null = null - let keptId: string | null = null + const begun: { removed?: string | null; kept?: string | null } = {} act(() => { - removedId = probe.latest().pendingChips.begin(undefined, 'report.pdf') - keptId = probe.latest().pendingChips.begin(undefined, 'notes.md') + begun.removed = probe.latest().pendingChips.begin(undefined, 'report.pdf') + begun.kept = probe.latest().pendingChips.begin(undefined, 'notes.md') }) - if (!removedId || !keptId) { + const { removed, kept } = begun + if (!removed || !kept) { throw new Error('expected pending chips') } - act(() => probe.latest().removeImageAttachment(removedId as string)) + act(() => probe.latest().removeImageAttachment(removed)) - let removedStillLive = true - let keptStillLive = false + const live: boolean[] = [] act(() => { - removedStillLive = probe.latest().pendingChips.drop(removedId as string) - keptStillLive = probe.latest().pendingChips.drop(keptId as string) + live.push(probe.latest().pendingChips.drop(removed), probe.latest().pendingChips.drop(kept)) }) - expect(removedStillLive).toBe(false) - expect(keptStillLive).toBe(true) + expect(live).toEqual([false, true]) expect(probe.latest().imageAttachments).toEqual([]) act(() => probe.root.unmount()) }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b20c7037d554..b74b90fad05d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2856,6 +2856,7 @@ "agentRefused": "The agent turned this down.", "agentStarting": "The agent is still starting.", "answerFirst": "Answer the question or approval first.", + "attachmentExpired": "This attachment expired.", "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "Orca can't find this chat.", @@ -2882,16 +2883,17 @@ "openCurrentConversation": "Open the current conversation to continue.", "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", - "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "ownerUnproven": "The previous agent in this chat may still be running.", "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", "quitTerminalAgent": "Quit that agent to continue the chat here.", + "reattachFile": "Remove it and attach it again.", "recordFailed": "Orca couldn't save this to the chat's history.", "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", "savedByNewerOrca": "Chats were saved by a newer Orca.", + "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts index 62a34ab39ab5..4b1e6b56af93 100644 --- a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - callEnvironmentEnvelope: vi.fn(), - activeEnvironment: { id: 'env-1', createdAt: 1, pairingRevision: 7 as number | undefined } -})) +type TestEnvironment = { id: string; createdAt: number; pairingRevision?: number } + +const mocks = vi.hoisted(() => { + const activeEnvironment: TestEnvironment = { id: 'env-1', createdAt: 1, pairingRevision: 7 } + return { callEnvironmentEnvelope: vi.fn(), activeEnvironment } +}) vi.mock('./web-runtime-calls', () => ({ callEnvironmentEnvelope: mocks.callEnvironmentEnvelope })) vi.mock('./web-runtime-session', () => ({ diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index 471e45ea0421..9cb4aeaa0939 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -24,7 +24,8 @@ export function isAgentSessionAttachmentStorePath(filePath: string): boolean { } // Windows refuses these as a file's name whatever its extension, and drops trailing dots and spaces. -const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i +const WINDOWS_DEVICE_NAME = + /^(con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i function utf8ByteLength(text: string): number { return new TextEncoder().encode(text).byteLength diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 287f64ccb818..915f2126c1c5 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -98,6 +98,7 @@ function causeWords( } const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const CLEARED = causeWords('conversationCleared', 'goElsewhere', 'openCurrentConversation') const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') @@ -114,11 +115,7 @@ const REASON_WORDS = { operationRefusedEarlier: codeWords('retry'), journalWriteFailed: causeWords('recordFailed', 'retry'), attachmentExpired: causeWords('attachmentExpired', 'actFirst', 'reattachFile'), - conversationCleared: causeWords( - 'conversationCleared', - 'goElsewhere', - 'openCurrentConversation' - ), + conversationCleared: CLEARED, // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), // Only an older host sends this, for a /clear it never settled; only that host resolves it. From 23a1eafbb9b307e7ddb4bc456482c5cdf8dbb546 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:21:05 -0700 Subject: [PATCH 12/40] test(native-chat): pin the attachment store grant in the Claude launch, restore upload result types --- ...laude-structured-launch-resolution.test.ts | 20 +++++++++++++++++-- src/shared/agent-session-attachments.ts | 7 +++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 66fed64c3230..1b2c54a055d9 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -59,7 +59,8 @@ function resolverFor( stripAuthEnv = false, // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, - hasTranscript: () => Promise = async () => true + hasTranscript: () => Promise = async () => true, + attachmentDirectory?: string ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, @@ -68,7 +69,8 @@ function resolverFor( resolveAuthPolicy: () => ({ stripAuthEnv }), resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, - ...(resolveEnv ? { resolveEnv } : {}) + ...(resolveEnv ? { resolveEnv } : {}), + ...(attachmentDirectory ? { attachmentDirectory } : {}) }) } @@ -354,6 +356,20 @@ describe('claude structured launch resolution', () => { } }) + it("lets the agent read the host's chat attachment store, outside the workspace", async () => { + const launch = await resolverFor( + record(), + undefined, + false, + { claude: '' }, + async () => true, + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual(['/state/agent-session-attachments']) + expect(launch.cwd).toBe('/repos/workspace-1') + }) + it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index 9cb4aeaa0939..aded5fbc011b 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -17,6 +17,13 @@ export const AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS = export const AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES = 200 +export type AgentSessionAttachmentUploadStartResult = { uploadId: string } +export type AgentSessionAttachmentUploadCommitResult = { + path: string + name: string + byteLength: number +} + /** Whether `filePath` names a file in some server's attachment store. Only routes a preview read; * the server re-checks the real path before reading anything. */ export function isAgentSessionAttachmentStorePath(filePath: string): boolean { From e3e74a776a8951502ff2c63fbe7958410210ce04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:24:28 -0700 Subject: [PATCH 13/40] fix(native-chat): create the attachment store at install and claim only exact store paths Claude drops an added directory that does not exist when it starts, so the store root is created when the host installs it, best effort. A commit keeps its upload in flight until the rename lands, so a sweep never takes a slow upload's part file. Only a path under this host's exact store root is claimed and required; any other mention of a store path is plain text and never refuses the message. --- .../agent-session-attachment-claims.test.ts | 32 ++++----- .../agent-session-attachment-claims.ts | 15 ++-- .../agent-session-attachment-references.ts | 35 ++++----- .../agent-session-attachment-store.test.ts | 14 ++++ .../agent-session-attachment-store.ts | 5 +- ...agent-session-attachment-admission.test.ts | 8 +++ ...ed-agent-session-attachment-wiring.test.ts | 71 +++++++++++++++++++ ...uctured-agent-session-attachment-wiring.ts | 25 +++++-- 8 files changed, 156 insertions(+), 49 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-attachment-wiring.test.ts diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts index 694281b426ab..1f95711bd345 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -41,18 +41,22 @@ describe('agentSessionAttachmentReferences', () => { ), 'linux' ) - expect([...references.uploadIds].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) - expect(references.foreign).toBe(0) + expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) }) - it("counts another server's store as foreign, never as this host's upload", () => { - const references = agentSessionAttachmentReferences( - root, - message({ type: 'text', text: `@/home/other/agent-session-attachments/${UPLOAD_A}/a.txt` }), - 'linux' - ) - expect(references.uploadIds.size).toBe(0) - expect(references.foreign).toBe(1) + it("leaves a mention of any other store path as text: another server's, a ~ form, a bare name", () => { + const mentions = [ + `@/home/other/agent-session-attachments/${UPLOAD_A}/a.txt`, + `~/Library/Application Support/Orca/agent-session-attachments/${UPLOAD_A}/x.png`, + `see agent-session-attachments/${UPLOAD_A}`, + // This host's root as the tail of a longer path is still another path. + `/mnt${root}/${UPLOAD_A}/a.txt` + ] + for (const text of mentions) { + expect( + agentSessionAttachmentReferences(root, message({ type: 'text', text }), 'linux') + ).toEqual(new Set()) + } }) it('matches a Windows store with either separator and in any case', () => { @@ -68,13 +72,7 @@ describe('agentSessionAttachmentReferences', () => { ), 'win32' ) - expect([...references.uploadIds].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) - }) - - it('ignores a message with no store reference', () => { - expect( - agentSessionAttachmentReferences(root, message({ type: 'text', text: 'hello' }), 'linux') - ).toEqual({ uploadIds: new Set(), foreign: 0 }) + expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) }) }) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts index 5b69f9553740..e71a2ba25e81 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -84,8 +84,8 @@ export type AgentSessionAttachmentClaim = { stateDirectory: string sessionId: string body: AgentJournalMessageItem - /** A client's own new message: every store reference must still be stored, or the whole message - * is refused. The host's own writes (a /clear carry, a draft it sends) never refuse. */ + /** A client's own new message: every upload of this host's store it names must still be stored, + * or the whole message is refused. The host's own writes (a /clear carry, a draft it sends) never refuse. */ required: boolean now: number } @@ -101,25 +101,24 @@ export function claimAgentSessionAttachmentsInTransaction( claim: AgentSessionAttachmentClaim ): void { const root = agentSessionAttachmentStoreRoot(claim.stateDirectory) - const references = agentSessionAttachmentReferences(root, claim.body) - if (references.uploadIds.size === 0 && references.foreign === 0) { + const uploadIds = agentSessionAttachmentReferences(root, claim.body) + if (uploadIds.size === 0) { return } if (claim.required) { if ( - references.foreign > 0 || - [...references.uploadIds].some( + [...uploadIds].some( (uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId) ) ) { throw new AgentSessionAttachmentExpiredError() } - insertClaims(db, references.uploadIds, claim.sessionId, claim.now) + insertClaims(db, uploadIds, claim.sessionId, claim.now) return } db.exec(`SAVEPOINT ${CLAIM_SAVEPOINT}`) try { - insertClaims(db, references.uploadIds, claim.sessionId, claim.now) + insertClaims(db, uploadIds, claim.sessionId, claim.now) db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) } catch (error) { db.exec(`ROLLBACK TO ${CLAIM_SAVEPOINT}`) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts index b71772fc4eb0..f08896ad4a5d 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts @@ -44,12 +44,8 @@ export function parseAgentSessionAttachmentStorePath( return { uploadId, name } } -export type AgentSessionAttachmentReferences = { - /** Uploads in this host's store. */ - uploadIds: Set - /** Store-shaped references whose root is not this host's: another server's, or a stale path. */ - foreign: number -} +// A root preceded by one of these is the tail of some other, longer path. +const PATH_CHARACTER = /[\w.~\\/-]/ function normalizedRoot(root: string, platform: NodeJS.Platform): string { const forward = root.replace(/\\/g, '/') @@ -57,16 +53,18 @@ function normalizedRoot(root: string, platform: NodeJS.Platform): string { } /** - * Every store reference in a message body: image paths and file references in its text alike. - * Matched on the decoded body by `/`, so quoting or a file name with spaces - * cannot hide one; Windows paths match with either separator and in any case. + * The uploads of this host's store a message body names, in image paths and file references in its + * text alike. Only a path that starts with this host's exact store root counts: matched on the + * decoded body by `/`, so quoting or a file name with spaces cannot hide one, + * and Windows paths match with either separator and in any case. Any other mention of a store + * path (another server's, a `~` form, a bare name) is only text. */ export function agentSessionAttachmentReferences( root: string, body: AgentJournalMessageItem, platform: NodeJS.Platform = process.platform -): AgentSessionAttachmentReferences { - const references: AgentSessionAttachmentReferences = { uploadIds: new Set(), foreign: 0 } +): Set { + const uploadIds = new Set() const ownRoot = normalizedRoot(root, platform) const pattern = new RegExp( `${AGENT_SESSION_ATTACHMENTS_DIR_NAME}[\\\\/](${UPLOAD_ID_SOURCE})(?![0-9a-z-])`, @@ -80,14 +78,17 @@ export function agentSessionAttachmentReferences( } for (const match of text.matchAll(pattern)) { const rootEnd = match.index + AGENT_SESSION_ATTACHMENTS_DIR_NAME.length - const prefix = text.slice(Math.max(0, rootEnd - root.length), rootEnd) + const rootStart = rootEnd - root.length const uploadId = match[1]?.toLowerCase() - if (uploadId && normalizedRoot(prefix, platform) === ownRoot) { - references.uploadIds.add(uploadId) - } else { - references.foreign++ + if ( + uploadId && + rootStart >= 0 && + !PATH_CHARACTER.test(text.charAt(rootStart - 1)) && + normalizedRoot(text.slice(rootStart, rootEnd), platform) === ownRoot + ) { + uploadIds.add(uploadId) } } } - return references + return uploadIds } diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts index ccec8315bde4..fc0c57d30b24 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -96,6 +96,20 @@ describe('AgentSessionAttachmentStore', () => { expect(await readdir(store.rootDir)).toEqual([]) }) + it('keeps an upload in flight until its file is renamed into place, so no sweep takes it', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: 'eA==' }) + const committing = store.commitUpload({ callerKey: caller, uploadId }) + expect(store.isUploadInFlight(uploadId)).toBe(true) + await committing + expect(store.isUploadInFlight(uploadId)).toBe(false) + }) + it('stores an empty file', async () => { const stored = await upload('empty.txt', Buffer.alloc(0)) expect((await stat(stored.path)).size).toBe(0) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index 7d783abf026e..59603b1063c2 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -132,7 +132,8 @@ export class AgentSessionAttachmentStore { if (upload.writing) { throw new Error('Attachment chunk is still being written') } - this.uploads.delete(args.uploadId) + // In flight until the rename lands, so the sweep never takes a slow upload's part file. + upload.writing = true try { if (upload.receivedLength !== upload.expectedLength) { throw new Error('Attachment upload is incomplete') @@ -143,6 +144,8 @@ export class AgentSessionAttachmentStore { } catch (error) { await removeQuietly(upload.uploadDir) throw error + } finally { + this.uploads.delete(args.uploadId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts index 1ba4f111f1f6..9228f6f0743f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -106,6 +106,14 @@ describe('attachments at send admission', () => { expect(claimedSessions()).toEqual([SESSION, replacementId].sort()) }) + it('sends text that only mentions another store path, claiming nothing', async () => { + const result = await clientSend( + textWith(`/home/someone/.config/Orca/agent-session-attachments/${UPLOAD}/notes.txt`) + ) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(claimedSessions()).toEqual([]) + }) + it('replays an admitted send under the same id without judging its attachments again', async () => { const path = await storeUpload('shot.png') const body: AgentJournalMessageItem = { diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts new file mode 100644 index 000000000000..8b6dbce639d2 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -0,0 +1,71 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type Database from '../sqlite/sync-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' + +let stateDirectory: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-wiring-')) +}) + +afterEach(async () => { + stopAgentSessionAttachments() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function install(directory = stateDirectory): void { + const records: Pick = { + getRecord: () => null, + listRecordedSessionIds: () => [] + } + // Written by a newer Orca: the sweep reads no claims, so nothing here touches the connection. + const journal: Pick< + JournalHostDatabase, + 'readOnly' | 'isClosed' | 'db' | 'legacyRecordImportOwed' + > = { + readOnly: true, + isClosed: false, + legacyRecordImportOwed: false, + get db(): Database.Database { + throw new Error('not opened in this test') + } + } + installAgentSessionAttachments({ + stateDirectory: directory, + store: records, + journalDatabase: journal, + logger: createStructuredAgentSessionLogger() + }) +} + +describe('installAgentSessionAttachments', () => { + // Claude takes the store as an added directory only if it exists when Claude starts. + it('creates the store root the Claude launch is granted, before any upload', () => { + install() + const root = agentSessionAttachmentStoreRoot(stateDirectory) + expect(existsSync(root)).toBe(true) + expect(getAgentSessionAttachmentStore()?.rootDir).toBe(root) + }) + + it('still installs the store when the root cannot be created', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A file where the state directory should be: nothing can be created under it. + const blocked = join(stateDirectory, 'not-a-directory') + await writeFile(blocked, '') + expect(() => install(blocked)).not.toThrow() + expect(getAgentSessionAttachmentStore()?.rootDir).toBe(agentSessionAttachmentStoreRoot(blocked)) + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts index 226d34ad3d6a..4959455e8f1b 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -1,6 +1,7 @@ // Installs the chat attachment store beside the structured host, and its sweeps. The store lives in // the same state directory as the journal, whose database holds the claims the sweeps honor. +import { mkdirSync } from 'node:fs' import { AgentSessionAttachmentStore, setAgentSessionAttachmentStore @@ -21,15 +22,27 @@ let sweeper: AgentSessionAttachmentSweeper | null = null export function installAgentSessionAttachments(deps: { stateDirectory: string - store: AgentSessionRecordStore - journalDatabase: JournalHostDatabase + store: Pick + journalDatabase: Pick< + JournalHostDatabase, + 'readOnly' | 'isClosed' | 'db' | 'legacyRecordImportOwed' + > logger: StructuredAgentSessionLogger }): void { stopAgentSessionAttachments() - const attachments = new AgentSessionAttachmentStore( - agentSessionAttachmentStoreRoot(deps.stateDirectory), - { hasSession: (sessionId) => deps.store.getRecord(sessionId) !== null } - ) + const root = agentSessionAttachmentStoreRoot(deps.stateDirectory) + // Before any Claude starts: Claude drops an added directory that does not exist yet, for good. + try { + mkdirSync(root, { recursive: true }) + } catch (error) { + deps.logger.warn('chat attachment store could not be created', { + scope: 'attachment-store', + error + }) + } + const attachments = new AgentSessionAttachmentStore(root, { + hasSession: (sessionId) => deps.store.getRecord(sessionId) !== null + }) setAgentSessionAttachmentStore(attachments) sweeper = startAgentSessionAttachmentSweeps( attachments, From c13bbd8574478ec85df5972297a508508cdf56b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:29:07 -0700 Subject: [PATCH 14/40] refactor(native-chat): reuse the newer-Orca notice and drop leftover attach options An older server's refusal to store attachments uses the notice every other write it refuses already shows, so one string fewer in every catalog; the attach callbacks lose an options parameter no caller passes since the provenance check went. --- .../native-chat-attachment-upload.test.ts | 2 +- .../native-chat-attachment-upload.ts | 17 +++++++++-------- .../native-chat-session-attachment-drop.ts | 7 +------ .../use-native-chat-composer-paste.ts | 11 ++--------- .../use-native-chat-external-attachments.ts | 11 ++--------- src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - 11 files changed, 15 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index 76e9be071a2c..b565548245e0 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -288,7 +288,7 @@ describe('a structured chat on a paired server', () => { await expect(prepareNativeChatSessionAttachmentUpload(owner)).resolves.toEqual({ ok: false, notice: - 'Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.' + 'This needs a newer Orca on the computer running this chat. Update Orca there, then try again.' }) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 075b9ee913ce..e58e453cdd45 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -13,6 +13,7 @@ import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-rev import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionAttachmentPathUploadResult, AgentSessionAttachmentUploadTarget @@ -167,13 +168,6 @@ export function nativeChatAttachFailedNotice(names: readonly string[]): string { }) } -export function nativeChatAttachmentsNeedNewerServerNotice(): string { - return translate( - 'components.native-chat.composer.attachmentsNeedNewerServer', - 'Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.' - ) -} - export function nativeChatLocalAttachmentUnsupportedNotice(): string { return translate( 'components.native-chat.composer.localAttachmentUnsupported', @@ -234,7 +228,14 @@ export async function prepareNativeChatSessionAttachmentUpload( { timeoutMs: 15_000, expectedEnvironmentPairingRevision: owner.pairingRevision } ) if (!status.capabilities?.includes(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY)) { - return { ok: false, notice: nativeChatAttachmentsNeedNewerServerNotice() } + // The same words as any write a server too old for it refuses. + return { + ok: false, + notice: translate( + 'components.native-chat.writeNotice.unsupported', + AGENT_SESSION_WRITE_NOTICE_COPY.unsupported + ) + } } return { ok: true, diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index c69e48824528..6036228265e8 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -7,7 +7,6 @@ import { uploadNativeChatSessionAttachmentPaths, type NativeChatRuntimeSessionAttachmentOwner } from './native-chat-attachment-upload' -import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' /** The composer's pending-chip controls, as an upload drives them. */ export type NativeChatPendingAttachmentChips = { @@ -30,11 +29,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { /** The composer was disabled or torn down meanwhile. */ isAbandoned: () => boolean ownerStillCurrent: () => boolean - attachResolvedPaths: ( - paths: string[], - connectionId?: string | null, - options?: NativeChatResolvedPathOptions - ) => void + attachResolvedPaths: (paths: string[], connectionId?: string | null) => void setNotice: (notice: string | null) => void }): Promise { const pending = args.paths.flatMap((path) => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 7bb8747d3533..27ae4b1195b0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -1,8 +1,5 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' -import { - nativeChatAttachmentOwnerUnchanged, - type NativeChatResolvedPathOptions -} from './native-chat-resolved-path-ownership' +import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text' import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { AgentType } from '../../../../shared/agent-status-types' @@ -34,11 +31,7 @@ export type UseNativeChatComposerPasteArgs = { /** Resolved at paste time: SSH panes must save the clipboard image on the * remote host, or the attached path names a file the agent cannot read. */ resolveAttachmentOwner: () => NativeChatAttachmentOwner - attachResolvedPaths: ( - paths: string[], - connectionId?: string | null, - options?: NativeChatResolvedPathOptions - ) => void + attachResolvedPaths: (paths: string[], connectionId?: string | null) => void beginPendingImageAttachment: (previewUrl?: string) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void dropPendingImageAttachment: (id: string) => void diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 50b5949b2eb3..f157c73a166e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -1,9 +1,6 @@ import { useCallback, useLayoutEffect, useRef } from 'react' import { useAppStore } from '@/store' -import { - nativeChatAttachmentOwnerUnchanged, - type NativeChatResolvedPathOptions -} from './native-chat-resolved-path-ownership' +import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' import { nativeChatAttachmentOwnerChangedNotice, nativeChatAttachmentUnreadableNotice, @@ -29,11 +26,7 @@ export type UseNativeChatExternalAttachmentsArgs = { /** Live composer-disabled state; read at await-resume via a ref so a flip * mid-upload doesn't attach into a guarded composer. */ disabled: boolean - attachResolvedPaths: ( - paths: string[], - connectionId?: string | null, - options?: NativeChatResolvedPathOptions - ) => void + attachResolvedPaths: (paths: string[], connectionId?: string | null) => void /** Chips shown while a file uploads, so Send waits for it. */ pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fe02b5807c95..ddd8e930a3de 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17765,7 +17765,6 @@ "skillScopeBuiltIn": "Built-in", "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", - "attachmentsNeedNewerServer": "Attaching files to this chat needs a newer Orca on the server. Update Orca on that computer, then try again.", "attachFailed": "Couldn't attach {{files}}.", "uploadingFile": "Uploading {{name}}…", "removeAttachment": "Remove attachment", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 955131046cde..fc3a77b97bf6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15084,7 +15084,6 @@ "stopDictation": "Detener dictado", "noSkills": "Sin habilidades coincidentes", "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", - "attachmentsNeedNewerServer": "Para adjuntar archivos a este chat, el servidor necesita una versión más reciente de Orca. Actualiza Orca en ese equipo y vuelve a intentarlo.", "attachFailed": "No se pudo adjuntar {{files}}.", "uploadingFile": "Subiendo {{name}}…", "removeAttachment": "Quitar archivo adjunto", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 429b59fb7178..d4a205cc6759 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17568,7 +17568,6 @@ "skillScopeBuiltIn": "Intégrée", "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", - "attachmentsNeedNewerServer": "Pour joindre des fichiers à ce chat, le serveur doit utiliser une version plus récente d’Orca. Mettez à jour Orca sur cet ordinateur, puis réessayez.", "attachFailed": "Impossible de joindre {{files}}.", "uploadingFile": "Envoi de {{name}}…", "removeAttachment": "Retirer la pièce jointe", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 55f126d48d18..21d0ba1894b4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17490,7 +17490,6 @@ "stopDictation": "ディクテーションを停止する", "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", - "attachmentsNeedNewerServer": "このチャットにファイルを添付するには、サーバーの Orca を新しいバージョンにする必要があります。そのコンピューターの Orca を更新してから、もう一度お試しください。", "attachFailed": "{{files}} を添付できませんでした。", "uploadingFile": "{{name}} をアップロードしています…", "removeAttachment": "添付ファイルを削除する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d6e4ca34e39c..c45091def681 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17490,7 +17490,6 @@ "stopDictation": "받아쓰기 중지", "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", - "attachmentsNeedNewerServer": "이 채팅에 파일을 첨부하려면 서버의 Orca를 최신 버전으로 업데이트해야 합니다. 해당 컴퓨터에서 Orca를 업데이트한 후 다시 시도하세요.", "attachFailed": "{{files}}을(를) 첨부할 수 없습니다.", "uploadingFile": "{{name}} 업로드 중…", "removeAttachment": "첨부파일 삭제", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8a4638ccdee..1479a4dfccc1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17507,7 +17507,6 @@ "stopDictation": "停止听写", "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", - "attachmentsNeedNewerServer": "要向此聊天附加文件,服务器上的 Orca 需要更新到较新版本。请在那台电脑上更新 Orca,然后重试。", "attachFailed": "无法附加 {{files}}。", "uploadingFile": "正在上传 {{name}}…", "removeAttachment": "移除附件", From 7933ea7a472cbe4ec5679f3418e2e950500b3ef1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:33:15 -0700 Subject: [PATCH 15/40] fix(native-chat): give a message refused for an expired attachment back to the composer Sending the same message again can never bring the attachment back, so instead of a Retry that always fails the message's text and images return to the composer, as a Stop's withdrawn message does, and the notice says what to remove. --- ...session-withdrawn-message-restore.test.tsx | 44 ++++++++++ ...agent-session-withdrawn-message-restore.ts | 83 ++++++++++++++----- .../use-structured-agent-session-outbox.ts | 5 +- 3 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 150b520bcd32..e669adf087bb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -473,3 +473,47 @@ describe('an open composer', () => { ]) }) }) + +describe('a message the host refused for an attachment it no longer stores', () => { + const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/shot.png' + + function refuseSendsAsExpired(): void { + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.send' + ? { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'not stored', + details: { reason: 'attachmentExpired' } + } + } + : null + ) + } + + it('comes back to the composer with its images, saying why, instead of waiting on a Retry', async () => { + refuseSendsAsExpired() + const { result } = renderOutbox() + act(() => + expect(result.current.send('look at this', [{ path: STORED, previewUri: STORED }])).toBe(true) + ) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readOutbox(SESSION)).toEqual([]) + expect(readNativeChatDraftCache(PANE)).toBe('look at this') + expect(readNativeChatAttachmentCache(PANE)).toEqual([{ id: expect.any(String), path: STORED }]) + expect(result.current.error).toBe( + 'This attachment expired. Your message was not sent. Remove it and attach it again.' + ) + }) + + it('stays on its Retry where no composer shows the chat, so nothing is lost', async () => { + refuseSendsAsExpired() + const { result } = renderOutbox(null) + act(() => expect(result.current.send('look at this')).toBe(true)) + + await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) + expect(result.current.outbox).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 40e69565ef07..a5275af6b415 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -5,6 +5,44 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +import type { StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' +import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' +import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' + +/** The host refused the message for an attachment it no longer stores: sending the same message + * again can never succeed, so only the sender can fix it. */ +function attachmentExpiredRefusal( + entry: StructuredAgentSessionOutboxEntry +): AgentSessionWriteRefusal | null { + const failure = entry.lastFailure + return failure?.kind === 'refused' && + failure.code === 'agent_session_operation_invalid' && + failure.details?.reason === 'attachmentExpired' + ? failure + : null +} + +/** Puts each message's text and images into the composer, after whatever is there. */ +function giveBackToComposer( + composerScopeKey: string, + entries: readonly StructuredAgentSessionOutboxEntry[] +): void { + for (const entry of entries) { + const blocks = entry.body.blocks + appendNativeChatDraftCache( + composerScopeKey, + blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') + ) + appendNativeChatAttachmentCache( + composerScopeKey, + blocks.flatMap((block, index) => + block.type === 'image-ref' && block.path + ? [{ id: `withdrawn-${entry.clientMessageId}-${index}`, path: block.path }] + : [] + ) + ) + } +} /** * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, @@ -24,24 +62,10 @@ function restoreWithdrawnMessages( const held = new Set( getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) ) - for (const entry of withdrawn) { - if (!held.has(entry.clientMessageId)) { - continue - } - const blocks = entry.body.blocks - appendNativeChatDraftCache( - composerScopeKey, - blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') - ) - appendNativeChatAttachmentCache( - composerScopeKey, - blocks.flatMap((block, index) => - block.type === 'image-ref' && block.path - ? [{ id: `withdrawn-${entry.clientMessageId}-${index}`, path: block.path }] - : [] - ) - ) - } + giveBackToComposer( + composerScopeKey, + withdrawn.filter((entry) => held.has(entry.clientMessageId)) + ) } export function useStructuredAgentSessionWithdrawnRestore( @@ -56,6 +80,12 @@ export function useStructuredAgentSessionWithdrawnRestore( ) => void /** Entries a Stop took out of the outbox here, before the host held them. */ byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void + /** A send's outcome, with any message refused for an expired attachment taken back out and + * returned to the composer, where the attachment can be removed, instead of held for a Retry + * that cannot succeed. Kept for Retry where no composer shows this session. */ + byRefusal: ( + disposition: StructuredAgentSessionSendDisposition + ) => StructuredAgentSessionSendDisposition } { return useMemo( () => ({ @@ -76,7 +106,22 @@ export function useStructuredAgentSessionWithdrawnRestore( entries.filter((entry) => withdrawn.has(entry.clientMessageId)) ) }, - byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) + byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries), + byRefusal: (disposition) => { + const returned = disposition.entries.filter( + (entry) => attachmentExpiredRefusal(entry) !== null + ) + const refusal = returned[0] && attachmentExpiredRefusal(returned[0]) + if (!composerScopeKey || !refusal) { + return disposition + } + // Only the send's own view applies its outcome, so nothing else gives these back. + giveBackToComposer(composerScopeKey, returned) + return { + entries: disposition.entries.filter((entry) => !returned.includes(entry)), + error: agentSessionWriteNoticeParts(refusal, 'send') + } + } }), [composerScopeKey, sessionId] ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 28bf17b8aa78..d408526c3b99 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -178,7 +178,8 @@ export function useStructuredAgentSessionOutbox(args: { // The one place that owns the refs, the React state and the storage write. const applyDisposition = useCallback( - (disposition: StructuredAgentSessionSendDisposition): void => { + (outcome: StructuredAgentSessionSendDisposition): void => { + const disposition = restoreWithdrawn.byRefusal(outcome) // Released here rather than in a `.finally`: the state write below is what re-runs the // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null @@ -186,7 +187,7 @@ export function useStructuredAgentSessionOutbox(args: { recordFailures(getStructuredAgentSessionOutbox(sessionId), disposition.entries) commitStructuredAgentSessionOutbox(sessionId, disposition.entries) }, - [recordFailures, sessionId] + [recordFailures, restoreWithdrawn, sessionId] ) const [drains, setDrains] = useState(0) From 760666710c84b67907556679f4b8fb4dda61f5cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:38:34 -0700 Subject: [PATCH 16/40] fix(native-chat): keep uploads across a prompt, say why a file did not attach An upload that finishes while a prompt card has replaced the composer lands in the scope's attachment and draft caches, which the composer reads back when it returns, instead of the unmounted composer. The single failure notice names the cause the files share, such as the size limit. Rich text pasted into a chat on an older server no longer flashes an image chip: the chip waits for the server to take the image. A picked command waits, as Send does, while an attachment is still uploading. --- .../native-chat-attachment-upload.ts | 19 +++++-- .../native-chat-clipboard-image-save.ts | 7 ++- .../native-chat-session-attachment-drop.ts | 45 +++++++++++----- ...-native-chat-composer-attachments.test.tsx | 34 ++++++++++++ .../use-native-chat-composer-attachments.ts | 19 +++---- ...omposer-paste-session-attachments.test.tsx | 10 ++-- .../use-native-chat-composer-paste.ts | 38 ++++++++++---- ...-native-chat-external-attachments.test.tsx | 47 ++++++++++------- .../use-native-chat-external-attachments.ts | 1 - ...se-native-chat-pending-attachment-chips.ts | 52 +++++++++++++++++++ ...ive-chat-structured-composer-send.test.tsx | 16 ++++++ ...se-native-chat-structured-composer-send.ts | 3 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 18 files changed, 233 insertions(+), 64 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index e58e453cdd45..1529a20a5f7b 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -161,11 +161,20 @@ export function nativeChatAttachmentUnreadableNotice(): string { ) } -/** One notice for every file of a drop or pick that did not attach, by name. */ -export function nativeChatAttachFailedNotice(names: readonly string[]): string { - return translate('components.native-chat.composer.attachFailed', "Couldn't attach {{files}}.", { - files: names.join(', ') - }) +/** One notice for every file of a drop or pick that did not attach, by name, with the cause they + * share when there is one. */ +export function nativeChatAttachFailedNotice(names: readonly string[], cause = ''): string { + const files = names.join(', ') + const reason = cause.trim().replace(/[^.!?]$/, '$&.') + return reason + ? translate( + 'components.native-chat.composer.attachFailedBecause', + "Couldn't attach {{files}}. {{reason}}", + { files, reason } + ) + : translate('components.native-chat.composer.attachFailed', "Couldn't attach {{files}}.", { + files + }) } export function nativeChatLocalAttachmentUnsupportedNotice(): string { diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts index 123bc545d632..f6e9e8184188 100644 --- a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -46,7 +46,11 @@ export type NativeChatClipboardImageFailureCause = 'serverTooOld' | 'failed' /** Save the clipboard image where the owner's agent can read it. Every failure is reported. */ export async function saveNativeChatClipboardImage( owner: NativeChatAttachmentOwner, - report: { setNotice: (notice: string, cause: NativeChatClipboardImageFailureCause) => void } + report: { + setNotice: (notice: string, cause: NativeChatClipboardImageFailureCause) => void + /** The image has somewhere to go, now that any server it goes to has said it takes one. */ + ready?: () => void + } ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> { if (!ownerAcceptsClipboardImage(owner)) { report.setNotice(nativeChatLocalAttachmentUnsupportedNotice(), 'failed') @@ -60,6 +64,7 @@ export async function saveNativeChatClipboardImage( report.setNotice(target.notice, target.cause) return { status: 'failed' } } + report.ready?.() const tempPath = await window.api.ui.saveClipboardImageAsTempFile(target.args) return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } } catch (error) { diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 6036228265e8..15cd75b2baa9 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -1,4 +1,6 @@ import { basename } from '@/lib/path' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { describeDropSkipReason } from '@/lib/drop-skip-reason-copy' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' import { nativeChatAttachFailedNotice, @@ -14,6 +16,8 @@ export type NativeChatPendingAttachmentChips = { resolve: (id: string, path: string, connectionId?: string | null) => void /** Removes the chip; false when the user already removed it, so its file must not attach. */ drop: (id: string) => boolean + /** Inserts stored files as `@path` references, into the draft wherever the composer is now. */ + attachReferences: (paths: string[]) => void } /** @@ -29,7 +33,6 @@ export async function attachNativeChatSessionAttachmentPaths(args: { /** The composer was disabled or torn down meanwhile. */ isAbandoned: () => boolean ownerStillCurrent: () => boolean - attachResolvedPaths: (paths: string[], connectionId?: string | null) => void setNotice: (notice: string | null) => void }): Promise { const pending = args.paths.flatMap((path) => { @@ -45,11 +48,8 @@ export async function attachNativeChatSessionAttachmentPaths(args: { args.chips.drop(chipId) } } - const failAll = (): void => { - dropAll() - args.setNotice(nativeChatAttachFailedNotice(pending.map(({ path }) => basename(path)))) - } let stored: Map + let reasons: Map try { const prepared = await prepareNativeChatSessionAttachmentUpload(args.owner) if (!prepared.ok) { @@ -62,11 +62,18 @@ export async function attachNativeChatSessionAttachmentPaths(args: { prepared.target ) stored = new Map(result.uploaded.map(({ sourcePath, path }) => [sourcePath, path])) - } catch { - if (!args.isAbandoned()) { - failAll() - } - return + reasons = new Map([ + ...result.skipped.map(({ sourcePath, reason }): [string, string] => [ + sourcePath, + describeDropSkipReason(reason) ?? reason + ]), + ...result.failed.map(({ sourcePath, reason }): [string, string] => [sourcePath, reason]) + ]) + } catch (error) { + // Every file failed for the same cause. + stored = new Map() + const cause = extractIpcErrorMessage(error, '') + reasons = new Map(pending.map(({ path }) => [path, cause])) } if (args.isAbandoned()) { dropAll() @@ -77,13 +84,13 @@ export async function attachNativeChatSessionAttachmentPaths(args: { args.setNotice(nativeChatAttachmentOwnerChangedNotice()) return } - const notAttached: string[] = [] + const notAttached: { name: string; reason: string }[] = [] const references: string[] = [] for (const { path, chipId } of pending) { const storedPath = stored.get(path) if (!storedPath) { if (args.chips.drop(chipId)) { - notAttached.push(basename(path)) + notAttached.push({ name: basename(path), reason: reasons.get(path) ?? '' }) } continue } @@ -97,8 +104,18 @@ export async function attachNativeChatSessionAttachmentPaths(args: { } } // Attaching clears the notice, so what failed is said after. - args.attachResolvedPaths(references, null) + if (references.length > 0) { + args.chips.attachReferences(references) + } if (notAttached.length > 0) { - args.setNotice(nativeChatAttachFailedNotice(notAttached)) + // A cause every file shares is said once, after their names. + const [first] = notAttached + const shared = notAttached.every(({ reason }) => reason === first?.reason) ? first?.reason : '' + args.setNotice( + nativeChatAttachFailedNotice( + notAttached.map(({ name }) => name), + shared + ) + ) } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 6e0ccdd2ea17..2ecb93f18781 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -8,6 +8,7 @@ import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import type { NativeChatResolvedTarget } from './native-chat-composer-target' +import { readNativeChatDraftCache } from './native-chat-draft-cache' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' vi.mock('@/i18n/i18n', () => ({ @@ -462,6 +463,39 @@ describe('useNativeChatComposerAttachments', () => { act(() => probe.root.unmount()) }) + it('keeps an upload that finishes while the composer is unmounted, for when it returns', async () => { + const probe = await renderProbe('pty-gone') + const chips = probe.latest().pendingChips + const begun: { image?: string | null; removed?: string | null } = {} + act(() => { + begun.image = chips.begin(undefined, 'shot.png') + begun.removed = chips.begin(undefined, 'old.png') + }) + const { image, removed } = begun + if (!image || !removed) { + throw new Error('expected pending chips') + } + act(() => probe.latest().removeImageAttachment(removed)) + // A prompt card took the composer's place while the files uploaded. + act(() => probe.root.unmount()) + + chips.resolve(image, '/srv/agent-session-attachments/u1/shot.png') + chips.resolve(removed, '/srv/agent-session-attachments/u2/old.png') + chips.attachReferences(['/srv/agent-session-attachments/u3/notes.pdf']) + + expect(readNativeChatAttachmentCache('pty-gone')).toEqual([ + { id: image, path: '/srv/agent-session-attachments/u1/shot.png' } + ]) + expect(readNativeChatDraftCache('pty-gone')).toBe( + '@/srv/agent-session-attachments/u3/notes.pdf' + ) + const back = await renderProbe('pty-gone') + expect(back.latest().imageAttachments).toMatchObject([ + { path: '/srv/agent-session-attachments/u1/shot.png' } + ]) + act(() => back.root.unmount()) + }) + it('excludes a pending chip from the scope cache while a settled chip persists', async () => { const probe = await renderProbe('pty-1') let pendingId: string | null = null diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 7def0d65710f..8644579a509b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,5 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget @@ -9,6 +9,7 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' import { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-attachment-upload' +import { useNativeChatPendingAttachmentChips } from './use-native-chat-pending-attachment-chips' import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' export type UseNativeChatComposerAttachmentsArgs = { @@ -190,14 +191,14 @@ export function useNativeChatComposerAttachments({ [updateImageAttachments] ) - const pendingChips = useMemo( - () => ({ - begin: beginPendingImageAttachment, - resolve: resolvePendingImageAttachment, - drop: dropPendingImageAttachment - }), - [beginPendingImageAttachment, dropPendingImageAttachment, resolvePendingImageAttachment] - ) + const pendingChips = useNativeChatPendingAttachmentChips({ + scopeKey: attachmentScopeKey, + livePendingChipIds, + begin: beginPendingImageAttachment, + resolve: resolvePendingImageAttachment, + drop: dropPendingImageAttachment, + attachResolvedPaths + }) return { pendingChips, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx index 4874b227621b..f23805a05a19 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -73,7 +73,7 @@ async function renderPaste(args: { attachResolvedPaths?: (...args: unknown[]) => void insertTypedText?: (text: string) => boolean setNotice?: (notice: string | null) => void -}): Promise<{ api: () => HookApi; chips: Chip[] }> { +}): Promise<{ api: () => HookApi; chips: Chip[]; begun: () => number }> { const chips: Chip[] = [] let counter = 0 let api: HookApi | null = null @@ -119,7 +119,8 @@ async function renderPaste(args: { } return api }, - chips + chips, + begun: () => counter } } @@ -163,7 +164,7 @@ describe('pasting into a structured chat on a paired server', () => { expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) }) - it('keeps the image of a paste that also carries text', async () => { + it('keeps the image of a paste that also carries text, its chip shown once the server takes it', async () => { const insertTypedText = vi.fn(() => true) const probe = await renderPaste({ insertTypedText }) await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) @@ -211,7 +212,8 @@ describe('pasting into a structured chat on a paired server', () => { await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) expect(insertTypedText).toHaveBeenCalledWith('caption') expect(setNotice).not.toHaveBeenCalledWith('needs newer server') - expect(probe.chips).toHaveLength(0) + // No chip ever showed, so none flashed and vanished. + expect(probe.begun()).toBe(0) }) it('pastes rich text from the button into a chat on an older server without a refusal', async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 27ae4b1195b0..4a15267728fd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -90,8 +90,13 @@ export function useNativeChatComposerPaste({ // Image failures do not decide whether text can be inserted. Beside pasted text, a server too old // to store the image drops only the image rendition, as an owner that takes no images does. const saveClipboardImageForOwner = useCallback( - (owner: NativeChatAttachmentOwner, besidePastedText: () => Promise) => + ( + owner: NativeChatAttachmentOwner, + besidePastedText: () => Promise, + ready?: () => void + ) => saveNativeChatClipboardImage(owner, { + ready, setNotice: (notice, cause) => { void (cause === 'serverTooOld' ? besidePastedText() : Promise.resolve(false)).then( (quiet) => { @@ -180,18 +185,31 @@ export function useNativeChatComposerPaste({ const caretAtPaste = caret // The clipboard blob is already in this process, so the chip can show the // real image on the same tick the paste happens — no round-trip at all. - const previewUrl = ownerAcceptsClipboardImage(owner) - ? URL.createObjectURL(imageFile) - : undefined - const pendingId = previewUrl ? beginPendingImageAttachment(previewUrl) : null - if (previewUrl && !pendingId) { - URL.revokeObjectURL(previewUrl) + let pendingId: string | null = null + const showChip = (): void => { + if (!ownerAcceptsClipboardImage(owner) || !canPaste()) { + return + } + const previewUrl = URL.createObjectURL(imageFile) + pendingId = beginPendingImageAttachment(previewUrl) + if (pendingId) { + lifetime.pending.set(pendingId, previewUrl) + } else { + URL.revokeObjectURL(previewUrl) + } } - if (pendingId) { - lifetime.pending.set(pendingId, previewUrl ?? '') + // Beside pasted text, a server too old to store the image drops it quietly, so its chip waits + // for the server's answer instead of flashing. + const awaitServer = Boolean(text) && owner.kind === 'runtime-session' + if (!awaitServer) { + showChip() } void (async () => { - const saved = await saveClipboardImageForOwner(owner, async () => Boolean(text)) + const saved = await saveClipboardImageForOwner( + owner, + async () => Boolean(text), + awaitServer ? showChip : undefined + ) if (saved.status !== 'saved' || !canPaste()) { if (pendingId) { lifetime.pending.delete(pendingId) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 84cac18c7b0e..7aa757f1f61d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -49,7 +49,12 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { } function noPendingChips(): NativeChatPendingAttachmentChips { - return { begin: vi.fn(() => null), resolve: vi.fn(), drop: vi.fn(() => true) } + return { + begin: vi.fn(() => null), + resolve: vi.fn(), + drop: vi.fn(() => true), + attachReferences: vi.fn() + } } function Probe({ @@ -486,7 +491,8 @@ describe('useNativeChatExternalAttachments', () => { return `chip-${begun.length}` }), resolve: vi.fn(), - drop: vi.fn((id: string) => !removed.has(id)) + drop: vi.fn((id: string) => !removed.has(id)), + attachReferences: vi.fn() } } @@ -497,7 +503,10 @@ describe('useNativeChatExternalAttachments', () => { return { uploaded: pairs.map(([sourcePath, path]) => ({ sourcePath, path })), skipped: [], - failed: failed.map((sourcePath) => ({ sourcePath, reason: 'upload failed' })) + failed: failed.map((sourcePath) => ({ + sourcePath, + reason: `'${sourcePath.split('/').at(-1)}' is 60 MB, over the 50 MB chat attachment limit` + })) } } @@ -544,10 +553,9 @@ describe('useNativeChatExternalAttachments', () => { null ) expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') - expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( - ['/srv/agent-session-attachments/u2/notes.md'], - null - ) + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() }) @@ -574,10 +582,9 @@ describe('useNativeChatExternalAttachments', () => { ) ) - expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( - ['/srv/agent-session-attachments/u2/notes.md'], - null - ) + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) }) it('names, in one notice, each file that did not attach', async () => { @@ -603,11 +610,13 @@ describe('useNativeChatExternalAttachments', () => { ) expect(chips.drop).toHaveBeenCalledWith('chip-1') - expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith( - ['/srv/agent-session-attachments/u2/notes.md'], - null - ) - expect(notices).toEqual(["Couldn't attach huge.mov."]) + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) + // The cause, the size limit here, rides in the same notice. + expect(notices).toEqual([ + "Couldn't attach huge.mov. 'huge.mov' is 60 MB, over the 50 MB chat attachment limit." + ]) expect(mocks.toastLoading).not.toHaveBeenCalled() }) @@ -631,7 +640,7 @@ describe('useNativeChatExternalAttachments', () => { expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled() expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') - expect(attachResolvedPaths).not.toHaveBeenCalled() + expect(chips.attachReferences).not.toHaveBeenCalled() expect(notices.at(-1)).toBe('needs newer server') }) @@ -655,8 +664,8 @@ describe('useNativeChatExternalAttachments', () => { expect(chips.resolve).not.toHaveBeenCalled() expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') - expect(attachResolvedPaths).not.toHaveBeenCalled() - expect(notices).toEqual(["Couldn't attach shot.png."]) + expect(chips.attachReferences).not.toHaveBeenCalled() + expect(notices).toEqual(["Couldn't attach shot.png. Runtime environment changed."]) }) it('keeps a local structured chat on the worktree owner', async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index f157c73a166e..aa63aa0f6b68 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -135,7 +135,6 @@ export function useNativeChatExternalAttachments({ chips: pendingChipsRef.current, isAbandoned: () => disabledRef.current, ownerStillCurrent, - attachResolvedPaths, setNotice }) return diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts new file mode 100644 index 000000000000..3e3df9f8abf9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts @@ -0,0 +1,52 @@ +import { useEffect, useMemo, useRef, type RefObject } from 'react' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' + +/** + * The pending-chip controls an upload drives. A prompt card unmounts the composer; an upload that + * finishes meanwhile lands in the scope's attachment and draft caches, which the composer reads + * back when it returns, instead of in the unmounted one's state. + */ +export function useNativeChatPendingAttachmentChips(args: { + scopeKey: string + /** Chips the user has not removed. */ + livePendingChipIds: RefObject> + begin: NativeChatPendingAttachmentChips['begin'] + resolve: NativeChatPendingAttachmentChips['resolve'] + drop: NativeChatPendingAttachmentChips['drop'] + attachResolvedPaths: (paths: string[], connectionId?: string | null) => void +}): NativeChatPendingAttachmentChips { + const { scopeKey, livePendingChipIds, begin, resolve, drop, attachResolvedPaths } = args + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + return useMemo( + () => ({ + begin, + resolve: (id, path, connectionId) => { + if (mountedRef.current) { + resolve(id, path, connectionId) + } else if (livePendingChipIds.current.delete(id)) { + appendNativeChatAttachmentCache(scopeKey, [ + { id, path, ...(connectionId ? { connectionId } : {}) } + ]) + } + }, + drop, + attachReferences: (paths) => { + if (mountedRef.current) { + attachResolvedPaths(paths, null) + } else { + appendNativeChatDraftCache(scopeKey, paths.map(formatNativeChatFileReference).join(' ')) + } + } + }), + [attachResolvedPaths, begin, drop, livePendingChipIds, resolve, scopeKey] + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 39892a49219f..af65564cbdfd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -97,3 +97,19 @@ describe('attachment guard follows what the host claims', () => { expect(structuredTransport.send).not.toHaveBeenCalled() }) }) + +describe('an attachment still uploading', () => { + it('holds a picked command, as Send is held, rather than send the chip without its path', async () => { + const { send, structuredTransport } = harness('claude') + const pending: NativeChatComposerImageAttachment = { + id: 'p1', + path: '', + pending: true, + pendingName: 'notes.pdf' + } + send('/review', [pending]) + await Promise.resolve() + expect(structuredTransport.send).not.toHaveBeenCalled() + expect(structuredTransport.onError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index f208e6cb4a93..4bb1b1de30d6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -45,7 +45,8 @@ export function useNativeChatStructuredComposerSend({ }, [draft, imageAttachments]) return useCallback( (text: string, attachments = imageAttachments): void => { - if (!structuredTransport) { + // A picked command is held like Send: it would carry attachments still uploading, pathless. + if (!structuredTransport || attachments.some((attachment) => attachment.pending)) { return } const hostCommand = diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ddd8e930a3de..c4b9ab7c59da 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17766,6 +17766,7 @@ "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "attachFailed": "Couldn't attach {{files}}.", + "attachFailedBecause": "Couldn't attach {{files}}. {{reason}}", "uploadingFile": "Uploading {{name}}…", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fc3a77b97bf6..eb11f7dbb11c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15085,6 +15085,7 @@ "noSkills": "Sin habilidades coincidentes", "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "attachFailed": "No se pudo adjuntar {{files}}.", + "attachFailedBecause": "No se pudo adjuntar {{files}}. {{reason}}", "uploadingFile": "Subiendo {{name}}…", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d4a205cc6759..70778f616d2c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17569,6 +17569,7 @@ "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", "attachFailed": "Impossible de joindre {{files}}.", + "attachFailedBecause": "Impossible de joindre {{files}}. {{reason}}", "uploadingFile": "Envoi de {{name}}…", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 21d0ba1894b4..5e2083eb5090 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17491,6 +17491,7 @@ "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", "attachFailed": "{{files}} を添付できませんでした。", + "attachFailedBecause": "{{files}} を添付できませんでした。{{reason}}", "uploadingFile": "{{name}} をアップロードしています…", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c45091def681..518a0da9fd7e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17491,6 +17491,7 @@ "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "attachFailed": "{{files}}을(를) 첨부할 수 없습니다.", + "attachFailedBecause": "{{files}}을(를) 첨부할 수 없습니다. {{reason}}", "uploadingFile": "{{name}} 업로드 중…", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1479a4dfccc1..e3697e862e5d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17508,6 +17508,7 @@ "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", "attachFailed": "无法附加 {{files}}。", + "attachFailedBecause": "无法附加 {{files}}。{{reason}}", "uploadingFile": "正在上传 {{name}}…", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", From a6ff388c1fb09de7dc5b2deb99a47cb3890eb095 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:58:48 -0700 Subject: [PATCH 17/40] fix(native-chat): keep a paste's upload across a prompt; pin the Claude grant hand-off A paste uploading into a paired server's store keeps its chip live when a prompt card unmounts the composer, and its result is filed for the composer's return, as a drop's is. A failure cause ending in full-width punctuation gets no extra full stop. A runtime test pins that the store root reaches Claude's launch resolver through the adapter. --- ...structured-claude-attachment-grant.test.ts | 78 +++++++++++++++++++ .../native-chat/NativeChatComposer.test.tsx | 3 +- .../native-chat/NativeChatComposer.tsx | 4 +- .../native-chat-attachment-upload.test.ts | 19 ++++- .../native-chat-attachment-upload.ts | 2 +- ...omposer-paste-session-attachments.test.tsx | 45 +++++++++++ .../use-native-chat-composer-paste.ts | 43 +++++----- .../use-native-chat-paste-lifetime.ts | 76 ++++++++++++++++++ 8 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 src/main/runtime/structured-claude-attachment-grant.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts new file mode 100644 index 000000000000..a2fe0ee5f9d1 --- /dev/null +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -0,0 +1,78 @@ +// The chat attachment store reaches Claude's launch as an added directory: the runtime hands its +// root to the Claude adapter, and the adapter to the launch resolver. Drop either hand-off and +// Claude asks before reading every attached document. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as LaunchResolution from '../claude/claude-structured-launch-resolution' +import type * as ClaudeRuntimeAdapter from './structured-claude-runtime-adapter' + +const captured = vi.hoisted(() => ({ + adapter: [] as { attachmentDirectory?: string }[], + resolver: [] as { attachmentDirectory?: string }[] +})) + +vi.mock('./structured-claude-runtime-adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createStructuredClaudeRuntimeAdapter: ( + deps: Parameters[0] + ) => { + captured.adapter.push(deps) + return actual.createStructuredClaudeRuntimeAdapter(deps) + } + } +}) + +vi.mock('../claude/claude-structured-launch-resolution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createClaudeStructuredLaunchResolver: ( + deps: Parameters[0] + ) => { + captured.resolver.push(deps) + return actual.createClaudeStructuredLaunchResolver(deps) + } + } +}) + +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let stateDirectory: string | null = null + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } +}) + +describe("structured Claude's read grant for chat attachments", () => { + it('hands the store root from the runtime through the adapter to the launch resolver', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-grant-')) + const directory = stateDirectory + await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({}) + }) + + const root = agentSessionAttachmentStoreRoot(directory) + expect(captured.adapter.at(-1)?.attachmentDirectory).toBe(root) + expect(captured.resolver.at(-1)?.attachmentDirectory).toBe(root) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index ad5ad98a62a2..d3c73184a255 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -123,7 +123,8 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), flushPendingAttachments: mocks.flushPendingAttachments, - removeImageAttachment: vi.fn() + removeImageAttachment: vi.fn(), + pendingChips: { begin: vi.fn(), resolve: vi.fn(), drop: vi.fn(), attachReferences: vi.fn() } } } })) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index a7ec22fa4738..5347bfc63531 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -171,7 +171,6 @@ const NativeChatComposerPane = forwardRef ({ })) vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name])) })) import { + nativeChatAttachFailedNotice, prepareNativeChatSessionAttachmentUpload, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, @@ -292,3 +294,18 @@ describe('a structured chat on a paired server', () => { }) }) }) + +describe('nativeChatAttachFailedNotice', () => { + it('ends the shared cause with one full stop, whatever script it is written in', () => { + expect(nativeChatAttachFailedNotice(['a.mov'], 'over the 50 MB limit')).toBe( + "Couldn't attach a.mov. over the 50 MB limit." + ) + expect(nativeChatAttachFailedNotice(['a'], 'サポートされていないファイル形式です。')).toBe( + "Couldn't attach a. サポートされていないファイル形式です。" + ) + expect(nativeChatAttachFailedNotice(['a'], '不支持的文件类型!')).toBe( + "Couldn't attach a. 不支持的文件类型!" + ) + expect(nativeChatAttachFailedNotice(['a', 'b'])).toBe("Couldn't attach a, b.") + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 1529a20a5f7b..bec1340fdd95 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -165,7 +165,7 @@ export function nativeChatAttachmentUnreadableNotice(): string { * share when there is one. */ export function nativeChatAttachFailedNotice(names: readonly string[], cause = ''): string { const files = names.join(', ') - const reason = cause.trim().replace(/[^.!?]$/, '$&.') + const reason = cause.trim().replace(/[^.!?。!?]$/u, '$&.') return reason ? translate( 'components.native-chat.composer.attachFailedBecause', diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx index f23805a05a19..3644e957131b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -229,4 +229,49 @@ describe('pasting into a structured chat on a paired server', () => { expect(insertTypedText).toHaveBeenCalledWith('caption') expect(setNotice).not.toHaveBeenCalledWith('needs newer server') }) + + it('files a paste whose upload lands after a prompt card unmounted the composer', async () => { + let finishUpload: (path: string) => void = () => {} + mocks.saveClipboardImageAsTempFile.mockReturnValue( + new Promise((resolve) => { + finishUpload = resolve + }) + ) + const resolved: string[] = [] + const dropped: string[] = [] + let api: HookApi | null = null + function Probe(): null { + api = useNativeChatComposerPaste({ + targetKey: 'session-1', + agent: 'claude', + disabled: false, + caret: 0, + setCaret: () => {}, + resolveAttachmentOwner: () => sessionOwner, + attachResolvedPaths: () => {}, + beginPendingImageAttachment: () => 'chip-1', + // The composer's chips file a result that lands after it unmounted into its scope cache. + resolvePendingImageAttachment: (id, path) => resolved.push(`${id} ${path}`), + dropPendingImageAttachment: (id) => { + dropped.push(id) + }, + insertTypedText: () => true, + setNotice: () => {} + }) + return null + } + const container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root?.render(createElement(Probe))) + await act(async () => api?.handlePaste(imagePasteEvent())) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledTimes(1) + + act(() => root?.unmount()) + root = null + await act(async () => finishUpload(storedPath)) + + expect(dropped).toEqual([]) + expect(resolved).toEqual([`chip-1 ${storedPath}`]) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 4a15267728fd..f45e07b381cb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -1,4 +1,5 @@ -import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text' import { extractIpcErrorMessage } from '@/lib/ipc-error' @@ -57,28 +58,14 @@ export function useNativeChatComposerPaste({ pasteFromClipboard: () => void } { const disabledRef = useRef(disabled) - const dropPendingRef = useRef(dropPendingImageAttachment) useLayoutEffect(() => { disabledRef.current = disabled - dropPendingRef.current = dropPendingImageAttachment - }, [disabled, dropPendingImageAttachment]) - const lifetime = useMemo( - () => ({ targetKey, active: false, pending: new Map() }), - [targetKey] - ) - useLayoutEffect(() => { - lifetime.active = true - return () => { - lifetime.active = false - for (const [id, preview] of lifetime.pending) { - if (preview.startsWith('blob:')) { - URL.revokeObjectURL(preview) - } - dropPendingRef.current(id) - } - lifetime.pending.clear() - } - }, [lifetime]) + }, [disabled]) + const { lifetime, track, keepStoreUploadAfterUnmount } = useNativeChatPasteLifetime({ + targetKey, + resolvePendingImageAttachment, + dropPendingImageAttachment + }) const canPaste = useCallback(() => lifetime.active && !disabledRef.current, [lifetime]) // A disabled composer still answers a paste, so it never vanishes silently. const showPasteUnavailable = useCallback(() => { @@ -193,7 +180,7 @@ export function useNativeChatComposerPaste({ const previewUrl = URL.createObjectURL(imageFile) pendingId = beginPendingImageAttachment(previewUrl) if (pendingId) { - lifetime.pending.set(pendingId, previewUrl) + track(pendingId, previewUrl, owner) } else { URL.revokeObjectURL(previewUrl) } @@ -210,6 +197,9 @@ export function useNativeChatComposerPaste({ async () => Boolean(text), awaitServer ? showChip : undefined ) + if (keepStoreUploadAfterUnmount(pendingId, saved)) { + return + } if (saved.status !== 'saved' || !canPaste()) { if (pendingId) { lifetime.pending.delete(pendingId) @@ -232,6 +222,8 @@ export function useNativeChatComposerPaste({ insertTypedText, showPasteUnavailable, resolveAttachmentOwner, + keepStoreUploadAfterUnmount, + track, saveClipboardImageForOwner, setCaret, setNotice, @@ -294,9 +286,12 @@ export function useNativeChatComposerPaste({ const pendingId = thumbnail && canPaste() ? beginPendingImageAttachment(thumbnail.dataUrl) : null if (pendingId) { - lifetime.pending.set(pendingId, thumbnail?.dataUrl ?? '') + track(pendingId, thumbnail?.dataUrl ?? '', owner) } const saved = await savePromise + if (keepStoreUploadAfterUnmount(pendingId, saved)) { + return + } if (!canPaste() || saved.status !== 'saved') { if (pendingId) { lifetime.pending.delete(pendingId) @@ -317,6 +312,8 @@ export function useNativeChatComposerPaste({ lifetime, dropPendingImageAttachment, insertTypedText, + keepStoreUploadAfterUnmount, + track, showPasteUnavailable, resolveAttachmentOwner, saveClipboardImageForOwner, diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts new file mode 100644 index 000000000000..e601b5a2d26b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -0,0 +1,76 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +/** + * The pending chips of pastes this composer started, released when it unmounts or changes target. + * A paste uploading into a paired server's store outlives the composer, which a prompt card + * unmounts: its chip stays live, and its result is filed for the composer's return, as a drop's is + * (`useNativeChatPendingAttachmentChips`). + */ +export function useNativeChatPasteLifetime(args: { + targetKey?: string + /** Files the result into the composer's scope even once this instance is unmounted. */ + resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + dropPendingImageAttachment: (id: string) => void +}): { + lifetime: { active: boolean; pending: Map } + track: (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => void + keepStoreUploadAfterUnmount: ( + pendingId: string | null, + saved: { status: string; tempPath?: string } + ) => boolean +} { + const { targetKey, resolvePendingImageAttachment, dropPendingImageAttachment } = args + const dropPendingRef = useRef(dropPendingImageAttachment) + useLayoutEffect(() => { + dropPendingRef.current = dropPendingImageAttachment + }, [dropPendingImageAttachment]) + const lifetime = useMemo( + () => ({ + targetKey, + active: false, + pending: new Map(), + storeUploads: new Set() + }), + [targetKey] + ) + useLayoutEffect(() => { + lifetime.active = true + return () => { + lifetime.active = false + for (const [id, preview] of lifetime.pending) { + if (preview.startsWith('blob:')) { + URL.revokeObjectURL(preview) + } + if (!lifetime.storeUploads.has(id)) { + dropPendingRef.current(id) + } + } + lifetime.pending.clear() + } + }, [lifetime]) + const track = useCallback( + (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => { + lifetime.pending.set(pendingId, preview) + if (owner.kind === 'runtime-session') { + lifetime.storeUploads.add(pendingId) + } + }, + [lifetime] + ) + const keepStoreUploadAfterUnmount = useCallback( + (pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => { + if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) { + return false + } + if (saved.status === 'saved' && saved.tempPath) { + resolvePendingImageAttachment(pendingId, saved.tempPath, null) + } else { + dropPendingImageAttachment(pendingId) + } + return true + }, + [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] + ) + return { lifetime, track, keepStoreUploadAfterUnmount } +} From dff514ac09d4c5c7356454539a80a0389cd9b70a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:59:29 -0700 Subject: [PATCH 18/40] test(native-chat): type the grant hand-off captures without casts --- .../structured-claude-attachment-grant.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts index a2fe0ee5f9d1..be3dfca4cbde 100644 --- a/src/main/runtime/structured-claude-attachment-grant.test.ts +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -9,10 +9,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type * as LaunchResolution from '../claude/claude-structured-launch-resolution' import type * as ClaudeRuntimeAdapter from './structured-claude-runtime-adapter' -const captured = vi.hoisted(() => ({ - adapter: [] as { attachmentDirectory?: string }[], - resolver: [] as { attachmentDirectory?: string }[] -})) +type GrantDeps = { attachmentDirectory?: string } + +const captured = vi.hoisted(() => { + const adapter: GrantDeps[] = [] + const resolver: GrantDeps[] = [] + return { adapter, resolver } +}) vi.mock('./structured-claude-runtime-adapter', async (importOriginal) => { const actual = await importOriginal() From bf41b08d4642edef411b951ad5e98b838479ed38 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:06:52 -0700 Subject: [PATCH 19/40] test(native-chat): pin that the composer files a paste's upload across a prompt --- .../NativeChatComposer.paste-remount.test.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx new file mode 100644 index 000000000000..66fa32d470e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom + +// A screenshot pasted into a chat on a paired server is still uploading when an agent prompt card +// replaces the composer. The real composer, attachment and paste hooks keep the upload: it is +// waiting in the composer when the composer comes back. + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClipboardEventLike } from './native-chat-clipboard-payload' + +type FieldProps = { + onPaste?: (event: ClipboardEventLike) => void + imageAttachments?: { path: string; pending?: boolean }[] +} + +const mocks = vi.hoisted(() => { + const state: { fieldProps: FieldProps | null } = { fieldProps: null } + return { state, prepare: vi.fn(), save: vi.fn() } +}) + +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + agentStatusByPaneKey: {}, + updateSettings: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchDraftAdopted: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) })) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) +vi.mock('./NativeChatComposerField', () => ({ + NativeChatComposerField: (props: FieldProps) => { + mocks.state.fieldProps = props + return
+ } +})) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) +vi.mock('./use-native-chat-external-attachments', () => ({ + useNativeChatExternalAttachments: () => ({ + attachExternalPaths: vi.fn(), + resolveAttachmentOwner: () => ({ + kind: 'runtime-session', + environmentId: 'env-1', + pairingRevision: 1, + sessionId: 'session-1' + }) + }) +})) +vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ + ...(await importOriginal()), + prepareNativeChatSessionAttachmentUpload: mocks.prepare +})) + +import { NativeChatComposer } from './NativeChatComposer' +import { + clearNativeChatAttachmentCacheForTests, + readNativeChatAttachmentCache +} from './use-native-chat-composer-attachments' + +const PANE = 'tab-1::session-1' +const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/orca-paste.png' + +function composer(): React.JSX.Element { + return ( + true), + dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'remote', + sessionId: 'session-1', + runtimeEnvironmentId: 'env-1' + }} + /> + ) +} + +/** The preload bridge: what this test drives, and an inert subscription for everything else. */ +function installPreloadApi(ui: Record): void { + const inert = (): (() => void) => () => {} + const namespace = (own: Record): Record => + new Proxy(own, { + get: (target, key) => (typeof key === 'string' && key in target ? target[key] : inert) + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: new Proxy>( + { ui: namespace(ui) }, + { get: (target, key) => (key === 'ui' ? target.ui : namespace({})) } + ) + }) +} + +function imagePaste(): ClipboardEventLike { + const data = new DataTransfer() + data.items.add(new File(['image'], 'image.png', { type: 'image/png' })) + return new ClipboardEvent('paste', { clipboardData: data, cancelable: true }) +} + +beforeEach(() => { + clearNativeChatAttachmentCacheForTests() + mocks.state.fieldProps = null + mocks.prepare.mockResolvedValue({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 1, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + vi.stubGlobal( + 'URL', + Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }) + ) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('a paste into a chat on a paired server', () => { + it('is still there when the composer returns from a prompt card that replaced it mid-upload', async () => { + let finishUpload: (path: string) => void = () => {} + mocks.save.mockReturnValue( + new Promise((resolve) => { + finishUpload = resolve + }) + ) + installPreloadApi({ saveClipboardImageAsTempFile: mocks.save }) + const view = render(composer()) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + + // The agent asks a question: its card takes the composer's place while the image uploads. + view.unmount() + await act(async () => finishUpload(STORED)) + + expect(readNativeChatAttachmentCache(PANE).map((attachment) => attachment.path)).toEqual([ + STORED + ]) + render(composer()) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) + }) +}) From 17b6706f5f4b12bff3d0de946516a3cd60344652 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:08:59 -0700 Subject: [PATCH 20/40] fix(native-chat): retry a held rename at commit and keep cut names Windows-safe A commit renames the part file with the Windows retry the app uses elsewhere, so an antivirus or indexer holding it briefly no longer loses the upload. A name cut to the byte limit is stripped of trailing dots and spaces again. The upload pump's result cast states why it holds. --- .../ipc/agent-session-attachment-upload.ts | 1 + .../agent-session-attachment-store.ts | 9 +++-- src/shared/agent-session-attachments.test.ts | 35 +++++++++++++++++++ src/shared/agent-session-attachments.ts | 5 +-- 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 src/shared/agent-session-attachments.test.ts diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts index 54e5b7e9d64c..83bf6c094553 100644 --- a/src/main/ipc/agent-session-attachment-upload.ts +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -61,6 +61,7 @@ async function callAttachmentMethod( if (response.ok !== true) { throw new Error(response.error.message || response.error.code) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result type of the agentSessionAttachment.* method it calls, whose server handler returns exactly that shape. return response.result as TResult } diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index 59603b1063c2..a0e3342b0f12 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -5,7 +5,7 @@ // file and are renamed into place on commit, so a stored name is always complete. Which chats hold // an upload is recorded by the message that names it (`agent-session-attachment-claims.ts`). -import { appendFile, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises' import { extname, join } from 'node:path' import { sanitizeAgentSessionAttachmentName, @@ -13,6 +13,7 @@ import { } from '../../../shared/agent-session-attachments' import type { RuntimeFilePreviewResult } from '../../../shared/runtime-types' import { readAuthorizedDocPreviewFile } from '../../../shared/doc-preview-file-access' +import { renameFileWithWindowsRetryAsync } from '../../../shared/windows-retry-file-operations' import { IMAGE_FILE_MIME_TYPES } from '../../../shared/image-file-extensions' import { LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, @@ -139,7 +140,11 @@ export class AgentSessionAttachmentStore { throw new Error('Attachment upload is incomplete') } const finalPath = join(upload.uploadDir, upload.name) - await rename(join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), finalPath) + // An antivirus or indexer briefly holding the part file on Windows must not lose the upload. + await renameFileWithWindowsRetryAsync( + join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), + finalPath + ) return { path: finalPath, name: upload.name, byteLength: upload.receivedLength } } catch (error) { await removeQuietly(upload.uploadDir) diff --git a/src/shared/agent-session-attachments.test.ts b/src/shared/agent-session-attachments.test.ts new file mode 100644 index 000000000000..c48b8964c18d --- /dev/null +++ b/src/shared/agent-session-attachments.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES, + sanitizeAgentSessionAttachmentName +} from './agent-session-attachments' + +const bytes = (text: string): number => new TextEncoder().encode(text).byteLength + +describe('sanitizeAgentSessionAttachmentName', () => { + it('keeps only the last path segment', () => { + expect(sanitizeAgentSessionAttachmentName('../../etc/passwd')).toBe('passwd') + expect(sanitizeAgentSessionAttachmentName('C:\\Users\\me\\shot.png')).toBe('shot.png') + }) + + it('caps a long name in UTF-8 bytes on whole characters, keeping its extension', () => { + const name = sanitizeAgentSessionAttachmentName(`${'图'.repeat(120)}.pdf`) + expect(bytes(name)).toBeLessThanOrEqual(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES) + expect(name.endsWith('.pdf')).toBe(true) + expect(name).not.toContain('\uFFFD') + }) + + it('never ends in a dot or space, even where the cut lands on one', () => { + // No short extension to keep, so the cut falls inside the name, right after a dot. + const long = `${'a'.repeat(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - 1)}.${'b'.repeat(30)}` + const name = sanitizeAgentSessionAttachmentName(long) + expect(name).toBe('a'.repeat(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - 1)) + expect(sanitizeAgentSessionAttachmentName('notes. .')).toBe('notes') + }) + + it('renames what Windows reserves for devices', () => { + expect(sanitizeAgentSessionAttachmentName('CON')).toBe('_CON') + expect(sanitizeAgentSessionAttachmentName('nul.tar.gz')).toBe('_nul.tar.gz') + expect(sanitizeAgentSessionAttachmentName('console.log')).toBe('console.log') + }) +}) diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index aded5fbc011b..d2fabcabd0e0 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -78,10 +78,11 @@ export function sanitizeAgentSessionAttachmentName(name: string): string { ? visible.slice(extensionAt) : '' const stem = visible.slice(0, visible.length - extension.length) - return ( + const truncated = truncateUtf8(stem, AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - utf8ByteLength(extension)) + extension - ) + // Cutting can expose a trailing dot or space, which Windows drops from a name. + return truncated.replace(/[. ]+$/, '') || 'attachment' } /** Pins an upload to the server the attachment was meant for: every call re-checks the pairing, From 5bf96794b3125a040f785258038b9f59740eef53 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:25:13 -0700 Subject: [PATCH 21/40] fix(native-chat): keep attachments still on their way in the pane's attachment cache A prompt card unmounts the composer, and an attachment still saving or uploading lived only in that composer: one that came back showed no pending chip, so Send went out without the file and the file then landed in the next draft. Pending chips now live in the pane's attachment cache beside settled ones and settle there whichever composer is showing, so Send waits for them after a remount too. A stored file's @path goes into the pane's draft cache, which keeps it through an input-method composition and a remount. A rich-text paste's image is registered as a hidden pending chip before the server is asked, so Send waits for it from the start, and it shows only once the server takes it. --- .../NativeChatComposer.paste-remount.test.tsx | 56 +++- .../native-chat/NativeChatComposer.tsx | 3 +- .../native-chat/NativeChatComposerField.tsx | 7 +- .../native-chat-attachment-cache.ts | 128 +++++++++ .../native-chat-session-attachment-drop.ts | 3 +- ...-native-chat-composer-attachments.test.tsx | 35 ++- .../use-native-chat-composer-attachments.ts | 249 +++++++----------- ...omposer-paste-session-attachments.test.tsx | 30 ++- .../use-native-chat-composer-paste.ts | 54 ++-- .../use-native-chat-paste-lifetime.ts | 56 +++- ...se-native-chat-pending-attachment-chips.ts | 52 ---- 11 files changed, 423 insertions(+), 250 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-attachment-cache.ts delete mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx index 66fa32d470e8..69cbf6c6db44 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom // A screenshot pasted into a chat on a paired server is still uploading when an agent prompt card -// replaces the composer. The real composer, attachment and paste hooks keep the upload: it is -// waiting in the composer when the composer comes back. +// replaces the composer. The real composer, attachment and paste hooks keep the upload, and Send +// waits for it, whenever the composer comes back; a rich-text paste's image is owed from the start. import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,7 @@ import type { ClipboardEventLike } from './native-chat-clipboard-payload' type FieldProps = { onPaste?: (event: ClipboardEventLike) => void imageAttachments?: { path: string; pending?: boolean }[] + sendButtonDisabled?: boolean } const mocks = vi.hoisted(() => { @@ -68,6 +69,10 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ })) import { NativeChatComposer } from './NativeChatComposer' +import { + clearNativeChatDraftCacheForTests, + writeNativeChatDraftCache +} from './native-chat-draft-cache' import { clearNativeChatAttachmentCacheForTests, readNativeChatAttachmentCache @@ -118,14 +123,29 @@ function installPreloadApi(ui: Record): void { }) } -function imagePaste(): ClipboardEventLike { +function imagePaste(text?: string): ClipboardEventLike { const data = new DataTransfer() data.items.add(new File(['image'], 'image.png', { type: 'image/png' })) + if (text) { + data.setData('text/plain', text) + } return new ClipboardEvent('paste', { clipboardData: data, cancelable: true }) } +function holdUpload(): (path: string) => void { + let finishUpload: (path: string) => void = () => {} + mocks.save.mockReturnValue( + new Promise((resolve) => { + finishUpload = resolve + }) + ) + installPreloadApi({ saveClipboardImageAsTempFile: mocks.save }) + return (path) => finishUpload(path) +} + beforeEach(() => { clearNativeChatAttachmentCacheForTests() + clearNativeChatDraftCacheForTests() mocks.state.fieldProps = null mocks.prepare.mockResolvedValue({ ok: true, @@ -170,4 +190,34 @@ describe('a paste into a chat on a paired server', () => { render(composer()) expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) }) + + it('still holds Send when the composer returns before the upload has finished', async () => { + const finishUpload = holdUpload() + const first = render(composer()) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) + first.unmount() + + // Back from the prompt card while the image is still on its way to the server. + render(composer()) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + + await act(async () => finishUpload(STORED)) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) + expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy() + }) + + it('holds Send for rich text while the server is still asked whether it takes the image', async () => { + const prepared = Promise.withResolvers() + mocks.prepare.mockReturnValue(prepared.promise) + holdUpload() + // What the user typed, and the pasted text with it: Send is open before the paste. + writeNativeChatDraftCache(PANE, 'look at this caption') + render(composer()) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption'))) + + // The text is in; the image is not yet anywhere, but it is owed to this message. + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 5347bfc63531..513fbe5e6129 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -218,8 +218,9 @@ const NativeChatComposerPane = forwardRef !attachment.hidden) // Value the IME started from, and whether a programmatic clear was dropped on top of it. const compositionBaseRef = useRef('') const droppedDraftClearRef = useRef(false) @@ -215,9 +218,9 @@ export function NativeChatComposerField({ '[contain:paint]' )} > - {imageAttachments.length > 0 ? ( + {shownAttachments.length > 0 ? (
- {imageAttachments.map((attachment) => ( + {shownAttachments.map((attachment) => ( () +const listeners = new Map void>>() + +/** The scope's chips; the same array until they change, so a reader can subscribe to it. */ +export function nativeChatAttachmentSnapshot( + scopeKey: string +): readonly NativeChatComposerImageAttachment[] { + return attachmentCache.get(scopeKey) ?? EMPTY +} + +export function readNativeChatAttachmentCache( + scopeKey: string +): NativeChatComposerImageAttachment[] { + return [...nativeChatAttachmentSnapshot(scopeKey)] +} + +/** Replaces the scope's chips and tells every composer showing them. */ +export function updateNativeChatAttachmentCache( + scopeKey: string, + updater: ( + previous: readonly NativeChatComposerImageAttachment[] + ) => readonly NativeChatComposerImageAttachment[] +): void { + // Preview URLs can retain the full clipboard Blob (or a large data URL) for the lifetime of the + // scope cache; a composer keeps its own and a chip reloads from its path. + const next = updater(nativeChatAttachmentSnapshot(scopeKey)).map( + ({ previewUrl: _previewUrl, ...attachment }) => attachment + ) + if (next.length === 0) { + attachmentCache.delete(scopeKey) + } else { + // LRU-bounded so attachments for permanently-removed panes can't accumulate. + setBoundedScopeCacheEntry(attachmentCache, scopeKey, next) + } + listeners.get(scopeKey)?.forEach((listener) => listener()) +} + +export function subscribeToNativeChatAttachmentCache( + scopeKey: string, + listener: () => void +): () => void { + const scoped = listeners.get(scopeKey) ?? new Set() + listeners.set(scopeKey, scoped) + scoped.add(listener) + return () => { + scoped.delete(listener) + if (scoped.size === 0 && listeners.get(scopeKey) === scoped) { + listeners.delete(scopeKey) + } + } +} + +/** Puts settled images back after whatever is attached, and shows them in a mounted composer. */ +export function appendNativeChatAttachmentCache( + scopeKey: string, + appended: readonly NativeChatComposerImageAttachment[] +): void { + if (appended.length > 0) { + updateNativeChatAttachmentCache(scopeKey, (previous) => [...previous, ...appended]) + } +} + +function hasPending(scopeKey: string, id: string): boolean { + return nativeChatAttachmentSnapshot(scopeKey).some( + (attachment) => attachment.id === id && attachment.pending === true + ) +} + +/** Settles a pending chip at its path. False when the user already removed it. */ +export function resolveNativeChatPendingAttachment( + scopeKey: string, + id: string, + path: string, + connectionId?: string | null +): boolean { + if (!hasPending(scopeKey, id)) { + return false + } + updateNativeChatAttachmentCache(scopeKey, (previous) => + previous.map((attachment) => { + if (attachment.id !== id) { + return attachment + } + const { pending: _pending, pendingName: _name, hidden: _hidden, ...settled } = attachment + return { ...settled, path, ...(connectionId ? { connectionId } : {}) } + }) + ) + return true +} + +/** Shows a pending chip that was held out of sight, such as while a server was asked first. */ +export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { + if (hasPending(scopeKey, id)) { + updateNativeChatAttachmentCache(scopeKey, (previous) => + previous.map((attachment) => { + const { hidden: _hidden, ...shown } = attachment + return attachment.id === id ? shown : attachment + }) + ) + } +} + +/** Removes a pending chip whose operation ended without a file. False when it was already gone. */ +export function dropNativeChatPendingAttachment(scopeKey: string, id: string): boolean { + if (!hasPending(scopeKey, id)) { + return false + } + updateNativeChatAttachmentCache(scopeKey, (previous) => + previous.filter((attachment) => attachment.id !== id) + ) + return true +} + +export function clearNativeChatAttachmentCacheForTests(): void { + attachmentCache.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 15cd75b2baa9..48f06a15cb8c 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -16,7 +16,8 @@ export type NativeChatPendingAttachmentChips = { resolve: (id: string, path: string, connectionId?: string | null) => void /** Removes the chip; false when the user already removed it, so its file must not attach. */ drop: (id: string) => boolean - /** Inserts stored files as `@path` references, into the draft wherever the composer is now. */ + /** Adds stored files as `@path` references to the scope's draft, which keeps them through an + * input-method composition or a remount. */ attachReferences: (paths: string[]) => void } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 2ecb93f18781..e86fcfe4648f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -496,7 +496,33 @@ describe('useNativeChatComposerAttachments', () => { act(() => back.root.unmount()) }) - it('excludes a pending chip from the scope cache while a settled chip persists', async () => { + it('keeps a stored file whose reference is held for an input-method composition across a remount', async () => { + let composing = true + const probe = await renderProbe('pty-ime', true, { isComposing: () => composing }) + const chips = probe.latest().pendingChips + let chipId: string | null = null + act(() => { + chipId = chips.begin(undefined, 'notes.pdf') + }) + if (!chipId) { + throw new Error('expected a pending chip') + } + const id: string = chipId + // The upload finishes mid-composition: the reference waits for the composition to settle. + act(() => { + expect(chips.drop(id)).toBe(true) + chips.attachReferences(['/srv/agent-session-attachments/u4/notes.pdf']) + }) + // A prompt card takes the composer's place before the composition settles. + act(() => probe.root.unmount()) + composing = false + + expect(readNativeChatDraftCache('pty-ime')).toContain( + '@/srv/agent-session-attachments/u4/notes.pdf' + ) + }) + + it('keeps a pending chip in the scope cache, without its preview, beside a settled one', async () => { const probe = await renderProbe('pty-1') let pendingId: string | null = null act(() => { @@ -506,10 +532,11 @@ describe('useNativeChatComposerAttachments', () => { probe.latest().attachResolvedPaths(['/tmp/settled.png']) }) + // A composer that comes back must still wait for the pending one. const cached = readNativeChatAttachmentCache('pty-1') - expect(cached.some((attachment) => attachment.id === pendingId)).toBe(false) - expect(cached).toMatchObject([{ path: '/tmp/settled.png' }]) - expect(cached[0]?.previewUrl).toBeUndefined() + expect(cached).toMatchObject([{ id: pendingId, pending: true }, { path: '/tmp/settled.png' }]) + expect(cached.every((attachment) => attachment.previewUrl === undefined)).toBe(true) + expect(probe.latest().imageAttachments[0]?.previewUrl).toBe('blob:preview-1') act(() => probe.root.unmount()) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 8644579a509b..6981d42c32f6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,16 +1,30 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useEffect, useRef, useState, type RefObject } from 'react' +import { useCallback, useMemo, useRef, useSyncExternalStore, type RefObject } from 'react' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' import { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-attachment-upload' -import { useNativeChatPendingAttachmentChips } from './use-native-chat-pending-attachment-chips' import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import { + dropNativeChatPendingAttachment, + nativeChatAttachmentSnapshot, + resolveNativeChatPendingAttachment, + revealNativeChatPendingAttachment, + subscribeToNativeChatAttachmentCache, + updateNativeChatAttachmentCache +} from './native-chat-attachment-cache' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' + +export { + appendNativeChatAttachmentCache, + clearNativeChatAttachmentCacheForTests, + readNativeChatAttachmentCache +} from './native-chat-attachment-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -46,41 +60,55 @@ export function useNativeChatComposerAttachments({ clearImageAttachments: () => void flushPendingAttachments: () => void removeImageAttachment: (id: string) => void - beginPendingImageAttachment: (previewUrl?: string, pendingName?: string) => string | null + beginPendingImageAttachment: ( + previewUrl?: string, + pendingName?: string, + options?: { hidden?: true } + ) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + revealPendingImageAttachment: (id: string, previewUrl?: string) => void dropPendingImageAttachment: (id: string) => boolean pendingChips: NativeChatPendingAttachmentChips } { - const [imageAttachments, setImageAttachments] = useState( - () => readNativeChatAttachmentCache(attachmentScopeKey) + // The chips live in the scope cache, so a composer a prompt card unmounted comes back to the same + // ones, still pending where their save or upload is (`native-chat-attachment-cache.ts`). + const subscribe = useCallback( + (listener: () => void) => subscribeToNativeChatAttachmentCache(attachmentScopeKey, listener), + [attachmentScopeKey] ) - const imageAttachmentCounter = useRef(0) - // Pending chips the user has not removed, known synchronously so a finishing upload can ask. - const livePendingChipIds = useRef(new Set()) - - useEffect( + const cached = useSyncExternalStore(subscribe, () => + nativeChatAttachmentSnapshot(attachmentScopeKey) + ) + // Clipboard thumbnails stay with the composer that made them; the cache never holds them. + const previews = useRef(new Map()) + const imageAttachments = useMemo( () => - subscribeToNativeChatAttachmentAppend(attachmentScopeKey, (appended) => - setImageAttachments((prev) => [...prev, ...appended]) - ), - [attachmentScopeKey] + cached.map((attachment) => { + const previewUrl = previews.current.get(attachment.id) + return previewUrl ? { ...attachment, previewUrl } : attachment + }), + [cached] ) + const imageAttachmentCounter = useRef(0) const updateImageAttachments = useCallback( ( updater: ( - previous: NativeChatComposerImageAttachment[] - ) => NativeChatComposerImageAttachment[] - ) => { - setImageAttachments((prev) => { - const next = updater(prev) - writeNativeChatAttachmentCache(attachmentScopeKey, next) - return next - }) - }, + previous: readonly NativeChatComposerImageAttachment[] + ) => readonly NativeChatComposerImageAttachment[] + ) => updateNativeChatAttachmentCache(attachmentScopeKey, updater), [attachmentScopeKey] ) + const releasePreview = useCallback((id: string) => { + const previewUrl = previews.current.get(id) + previews.current.delete(id) + if (previewUrl?.startsWith('blob:')) { + // Object URLs minted from a clipboard blob leak until revoked; data URLs don't. + URL.revokeObjectURL(previewUrl) + } + }, []) + const nextAttachmentId = useCallback((): string => { imageAttachmentCounter.current += 1 return `${Date.now()}-${imageAttachmentCounter.current}` @@ -137,7 +165,7 @@ export function useNativeChatComposerAttachments({ // Placeholder chip shown the instant a paste starts, so a clipboard image that // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( - (previewUrl?: string, pendingName?: string): string | null => { + (previewUrl?: string, pendingName?: string, options?: { hidden?: true }): string | null => { if (disabledRef.current) { return null } @@ -146,10 +174,18 @@ export function useNativeChatComposerAttachments({ return null } const id = nextAttachmentId() - livePendingChipIds.current.add(id) + if (previewUrl) { + previews.current.set(id, previewUrl) + } updateImageAttachments((prev) => [ ...prev, - { id, path: '', previewUrl, pending: true, ...(pendingName ? { pendingName } : {}) } + { + id, + path: '', + pending: true, + ...(pendingName ? { pendingName } : {}), + ...(options?.hidden ? { hidden: true } : {}) + } ]) return id }, @@ -164,146 +200,65 @@ export function useNativeChatComposerAttachments({ const resolvePendingImageAttachment = useCallback( (id: string, path: string, connectionId?: string | null) => { - livePendingChipIds.current.delete(id) - updateImageAttachments((prev) => - prev.map((attachment) => - attachment.id === id - ? { - ...attachment, - path, - connectionId: connectionId ?? undefined, - pending: undefined, - pendingName: undefined - } - : attachment - ) - ) + resolveNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId) + }, + [attachmentScopeKey] + ) + + const revealPendingImageAttachment = useCallback( + (id: string, previewUrl?: string) => { + if (previewUrl) { + previews.current.set(id, previewUrl) + } + revealNativeChatPendingAttachment(attachmentScopeKey, id) }, - [updateImageAttachments] + [attachmentScopeKey] ) const dropPendingImageAttachment = useCallback( (id: string): boolean => { - const live = livePendingChipIds.current.delete(id) - updateImageAttachments((prev) => removeAttachmentById(prev, id)) - return live + releasePreview(id) + return dropNativeChatPendingAttachment(attachmentScopeKey, id) }, - [updateImageAttachments] + [attachmentScopeKey, releasePreview] ) - const pendingChips = useNativeChatPendingAttachmentChips({ - scopeKey: attachmentScopeKey, - livePendingChipIds, - begin: beginPendingImageAttachment, - resolve: resolvePendingImageAttachment, - drop: dropPendingImageAttachment, - attachResolvedPaths - }) + const pendingChips = useMemo( + (): NativeChatPendingAttachmentChips => ({ + begin: beginPendingImageAttachment, + resolve: resolvePendingImageAttachment, + drop: dropPendingImageAttachment, + // Into the scope's draft, which keeps it through a composition or a remount. + attachReferences: (paths) => + appendNativeChatDraftCache( + attachmentScopeKey, + paths.map(formatNativeChatFileReference).join(' ') + ) + }), + [ + attachmentScopeKey, + beginPendingImageAttachment, + dropPendingImageAttachment, + resolvePendingImageAttachment + ] + ) return { pendingChips, imageAttachments, attachResolvedPaths, clearImageAttachments: () => { - livePendingChipIds.current.clear() - updateImageAttachments((prev) => { - prev.forEach(releaseAttachmentPreview) - return [] - }) + ;[...previews.current.keys()].forEach(releasePreview) + updateImageAttachments(() => []) }, flushPendingAttachments, removeImageAttachment: (id) => { - livePendingChipIds.current.delete(id) - updateImageAttachments((prev) => removeAttachmentById(prev, id)) + releasePreview(id) + updateImageAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) }, beginPendingImageAttachment, resolvePendingImageAttachment, + revealPendingImageAttachment, dropPendingImageAttachment } } - -/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ -function releaseAttachmentPreview(attachment: NativeChatComposerImageAttachment): void { - if (attachment.previewUrl?.startsWith('blob:')) { - URL.revokeObjectURL(attachment.previewUrl) - } -} - -function removeAttachmentById( - attachments: readonly NativeChatComposerImageAttachment[], - id: string -): NativeChatComposerImageAttachment[] { - const removed = attachments.find((attachment) => attachment.id === id) - if (removed) { - releaseAttachmentPreview(removed) - } - return attachments.filter((attachment) => attachment.id !== id) -} - -const attachmentCache = new Map() - -export function readNativeChatAttachmentCache( - scopeKey: string -): NativeChatComposerImageAttachment[] { - return [...(attachmentCache.get(scopeKey) ?? [])] -} - -function writeNativeChatAttachmentCache( - scopeKey: string, - cacheable: readonly NativeChatComposerImageAttachment[] -): void { - // A pending chip's save resolves into THIS hook instance; restoring one into a - // remount would strand it pending forever, so only settled chips are cached. - const attachments = cacheable - .filter((attachment) => !attachment.pending) - // Preview URLs can retain the full clipboard Blob (or a large data URL) for - // the lifetime of the scope cache. Settled attachments reload from their - // authorized path after a remount, so never retain the transient preview. - .map(({ previewUrl: _previewUrl, ...attachment }) => attachment) - if (attachments.length === 0) { - attachmentCache.delete(scopeKey) - return - } - // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. - setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments]) -} - -// Only a write from outside the composer notifies; its own writes already hold the chips. -const appendListeners = new Map< - string, - Set<(appended: readonly NativeChatComposerImageAttachment[]) => void> ->() - -/** Puts settled images back after whatever is attached, and shows them in a mounted composer. */ -export function appendNativeChatAttachmentCache( - scopeKey: string, - appended: readonly NativeChatComposerImageAttachment[] -): void { - if (appended.length === 0) { - return - } - writeNativeChatAttachmentCache(scopeKey, [ - ...readNativeChatAttachmentCache(scopeKey), - ...appended - ]) - appendListeners.get(scopeKey)?.forEach((listener) => listener(appended)) -} - -function subscribeToNativeChatAttachmentAppend( - scopeKey: string, - listener: (appended: readonly NativeChatComposerImageAttachment[]) => void -): () => void { - const listeners = appendListeners.get(scopeKey) ?? new Set() - appendListeners.set(scopeKey, listeners) - listeners.add(listener) - return () => { - listeners.delete(listener) - if (listeners.size === 0 && appendListeners.get(scopeKey) === listeners) { - appendListeners.delete(scopeKey) - } - } -} - -export function clearNativeChatAttachmentCacheForTests(): void { - attachmentCache.clear() -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx index 3644e957131b..e00cb91f2c91 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -49,7 +49,7 @@ vi.stubGlobal('URL', { import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' type HookApi = ReturnType -type Chip = { id: string; path: string; pending: boolean } +type Chip = { id: string; path: string; pending: boolean; hidden?: true } const sessionOwner: NativeChatAttachmentOwner = { kind: 'runtime-session', @@ -73,9 +73,10 @@ async function renderPaste(args: { attachResolvedPaths?: (...args: unknown[]) => void insertTypedText?: (text: string) => boolean setNotice?: (notice: string | null) => void -}): Promise<{ api: () => HookApi; chips: Chip[]; begun: () => number }> { +}): Promise<{ api: () => HookApi; chips: Chip[]; begun: () => number; revealed: () => number }> { const chips: Chip[] = [] let counter = 0 + let revealed = 0 let api: HookApi | null = null function Probe(): null { api = useNativeChatComposerPaste({ @@ -86,11 +87,23 @@ async function renderPaste(args: { setCaret: () => {}, resolveAttachmentOwner: () => sessionOwner, attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), - beginPendingImageAttachment: () => { + beginPendingImageAttachment: (_preview, _name, options) => { counter += 1 - chips.push({ id: `chip-${counter}`, path: '', pending: true }) + chips.push({ + id: `chip-${counter}`, + path: '', + pending: true, + ...(options?.hidden ? { hidden: true } : {}) + }) return `chip-${counter}` }, + revealPendingImageAttachment: (id) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + delete chip.hidden + revealed += 1 + } + }, resolvePendingImageAttachment: (id, path) => { const chip = chips.find((candidate) => candidate.id === id) if (chip) { @@ -120,7 +133,8 @@ async function renderPaste(args: { return api }, chips, - begun: () => counter + begun: () => counter, + revealed: () => revealed } } @@ -212,8 +226,10 @@ describe('pasting into a structured chat on a paired server', () => { await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) expect(insertTypedText).toHaveBeenCalledWith('caption') expect(setNotice).not.toHaveBeenCalledWith('needs newer server') - // No chip ever showed, so none flashed and vanished. - expect(probe.begun()).toBe(0) + // The image was owed to the message only out of sight: no chip showed, so none flashed. + expect(probe.begun()).toBe(1) + expect(probe.revealed()).toBe(0) + expect(probe.chips).toEqual([]) }) it('pastes rich text from the button into a chat on an older server without a refusal', async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index f45e07b381cb..fdbdffb84849 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -33,8 +33,14 @@ export type UseNativeChatComposerPasteArgs = { * remote host, or the attached path names a file the agent cannot read. */ resolveAttachmentOwner: () => NativeChatAttachmentOwner attachResolvedPaths: (paths: string[], connectionId?: string | null) => void - beginPendingImageAttachment: (previewUrl?: string) => string | null + beginPendingImageAttachment: ( + previewUrl?: string, + pendingName?: string, + options?: { hidden?: true } + ) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + /** Shows a chip begun hidden, with its thumbnail. */ + revealPendingImageAttachment?: (id: string, previewUrl?: string) => void dropPendingImageAttachment: (id: string) => void insertTypedText: (text: string) => boolean setCaret: (caret: number) => void @@ -49,6 +55,7 @@ export function useNativeChatComposerPaste({ attachResolvedPaths, beginPendingImageAttachment, resolvePendingImageAttachment, + revealPendingImageAttachment, dropPendingImageAttachment, insertTypedText, setCaret, @@ -61,11 +68,14 @@ export function useNativeChatComposerPaste({ useLayoutEffect(() => { disabledRef.current = disabled }, [disabled]) - const { lifetime, track, keepStoreUploadAfterUnmount } = useNativeChatPasteLifetime({ - targetKey, - resolvePendingImageAttachment, - dropPendingImageAttachment - }) + const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } = + useNativeChatPasteLifetime({ + targetKey, + beginPendingImageAttachment, + resolvePendingImageAttachment, + revealPendingImageAttachment, + dropPendingImageAttachment + }) const canPaste = useCallback(() => lifetime.active && !disabledRef.current, [lifetime]) // A disabled composer still answers a paste, so it never vanishes silently. const showPasteUnavailable = useCallback(() => { @@ -170,32 +180,19 @@ export function useNativeChatComposerPaste({ // Why: snapshot the caret before the async temp-file round-trip — `caret` // state can move (further typing/selection) while the await is in flight. const caretAtPaste = caret - // The clipboard blob is already in this process, so the chip can show the - // real image on the same tick the paste happens — no round-trip at all. - let pendingId: string | null = null - const showChip = (): void => { - if (!ownerAcceptsClipboardImage(owner) || !canPaste()) { - return - } - const previewUrl = URL.createObjectURL(imageFile) - pendingId = beginPendingImageAttachment(previewUrl) - if (pendingId) { - track(pendingId, previewUrl, owner) - } else { - URL.revokeObjectURL(previewUrl) - } - } - // Beside pasted text, a server too old to store the image drops it quietly, so its chip waits - // for the server's answer instead of flashing. + // Beside pasted text, a server too old to store the image drops it quietly, so its chip stays + // out of sight until the server answers; Send waits for it from the start all the same. const awaitServer = Boolean(text) && owner.kind === 'runtime-session' - if (!awaitServer) { - showChip() - } + const chip = startImageChip(owner, imageFile, { + hidden: awaitServer, + canShow: () => ownerAcceptsClipboardImage(owner) && canPaste() + }) + const pendingId = chip.id void (async () => { const saved = await saveClipboardImageForOwner( owner, async () => Boolean(text), - awaitServer ? showChip : undefined + awaitServer ? chip.reveal : undefined ) if (keepStoreUploadAfterUnmount(pendingId, saved)) { return @@ -214,7 +211,6 @@ export function useNativeChatComposerPaste({ })() }, [ - beginPendingImageAttachment, canPaste, lifetime, caret, @@ -223,7 +219,7 @@ export function useNativeChatComposerPaste({ showPasteUnavailable, resolveAttachmentOwner, keepStoreUploadAfterUnmount, - track, + startImageChip, saveClipboardImageForOwner, setCaret, setNotice, diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index e601b5a2d26b..9ef6e04ba7fb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -4,23 +4,37 @@ import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' /** * The pending chips of pastes this composer started, released when it unmounts or changes target. * A paste uploading into a paired server's store outlives the composer, which a prompt card - * unmounts: its chip stays live, and its result is filed for the composer's return, as a drop's is - * (`useNativeChatPendingAttachmentChips`). + * unmounts: its chip stays pending in the scope's attachment cache, where its result settles for + * the composer's return (`native-chat-attachment-cache.ts`). */ export function useNativeChatPasteLifetime(args: { targetKey?: string + beginPendingImageAttachment: ( + previewUrl?: string, + pendingName?: string, + options?: { hidden?: true } + ) => string | null /** Files the result into the composer's scope even once this instance is unmounted. */ resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + revealPendingImageAttachment?: (id: string, previewUrl?: string) => void dropPendingImageAttachment: (id: string) => void }): { lifetime: { active: boolean; pending: Map } track: (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => void + /** A paste's chip, shown at once with the clipboard's own thumbnail, or held out of sight + * (`hidden`) until `reveal`; `id` is null when the composer refused it. */ + startImageChip: ( + owner: NativeChatAttachmentOwner, + imageFile: Blob, + options: { hidden: boolean; canShow: () => boolean } + ) => { id: string | null; reveal: () => void } keepStoreUploadAfterUnmount: ( pendingId: string | null, saved: { status: string; tempPath?: string } ) => boolean } { - const { targetKey, resolvePendingImageAttachment, dropPendingImageAttachment } = args + const { targetKey, beginPendingImageAttachment, revealPendingImageAttachment } = args + const { resolvePendingImageAttachment, dropPendingImageAttachment } = args const dropPendingRef = useRef(dropPendingImageAttachment) useLayoutEffect(() => { dropPendingRef.current = dropPendingImageAttachment @@ -58,6 +72,40 @@ export function useNativeChatPasteLifetime(args: { }, [lifetime] ) + const startImageChip = useCallback( + ( + owner: NativeChatAttachmentOwner, + imageFile: Blob, + options: { hidden: boolean; canShow: () => boolean } + ): { id: string | null; reveal: () => void } => { + if (options.hidden) { + const id = beginPendingImageAttachment(undefined, undefined, { hidden: true }) + if (id) { + track(id, '', owner) + } + const reveal = (): void => { + if (id && lifetime.active) { + const previewUrl = URL.createObjectURL(imageFile) + lifetime.pending.set(id, previewUrl) + revealPendingImageAttachment?.(id, previewUrl) + } + } + return { id, reveal } + } + if (!options.canShow()) { + return { id: null, reveal: () => {} } + } + const previewUrl = URL.createObjectURL(imageFile) + const id = beginPendingImageAttachment(previewUrl) + if (id) { + track(id, previewUrl, owner) + } else { + URL.revokeObjectURL(previewUrl) + } + return { id, reveal: () => {} } + }, + [beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track] + ) const keepStoreUploadAfterUnmount = useCallback( (pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => { if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) { @@ -72,5 +120,5 @@ export function useNativeChatPasteLifetime(args: { }, [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] ) - return { lifetime, track, keepStoreUploadAfterUnmount } + return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts deleted file mode 100644 index 3e3df9f8abf9..000000000000 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-attachment-chips.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useMemo, useRef, type RefObject } from 'react' -import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' -import { appendNativeChatDraftCache } from './native-chat-draft-cache' -import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' -import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' - -/** - * The pending-chip controls an upload drives. A prompt card unmounts the composer; an upload that - * finishes meanwhile lands in the scope's attachment and draft caches, which the composer reads - * back when it returns, instead of in the unmounted one's state. - */ -export function useNativeChatPendingAttachmentChips(args: { - scopeKey: string - /** Chips the user has not removed. */ - livePendingChipIds: RefObject> - begin: NativeChatPendingAttachmentChips['begin'] - resolve: NativeChatPendingAttachmentChips['resolve'] - drop: NativeChatPendingAttachmentChips['drop'] - attachResolvedPaths: (paths: string[], connectionId?: string | null) => void -}): NativeChatPendingAttachmentChips { - const { scopeKey, livePendingChipIds, begin, resolve, drop, attachResolvedPaths } = args - const mountedRef = useRef(true) - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - return useMemo( - () => ({ - begin, - resolve: (id, path, connectionId) => { - if (mountedRef.current) { - resolve(id, path, connectionId) - } else if (livePendingChipIds.current.delete(id)) { - appendNativeChatAttachmentCache(scopeKey, [ - { id, path, ...(connectionId ? { connectionId } : {}) } - ]) - } - }, - drop, - attachReferences: (paths) => { - if (mountedRef.current) { - attachResolvedPaths(paths, null) - } else { - appendNativeChatDraftCache(scopeKey, paths.map(formatNativeChatFileReference).join(' ')) - } - } - }), - [attachResolvedPaths, begin, drop, livePendingChipIds, resolve, scopeKey] - ) -} From af18c94c47f29e7afb71933046205695d7a48dde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:26:01 -0700 Subject: [PATCH 22/40] test(native-chat): a dropped file still uploading stays pending across a remount --- ...-native-chat-composer-attachments.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index e86fcfe4648f..d80539662086 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -496,6 +496,30 @@ describe('useNativeChatComposerAttachments', () => { act(() => back.root.unmount()) }) + it('shows a dropped file still uploading as pending to a composer that comes back, then settles it there', async () => { + const first = await renderProbe('pty-drop') + const chips = first.latest().pendingChips + let chipId: string | null = null + act(() => { + chipId = chips.begin(undefined, 'shot.png') + }) + if (!chipId) { + throw new Error('expected a pending chip') + } + const id: string = chipId + act(() => first.root.unmount()) + + const back = await renderProbe('pty-drop') + expect(back.latest().imageAttachments).toMatchObject([ + { id, pending: true, pendingName: 'shot.png' } + ]) + act(() => chips.resolve(id, '/srv/agent-session-attachments/u5/shot.png')) + expect(back.latest().imageAttachments).toEqual([ + { id, path: '/srv/agent-session-attachments/u5/shot.png' } + ]) + act(() => back.root.unmount()) + }) + it('keeps a stored file whose reference is held for an input-method composition across a remount', async () => { let composing = true const probe = await renderProbe('pty-ime', true, { isComposing: () => composing }) From fdc5373c6e3a1a5a000c3ff68f5db130e1d1b8b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:36:23 -0700 Subject: [PATCH 23/40] fix(native-chat): reveal a rich-text image in a composer that came back; keep caret inserts A rich-text paste's held image is revealed through the pane's attachment cache, so a composer a prompt card remounted during the server's answer shows it rather than holding Send on an invisible chip. A stored file's @path goes in at the caret while the composer is showing and not composing, as every other attach does; only mid-composition or after a remount does it go to the pane's draft. --- .../NativeChatComposer.paste-remount.test.tsx | 33 +++++++++++++++++++ .../native-chat-session-attachment-drop.ts | 2 +- ...-native-chat-composer-attachments.test.tsx | 10 ++++++ .../use-native-chat-composer-attachments.ts | 28 ++++++++++++++-- .../use-native-chat-paste-lifetime.ts | 10 ++++-- 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx index 69cbf6c6db44..f5509b5b10df 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -193,6 +193,8 @@ describe('a paste into a chat on a paired server', () => { it('still holds Send when the composer returns before the upload has finished', async () => { const finishUpload = holdUpload() + // Something typed, so only the pending image can hold Send. + writeNativeChatDraftCache(PANE, 'look at this') const first = render(composer()) await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) first.unmount() @@ -220,4 +222,35 @@ describe('a paste into a chat on a paired server', () => { // The text is in; the image is not yet anywhere, but it is owed to this message. expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) }) + + it("shows a rich-text paste's image once the server answers, even in a composer that came back", async () => { + const prepared = Promise.withResolvers() + mocks.prepare.mockReturnValue(prepared.promise) + const finishUpload = holdUpload() + writeNativeChatDraftCache(PANE, 'look at this caption') + const first = render(composer()) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption'))) + // A prompt card replaces the composer while the server is still being asked. + first.unmount() + render(composer()) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + + await act(async () => + prepared.resolve({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 1, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + ) + // Shown, so the user can see it and remove it while it uploads. + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + expect(mocks.state.fieldProps?.imageAttachments?.[0]).not.toHaveProperty('hidden') + + await act(async () => finishUpload(STORED)) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 48f06a15cb8c..0d655d8de8a3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -104,7 +104,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { references.push(storedPath) } } - // Attaching clears the notice, so what failed is said after. + // Inserting at the caret clears the notice, so what failed is said after. if (references.length > 0) { args.chips.attachReferences(references) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index d80539662086..38b5ac351bef 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -520,6 +520,16 @@ describe('useNativeChatComposerAttachments', () => { act(() => back.root.unmount()) }) + it('inserts a stored file at the caret while the composer is showing and not composing', async () => { + const probe = await renderProbe('pty-caret', true) + act(() => + probe.latest().pendingChips.attachReferences(['/srv/agent-session-attachments/u6/a.pdf']) + ) + expect(probe.draft()).toBe('@/srv/agent-session-attachments/u6/a.pdf ') + expect(readNativeChatDraftCache('pty-caret')).toBe('') + act(() => probe.root.unmount()) + }) + it('keeps a stored file whose reference is held for an input-method composition across a remount', async () => { let composing = true const probe = await renderProbe('pty-ime', true, { isComposing: () => composing }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 6981d42c32f6..bb8710521403 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,5 +1,12 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, useMemo, useRef, useSyncExternalStore, type RefObject } from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type RefObject +} from 'react' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget @@ -90,6 +97,13 @@ export function useNativeChatComposerAttachments({ [cached] ) const imageAttachmentCounter = useRef(0) + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) const updateImageAttachments = useCallback( ( @@ -228,17 +242,25 @@ export function useNativeChatComposerAttachments({ begin: beginPendingImageAttachment, resolve: resolvePendingImageAttachment, drop: dropPendingImageAttachment, - // Into the scope's draft, which keeps it through a composition or a remount. - attachReferences: (paths) => + // At the caret, as every attach does; mid-composition or once the composer is gone, into the + // scope's draft, which keeps it until the composition settles or the composer comes back. + attachReferences: (paths) => { + if (mountedRef.current && !isComposing()) { + attachResolvedPaths(paths, null) + return + } appendNativeChatDraftCache( attachmentScopeKey, paths.map(formatNativeChatFileReference).join(' ') ) + } }), [ + attachResolvedPaths, attachmentScopeKey, beginPendingImageAttachment, dropPendingImageAttachment, + isComposing, resolvePendingImageAttachment ] ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index 9ef6e04ba7fb..2bce538a8499 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -83,12 +83,16 @@ export function useNativeChatPasteLifetime(args: { if (id) { track(id, '', owner) } + // Through the scope's cache, so it shows even in a composer that came back since. const reveal = (): void => { - if (id && lifetime.active) { - const previewUrl = URL.createObjectURL(imageFile) + if (!id) { + return + } + const previewUrl = lifetime.active ? URL.createObjectURL(imageFile) : undefined + if (previewUrl) { lifetime.pending.set(id, previewUrl) - revealPendingImageAttachment?.(id, previewUrl) } + revealPendingImageAttachment?.(id, previewUrl) } return { id, reveal } } From 2bead962374b6fb1dbfad24baf4dbd5f33dd27d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:55:15 -0700 Subject: [PATCH 24/40] fix(native-chat): never evict a pane's attachments while a composer shows them or one is on its way The pane attachment cache is now where chips live, so its 128-scope bound passes over a scope a mounted composer subscribes to or that still holds a pending attachment, and evicts the oldest scope nobody uses instead. Protected scopes are bounded by mounted composers and attachments in flight. --- .../native-chat-attachment-cache.test.ts | 49 +++++++++++++++++++ .../native-chat-attachment-cache.ts | 12 ++++- .../native-chat-composer-scope-cache.ts | 15 ++++-- 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts new file mode 100644 index 000000000000..ce3ec26e898e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + appendNativeChatAttachmentCache, + clearNativeChatAttachmentCacheForTests, + readNativeChatAttachmentCache, + resolveNativeChatPendingAttachment, + subscribeToNativeChatAttachmentCache, + updateNativeChatAttachmentCache +} from './native-chat-attachment-cache' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' + +afterEach(() => clearNativeChatAttachmentCacheForTests()) + +function fillWithOtherScopes(): void { + for (let index = 0; index <= NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX; index += 1) { + appendNativeChatAttachmentCache(`other-${index}`, [ + { id: `o${index}`, path: `/tmp/${index}.png` } + ]) + } +} + +describe('the pane attachment cache bound', () => { + it('never evicts a scope a composer is showing, however many newer scopes arrive', () => { + appendNativeChatAttachmentCache('pane-a', [{ id: 'a1', path: '/tmp/a.png' }]) + const unsubscribe = subscribeToNativeChatAttachmentCache('pane-a', () => {}) + + fillWithOtherScopes() + + expect(readNativeChatAttachmentCache('pane-a')).toEqual([{ id: 'a1', path: '/tmp/a.png' }]) + // The bound still holds: the oldest scope nobody shows went instead. + expect(readNativeChatAttachmentCache('other-0')).toEqual([]) + unsubscribe() + }) + + it('never evicts a scope whose attachment is still on its way, so it can still settle', () => { + updateNativeChatAttachmentCache('pane-b', () => [{ id: 'b1', path: '', pending: true }]) + + fillWithOtherScopes() + + expect(resolveNativeChatPendingAttachment('pane-b', 'b1', '/srv/b.png')).toBe(true) + expect(readNativeChatAttachmentCache('pane-b')).toEqual([{ id: 'b1', path: '/srv/b.png' }]) + }) + + it('evicts the oldest scope nobody uses', () => { + appendNativeChatAttachmentCache('pane-c', [{ id: 'c1', path: '/tmp/c.png' }]) + fillWithOtherScopes() + expect(readNativeChatAttachmentCache('pane-c')).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-attachment-cache.ts index f673071e3c65..10bbefd537c4 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-cache.ts @@ -41,8 +41,9 @@ export function updateNativeChatAttachmentCache( if (next.length === 0) { attachmentCache.delete(scopeKey) } else { - // LRU-bounded so attachments for permanently-removed panes can't accumulate. - setBoundedScopeCacheEntry(attachmentCache, scopeKey, next) + // LRU-bounded so attachments for permanently-removed panes can't accumulate. A scope a composer + // shows or an attachment is still on its way to is never evicted: this is where they live. + setBoundedScopeCacheEntry(attachmentCache, scopeKey, next, scopeInUse) } listeners.get(scopeKey)?.forEach((listener) => listener()) } @@ -72,6 +73,13 @@ export function appendNativeChatAttachmentCache( } } +function scopeInUse(scopeKey: string): boolean { + return ( + (listeners.get(scopeKey)?.size ?? 0) > 0 || + nativeChatAttachmentSnapshot(scopeKey).some((attachment) => attachment.pending === true) + ) +} + function hasPending(scopeKey: string, id: string): boolean { return nativeChatAttachmentSnapshot(scopeKey).some( (attachment) => attachment.id === id && attachment.pending === true diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts index fc26e303a9f2..99e4be44fe58 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -10,15 +10,20 @@ export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128 export function setBoundedScopeCacheEntry( cache: Map, scopeKey: string, - value: T + value: T, + /** A scope still in use, which eviction passes over for the oldest one that is not. */ + isProtected: (scopeKey: string) => boolean = () => false ): void { cache.delete(scopeKey) cache.set(scopeKey, value) - while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { - const oldest = cache.keys().next().value - if (oldest === undefined) { + let excess = cache.size - NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + for (const key of cache.keys()) { + if (excess <= 0) { break } - cache.delete(oldest) + if (!isProtected(key)) { + cache.delete(key) + excess -= 1 + } } } From 82c588078d29eca79e996af1a41865409de2d9b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:58:38 -0700 Subject: [PATCH 25/40] fix(native-chat): never evict the scope just written when every older one is in use --- .../native-chat-attachment-cache.test.ts | 18 ++++++++++++++++++ .../native-chat-composer-scope-cache.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts index ce3ec26e898e..4732e97979f7 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-cache.test.ts @@ -46,4 +46,22 @@ describe('the pane attachment cache bound', () => { fillWithOtherScopes() expect(readNativeChatAttachmentCache('pane-c')).toEqual([]) }) + + it('keeps a write to an unused scope when every older scope is in use', () => { + const unsubscribes = Array.from( + { length: NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX }, + (_, index) => { + appendNativeChatAttachmentCache(`shown-${index}`, [ + { id: `s${index}`, path: `/tmp/${index}.png` } + ]) + return subscribeToNativeChatAttachmentCache(`shown-${index}`, () => {}) + } + ) + + appendNativeChatAttachmentCache('pane-d', [{ id: 'd1', path: '/tmp/d.png' }]) + + expect(readNativeChatAttachmentCache('pane-d')).toEqual([{ id: 'd1', path: '/tmp/d.png' }]) + expect(readNativeChatAttachmentCache('shown-0')).toHaveLength(1) + unsubscribes.forEach((unsubscribe) => unsubscribe()) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts index 99e4be44fe58..69579207e7dc 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -21,7 +21,8 @@ export function setBoundedScopeCacheEntry( if (excess <= 0) { break } - if (!isProtected(key)) { + // Never the entry just written: when every older scope is in use, the cache runs over instead. + if (key !== scopeKey && !isProtected(key)) { cache.delete(key) excess -= 1 } From 690970c1b08a95a1f81e3f54cb394c7a883a1758 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:50:30 -0700 Subject: [PATCH 26/40] fix(native-chat): keep the attachment store out of the RPC method table's imports The method table is imported far and wide (the SSH relay's CLI included), and the attachment RPCs pulled in the store, whose preview read loads modules that read fs constants at load: any test that mocks fs/promises without them failed to import. The installed store now lives in a small registry module; only the host wiring loads the store itself. --- .../agent-session-attachment-store-registry.ts | 15 +++++++++++++++ .../agent-session-attachment-store.ts | 11 ----------- .../structured-agent-session-attachments.test.ts | 6 ++---- .../structured-agent-session-attachments.ts | 6 ++---- ...ctured-agent-session-attachment-wiring.test.ts | 2 +- .../structured-agent-session-attachment-wiring.ts | 6 ++---- 6 files changed, 22 insertions(+), 24 deletions(-) create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts new file mode 100644 index 000000000000..e0f4201618a3 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts @@ -0,0 +1,15 @@ +// The installed attachment store, apart from the store itself: the RPC methods are imported +// wherever the method table is, and must not load the store's file-reading dependencies with it. + +import type { AgentSessionAttachmentStore } from './agent-session-attachment-store' + +let installedStore: AgentSessionAttachmentStore | null = null + +/** Installed with the structured host, which owns the state directory the store lives in. */ +export function setAgentSessionAttachmentStore(store: AgentSessionAttachmentStore | null): void { + installedStore = store +} + +export function getAgentSessionAttachmentStore(): AgentSessionAttachmentStore | null { + return installedStore +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index a0e3342b0f12..a9626e86afaf 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -223,14 +223,3 @@ export async function removeQuietly(path: string): Promise { // Cleanup is best effort: a file that will not go is retried by the next sweep. await rm(path, { recursive: true, force: true }).catch(() => {}) } - -let installedStore: AgentSessionAttachmentStore | null = null - -/** Installed with the structured host, which owns the state directory the store lives in. */ -export function setAgentSessionAttachmentStore(store: AgentSessionAttachmentStore | null): void { - installedStore = store -} - -export function getAgentSessionAttachmentStore(): AgentSessionAttachmentStore | null { - return installedStore -} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts index 59124ec7e72b..e2b33bc9bb6a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -9,10 +9,8 @@ import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' -import { - AgentSessionAttachmentStore, - setAgentSessionAttachmentStore -} from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { setAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' import { isMobileE2EETextPayloadWithinLimit } from '../mobile-e2ee-outbound-admission' import { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts index 655869ebb2ff..2c45d75c662e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -17,10 +17,8 @@ import { AttachmentUploadStartParams } from '../../../../shared/rpc-contract/agent-session-attachment-params' import { remoteFileContentBudget } from './files-remote-content-budget' -import { - getAgentSessionAttachmentStore, - type AgentSessionAttachmentStore -} from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import type { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { getAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' async function requireStore( ctx: RpcContext diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts index 8b6dbce639d2..0cfc53dee693 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { getAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type Database from '../sqlite/sync-database' diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts index 4959455e8f1b..fcbea31a95a9 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -2,10 +2,8 @@ // the same state directory as the journal, whose database holds the claims the sweeps honor. import { mkdirSync } from 'node:fs' -import { - AgentSessionAttachmentStore, - setAgentSessionAttachmentStore -} from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { setAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' import { startAgentSessionAttachmentSweeps, type AgentSessionAttachmentSweeper From ca5bcb51b6cfe6b6f711a672f102d85cbeded57b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:46:05 -0700 Subject: [PATCH 27/40] refactor(native-chat): the submission hook gets its own module Merging main added the operation receipt to the queued-message insert, which put journal-queued-messages.ts over the 300-line limit. The submission hook only uses the queued-messages object's public methods, so it moves out. --- .../journal-queued-messages.ts | 24 ++----------------- .../agent-session-journal/journal-store.ts | 3 ++- .../journal-submission-hook.ts | 23 ++++++++++++++++++ 3 files changed, 27 insertions(+), 23 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-hook.ts diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index d1271027a820..6f21ff327a13 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,8 +13,8 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' -import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' +import type { JournalOperationReceipt } from './journal-row-writer' +import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, @@ -362,26 +362,6 @@ export class JournalQueuedMessages { } } -/** The submission append's hook, inside its transaction: converts the draft it hands off, if any, - * and claims the stored attachments it names. A client's new message must name only attachments - * still stored; a draft's conversion was claimed when the draft was written. */ -export function journalSubmissionHook( - queuedMessages: JournalQueuedMessages, - input: Pick, - consume: JournalSubmissionConsume | undefined -): JournalRowTransactionHook { - return (db) => { - if (consume) { - queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId }) - } - queuedMessages.claimAttachmentsInTransaction( - db, - input.body, - input.origin === 'client' && !consume - ) - } -} - export class QueuedMessageNotConsumableError extends Error { constructor( readonly messageId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 7d95b746ef9c..db2cd5f72f74 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -57,7 +57,8 @@ import type { JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import { journalSubmissionHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages } from './journal-queued-messages' +import { journalSubmissionHook } from './journal-submission-hook' import { journalQueueResumeRowBuilder, journalStopEventRowBuilder diff --git a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts new file mode 100644 index 000000000000..ca99e1dc8348 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts @@ -0,0 +1,23 @@ +import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' + +/** The submission append's hook, inside its transaction: converts the draft it hands off, if any, + * and claims the stored attachments it names. A client's new message must name only attachments + * still stored; a draft's conversion was claimed when the draft was written. */ +export function journalSubmissionHook( + queuedMessages: JournalQueuedMessages, + input: Pick, + consume: JournalSubmissionConsume | undefined +): JournalRowTransactionHook { + return (db) => { + if (consume) { + queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId }) + } + queuedMessages.claimAttachmentsInTransaction( + db, + input.body, + input.origin === 'client' && !consume + ) + } +} From ba43a87e10caf60f54e33855fe4243ed7ed5f38a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:13:46 -0700 Subject: [PATCH 28/40] refactor(native-chat): fit the attachment sweep and paste upload into main's line budgets After merging main, the record store and the clipboard handlers each ran a few lines over the 300-line budget. The sweep now asks the record store whether a chat is recorded through the two lookups it already has (readable or unreadable) instead of a new id-listing method, and the paired-server paste upload lives beside the other attachment uploads. No behavior changes. --- .../ipc/agent-session-attachment-upload.ts | 23 +++++++++++++++++++ .../agent-session-attachment-claims.ts | 2 +- .../agent-session-attachment-sweep.ts | 2 +- .../runtime/agent-session-record-store.ts | 6 ----- ...ed-agent-session-attachment-wiring.test.ts | 4 ++-- ...uctured-agent-session-attachment-wiring.ts | 9 ++++++-- src/main/window/clipboard-ipc-handlers.ts | 18 ++++----------- 7 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts index 83bf6c094553..258c5d355d9a 100644 --- a/src/main/ipc/agent-session-attachment-upload.ts +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -2,16 +2,20 @@ // the runtime socket, so it pumps the bytes into the server's attachment store and hands the // renderer only the server path. +import { randomUUID } from 'node:crypto' import { lstat } from 'node:fs/promises' import { basename } from 'node:path' import { AGENT_SESSION_ATTACHMENT_CHUNK_BYTES, AGENT_SESSION_ATTACHMENT_MAX_BYTES, + agentSessionPastedImageName, + type AgentSessionAttachmentClipboardTarget, type AgentSessionAttachmentPathUploadResult, type AgentSessionAttachmentUploadCommitResult, type AgentSessionAttachmentUploadStartResult, type AgentSessionAttachmentUploadTarget } from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' import { isRuntimeEnvironmentManuallyDisconnected, @@ -123,6 +127,25 @@ export function uploadBufferToAgentSessionAttachments( }) } +/** A pasted image for a structured chat on a paired server, stored in that server's store. */ +export async function uploadPastedImageToAgentSessionAttachments( + target: AgentSessionAttachmentClipboardTarget, + runtimeEnvironmentId: string, + userDataPath: string, + buffer: Buffer +): Promise { + const stored = await uploadBufferToAgentSessionAttachments( + { + ...target, + environmentId: resolveEnvironment(userDataPath, runtimeEnvironmentId).id, + userDataPath + }, + agentSessionPastedImageName(Date.now(), randomUUID()), + buffer + ) + return stored.path +} + /** * Upload dropped or picked client-local files, in input order. A folder is skipped as * unsupported: a chat attachment is a file. diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts index e71a2ba25e81..ac41b427e58b 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -172,7 +172,7 @@ export function finishUploadSweep(db: Database.Database, uploadId: string): void */ export function pruneAgentSessionAttachmentClaims( db: Database.Database, - facts: { root: string; recordedSessionIds: ReadonlySet | null } + facts: { root: string; recordedSessionIds: Pick, 'has'> | null } ): number { const rows = db .prepare('SELECT upload_id, session_id FROM agent_session_attachment_claims') diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts index 1c85f5a22199..cb685a018939 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts @@ -29,7 +29,7 @@ export type AgentSessionAttachmentSweepFacts = { database: () => Database.Database | null /** Every chat this host holds a record for; null while that list is incomplete (records still * owed their import), when no chat's claims can be judged orphaned. */ - recordedSessionIds: () => ReadonlySet | null + recordedSessionIds: () => Pick, 'has'> | null } export type AgentSessionAttachmentSweepResult = { removed: string[] } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 856f9200d47b..e2e2da7e1fec 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -113,12 +113,6 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] - /** Every chat this host holds a row for, readable or not. */ - listRecordedSessionIds = (): string[] => [ - ...this.state.records.keys(), - ...this.state.unreadableRecords.keys() - ] - /** Whether this host has recorded a chat, readable or not. Nothing removes a record row. */ holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts index 0cfc53dee693..f5f6df4269aa 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -26,9 +26,9 @@ afterEach(async () => { }) function install(directory = stateDirectory): void { - const records: Pick = { + const records: Pick = { getRecord: () => null, - listRecordedSessionIds: () => [] + isSessionUnreadable: () => false } // Written by a newer Orca: the sweep reads no claims, so nothing here touches the connection. const journal: Pick< diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts index fcbea31a95a9..8c1b1947a65b 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -20,7 +20,7 @@ let sweeper: AgentSessionAttachmentSweeper | null = null export function installAgentSessionAttachments(deps: { stateDirectory: string - store: Pick + store: Pick journalDatabase: Pick< JournalHostDatabase, 'readOnly' | 'isClosed' | 'db' | 'legacyRecordImportOwed' @@ -53,7 +53,12 @@ export function installAgentSessionAttachments(deps: { recordedSessionIds: () => deps.journalDatabase.legacyRecordImportOwed ? null - : new Set(deps.store.listRecordedSessionIds()) + : { + // Readable or not: an unreadable chat's claims still protect its uploads. + has: (sessionId) => + deps.store.getRecord(sessionId) !== null || + deps.store.isSessionUnreadable(sessionId) + } }, { initialDelayMs: FIRST_SWEEP_DELAY_MS, diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index ffeb3d1443d4..05a09a54bb81 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -7,7 +7,6 @@ import { type WebContents } from 'electron' import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' import { PATH_ACCESS_DENIED_MESSAGE } from '../ipc/filesystem-auth' @@ -40,9 +39,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' -import { uploadBufferToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' -import { agentSessionPastedImageName } from '../../shared/agent-session-attachments' -import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' @@ -65,17 +62,12 @@ async function saveClipboardImageBufferForTarget( const runtimeEnvironmentId = args?.runtimeEnvironmentId?.trim() // A structured chat on a paired server keeps its pasted images in that server's store. if (runtimeEnvironmentId && args?.agentSessionAttachment) { - const userDataPath = app.getPath('userData') - const stored = await uploadBufferToAgentSessionAttachments( - { - ...args.agentSessionAttachment, - environmentId: resolveEnvironment(userDataPath, runtimeEnvironmentId).id, - userDataPath - }, - agentSessionPastedImageName(Date.now(), randomUUID()), + return uploadPastedImageToAgentSessionAttachments( + args.agentSessionAttachment, + runtimeEnvironmentId, + app.getPath('userData'), buffer ) - return stored.path } // Why (#17679): with a runtime owner, a connectionId names one of the RUNTIME's SSH // connections (nested Remote Server -> SSH), not one this process dialed. Looking it up From 491ec81fa551d62c1a328b0ae2324560bf81c92f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:13:46 -0700 Subject: [PATCH 29/40] fix(native-chat): pending chips sit beside main's saved draft, which keeps server-stored images Main now saves a composer's draft (text and settled images) in one store that survives a reload or quit. This branch kept every chip, settled or not, in its own pane cache. After the merge the saved draft owns settled images and the pane cache holds only chips still on their way: they come back pending in a composer a prompt card remounted, hold Send, settle into the saved draft whichever composer is showing, and are never saved themselves, so a restored draft cannot bring back an upload as if it were attached. An image a paired server stored for the chat is saved with the draft as the real image (a pasted one is no longer turned into a "Not kept" placeholder), and the restore check leaves it to that server, whose claim at Send refuses one it no longer holds, instead of asking this machine's disk for a path that only exists on the server. Also: previews and the pending subscription move into their own small hooks to fit the line budget, the "local attachments" notice moves to the composer-target module so the attachments hook no longer pulls in the upload module's imports, and tests follow main's new mocks. --- .../NativeChatImageAttachmentPreview.test.tsx | 10 +- .../native-chat-attachment-upload.ts | 7 +- ...ive-chat-composer-draft-lifecycle.test.tsx | 32 +++++- .../native-chat-composer-draft-store.test.ts | 11 +++ .../native-chat-composer-target.ts | 7 ++ .../native-chat-pending-attachment-cache.ts | 36 ++++++- ...ative-chat-composer-attachment-previews.ts | 77 +++++++++++++++ ...-native-chat-composer-attachments.test.tsx | 16 +-- .../use-native-chat-composer-attachments.ts | 97 ++----------------- ...-native-chat-external-attachments.test.tsx | 3 +- 10 files changed, 182 insertions(+), 114 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 0483f4a71903..dcfa8c4a24c5 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -78,13 +78,9 @@ describe('NativeChatImageAttachmentPreview', () => { worktreePath: null }) } - expect(mocks.useLocalImageSrc).toHaveBeenCalledWith( - path, - path, - undefined, - expect.anything(), - { kind: 'chat-image' } - ) + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(path, path, undefined, expect.anything(), { + kind: 'chat-image' + }) expect(screen.getByRole('img', { name: 'shot.png' }).getAttribute('src')).toBe( 'blob:from-server' ) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index bec1340fdd95..0cd2733a5407 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -177,12 +177,7 @@ export function nativeChatAttachFailedNotice(names: readonly string[], cause = ' }) } -export function nativeChatLocalAttachmentUnsupportedNotice(): string { - return translate( - 'components.native-chat.composer.localAttachmentUnsupported', - 'Local attachments are not available for remote sessions.' - ) -} +export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-composer-target' /** * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 13e4bde85030..4acafdb5ccbd 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -443,8 +443,12 @@ describe('native-chat composer draft lifecycle', () => { { scopeKey: 'tab-1:pane' } ) ) - expect(second.api?.attachments.imageAttachments.map(({ path }) => path)).toEqual([ - '/repo/x.png' + // The paste still on its way comes back pending, so Send waits for it. + expect( + second.api?.attachments.imageAttachments.map(({ path, pending }) => ({ path, pending })) + ).toEqual([ + { path: '/repo/x.png', pending: undefined }, + { path: '', pending: true } ]) await act(async () => second.api?.attachments.clearImageAttachments()) await unmount() @@ -559,6 +563,30 @@ describe('native-chat composer draft lifecycle', () => { ]) }) + it('leaves a restored image a paired server stored to that server to check', async () => { + // Not on this machine's disk, so a local check could only call it gone. + const pathExists = vi.fn(async () => false) + vi.stubGlobal('api', { fs: { pathExists } }) + const stored = { id: 'a', path: '/srv/orca/agent-session-attachments/0f6c/shot.png' } + storage.drafts.set('agent-session:s1', { text: 'see this', images: [stored], savedAt: 1 }) + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + try { + await mount( + createElement( + composer(hooks, (next) => (seen.api = next)), + { scopeKey: 'agent-session:s1' } + ) + ) + await act(async () => {}) + } finally { + vi.unstubAllGlobals() + } + + expect(pathExists).not.toHaveBeenCalled() + expect(seen.api?.attachments.imageAttachments).toEqual([stored]) + }) + it('shows a restored paste from Orca’s paste folder once main confirms it is kept, and marks the rest', async () => { const folder = '/Users/me/Library/Application Support/orca/native-chat-pastes' let answer: () => void = () => {} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index e0da93ddcd10..dda79eaf9603 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -377,6 +377,17 @@ describe('native-chat composer draft store', () => { ]) }) + it('saves a paste a paired server stored for the chat as the real image', async () => { + const stored = { + id: 'p-1', + path: '/srv/orca/agent-session-attachments/0f6c/orca-paste-1-0f.png' + } + modules.attachments.appendNativeChatAttachmentCache('agent-session:s1', [stored]) + + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('agent-session:s1')).toEqual([stored]) + }) + it('puts a re-attached image in the place of the one to attach again', async () => { storage.drafts.set('tab-1:pane', { text: 'see', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index c88882f7ced3..6244e7d86f23 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -28,4 +28,11 @@ export function nativeChatComposerTargetIsRemote(ptyId: string | null): boolean return ptyId !== null && isRemoteRuntimePtyId(ptyId) } +export function nativeChatLocalAttachmentUnsupportedNotice(): string { + return translate( + 'components.native-chat.composer.localAttachmentUnsupported', + 'Local attachments are not available for remote sessions.' + ) +} + export { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index 67ff34b73806..1e5f06f989ce 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -6,7 +6,9 @@ // attached. Every chip dies with its operation: settled or dropped when the save or upload settles // (each bounded by its call timeout), or removed by the user. +import { useCallback, useSyncExternalStore } from 'react' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { appendToNativeChatComposerDraft } from './native-chat-composer-draft-store' const EMPTY: readonly NativeChatComposerImageAttachment[] = Object.freeze([]) const pendingCache = new Map() @@ -19,10 +21,7 @@ export function nativeChatPendingAttachmentSnapshot( return pendingCache.get(scopeKey) ?? EMPTY } -function writePending( - scopeKey: string, - next: readonly NativeChatComposerImageAttachment[] -): void { +function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { if (next.length === 0) { pendingCache.delete(scopeKey) } else { @@ -46,6 +45,17 @@ export function subscribeToNativeChatPendingAttachments( } } +/** The scope's pending chips, re-rendering when they change. */ +export function useNativeChatPendingAttachments( + scopeKey: string +): readonly NativeChatComposerImageAttachment[] { + const subscribe = useCallback( + (listener: () => void) => subscribeToNativeChatPendingAttachments(scopeKey, listener), + [scopeKey] + ) + return useSyncExternalStore(subscribe, () => nativeChatPendingAttachmentSnapshot(scopeKey)) +} + /** Adds a chip still on its way. Preview URLs stay with the composer that minted them. */ export function addNativeChatPendingAttachment( scopeKey: string, @@ -71,6 +81,24 @@ export function takeNativeChatPendingAttachment( return taken } +/** Settles a pending chip into the scope's draft, as an image the user attached. False when the + * user already removed it. */ +export function settleNativeChatPendingAttachment( + scopeKey: string, + id: string, + path: string, + connectionId?: string | null +): boolean { + if (!takeNativeChatPendingAttachment(scopeKey, id)) { + return false + } + appendToNativeChatComposerDraft(scopeKey, { + images: [{ id, path, ...(connectionId ? { connectionId } : {}) }], + fromUser: true + }) + return true +} + /** Shows a pending chip that was held out of sight, such as while a server was asked first. */ export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { const current = nativeChatPendingAttachmentSnapshot(scopeKey) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts new file mode 100644 index 000000000000..02d18e2dc2ab --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +const NO_PREVIEWS: ReadonlyMap = new Map() + +/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ +function releasePreviewUrl(previewUrl: string | undefined): void { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl) + } +} + +/** The clipboard previews a composer minted, by chip id: its own, since no store holds them. A + * preview whose chip left both the draft and the pending chips (sent, or removed elsewhere) is + * released. */ +export function useNativeChatComposerAttachmentPreviews( + settled: readonly NativeChatComposerImageAttachment[], + pending: readonly NativeChatComposerImageAttachment[] +): { + previews: ReadonlyMap + setPreview: (id: string, previewUrl: string | undefined) => void + releasePreview: (id: string) => void + releaseAllPreviews: () => void +} { + const [previews, setPreviews] = useState>(NO_PREVIEWS) + // Read by callbacks between renders; only they change it, always together with the state. + const previewsRef = useRef(previews) + const updatePreviews = useCallback((next: ReadonlyMap) => { + previewsRef.current = next + setPreviews(next) + }, []) + useEffect(() => { + const current = previewsRef.current + const gone = [...current.keys()].filter( + (id) => !settled.some((image) => image.id === id) && !pending.some((chip) => chip.id === id) + ) + if (gone.length === 0) { + return + } + const next = new Map(current) + for (const id of gone) { + releasePreviewUrl(next.get(id)) + next.delete(id) + } + updatePreviews(next) + }, [pending, settled, updatePreviews]) + + const setPreview = useCallback( + (id: string, previewUrl: string | undefined) => { + if (previewUrl) { + updatePreviews(new Map(previewsRef.current).set(id, previewUrl)) + } + }, + [updatePreviews] + ) + + const releasePreview = useCallback( + (id: string) => { + const current = previewsRef.current + if (!current.has(id)) { + return + } + releasePreviewUrl(current.get(id)) + const next = new Map(current) + next.delete(id) + updatePreviews(next) + }, + [updatePreviews] + ) + + const releaseAllPreviews = useCallback(() => { + previewsRef.current.forEach(releasePreviewUrl) + updatePreviews(NO_PREVIEWS) + }, [updatePreviews]) + + return { previews, setPreview, releasePreview, releaseAllPreviews } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 38b5ac351bef..3285d82d0bcc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -8,6 +8,7 @@ import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import type { NativeChatResolvedTarget } from './native-chat-composer-target' +import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache' import { readNativeChatDraftCache } from './native-chat-draft-cache' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' @@ -556,7 +557,7 @@ describe('useNativeChatComposerAttachments', () => { ) }) - it('keeps a pending chip in the scope cache, without its preview, beside a settled one', async () => { + it('keeps a pending chip in the pending cache, without its preview, and the settled one in the draft', async () => { const probe = await renderProbe('pty-1') let pendingId: string | null = null act(() => { @@ -566,11 +567,14 @@ describe('useNativeChatComposerAttachments', () => { probe.latest().attachResolvedPaths(['/tmp/settled.png']) }) - // A composer that comes back must still wait for the pending one. - const cached = readNativeChatAttachmentCache('pty-1') - expect(cached).toMatchObject([{ id: pendingId, pending: true }, { path: '/tmp/settled.png' }]) - expect(cached.every((attachment) => attachment.previewUrl === undefined)).toBe(true) - expect(probe.latest().imageAttachments[0]?.previewUrl).toBe('blob:preview-1') + // A composer that comes back must still wait for the pending one, which no draft saves. + expect(nativeChatPendingAttachmentSnapshot('pty-1')).toEqual([ + { id: pendingId, path: '', pending: true } + ]) + expect(readNativeChatAttachmentCache('pty-1')).toMatchObject([{ path: '/tmp/settled.png' }]) + expect( + probe.latest().imageAttachments.find((attachment) => attachment.id === pendingId)?.previewUrl + ).toBe('blob:preview-1') act(() => probe.root.unmount()) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 28306cd85968..85adfc0e1ce0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -4,12 +4,12 @@ import { useEffect, useMemo, useRef, - useState, useSyncExternalStore, type RefObject } from 'react' import { nativeChatComposerTargetIsRemote, + nativeChatLocalAttachmentUnsupportedNotice, type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -24,18 +24,18 @@ import { import { useRestoredNativeChatComposerDraftImageCheck } from './native-chat-composer-draft-image-check' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' -import { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-attachment-upload' import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' import { addNativeChatPendingAttachment, clearNativeChatPendingAttachments, clearNativeChatPendingAttachmentsForTests, - nativeChatPendingAttachmentSnapshot, revealNativeChatPendingAttachment, - subscribeToNativeChatPendingAttachments, - takeNativeChatPendingAttachment + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment, + useNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews' import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' export type UseNativeChatComposerAttachmentsArgs = { @@ -95,21 +95,9 @@ export function useNativeChatComposerAttachments({ const restoring = useRestoredNativeChatComposerDraftImageCheck(attachmentScopeKey, subscribe) // Chips still on their way live outside the composer, so one a prompt card unmounted comes back // still pending and settles into the draft whichever composer is showing. - const subscribePending = useCallback( - (listener: () => void) => subscribeToNativeChatPendingAttachments(attachmentScopeKey, listener), - [attachmentScopeKey] - ) - const pending = useSyncExternalStore(subscribePending, () => - nativeChatPendingAttachmentSnapshot(attachmentScopeKey) - ) - // The clipboard previews this composer minted are its own; no store holds them. - const [previews, setPreviews] = useState>(NO_PREVIEWS) - // Read by callbacks between renders; only they change it, always together with the state. - const previewsRef = useRef(previews) - const updatePreviews = useCallback((next: ReadonlyMap) => { - previewsRef.current = next - setPreviews(next) - }, []) + const pending = useNativeChatPendingAttachments(attachmentScopeKey) + const { previews, setPreview, releasePreview, releaseAllPreviews } = + useNativeChatComposerAttachmentPreviews(settled, pending) const imageAttachments = useMemo( () => [ ...settled.map((image) => { @@ -126,22 +114,6 @@ export function useNativeChatComposerAttachments({ ], [pending, previews, restoring, settled] ) - // A preview whose image left the draft (sent, or removed elsewhere) is released. - useEffect(() => { - const current = previewsRef.current - const gone = [...current.keys()].filter( - (id) => !settled.some((image) => image.id === id) && !pending.some((chip) => chip.id === id) - ) - if (gone.length === 0) { - return - } - const next = new Map(current) - for (const id of gone) { - releasePreviewUrl(next.get(id)) - next.delete(id) - } - updatePreviews(next) - }, [pending, settled, updatePreviews]) const imageAttachmentCounter = useRef(0) const mountedRef = useRef(true) useEffect(() => { @@ -151,29 +123,6 @@ export function useNativeChatComposerAttachments({ } }, []) - const setPreview = useCallback( - (id: string, previewUrl: string | undefined) => { - if (previewUrl) { - updatePreviews(new Map(previewsRef.current).set(id, previewUrl)) - } - }, - [updatePreviews] - ) - - const releasePreview = useCallback( - (id: string) => { - const current = previewsRef.current - if (!current.has(id)) { - return - } - releasePreviewUrl(current.get(id)) - const next = new Map(current) - next.delete(id) - updatePreviews(next) - }, - [updatePreviews] - ) - const nextAttachmentId = useCallback((): string => { imageAttachmentCounter.current += 1 return `${Date.now()}-${imageAttachmentCounter.current}` @@ -315,8 +264,7 @@ export function useNativeChatComposerAttachments({ imageAttachments, attachResolvedPaths, clearImageAttachments: () => { - previewsRef.current.forEach(releasePreviewUrl) - updatePreviews(NO_PREVIEWS) + releaseAllPreviews() clearNativeChatPendingAttachments(attachmentScopeKey) updateNativeChatComposerDraft(attachmentScopeKey, { images: [] }, 'immediate') }, @@ -341,33 +289,6 @@ export function useNativeChatComposerAttachments({ } } -const NO_PREVIEWS: ReadonlyMap = new Map() - -/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ -function releasePreviewUrl(previewUrl: string | undefined): void { - if (previewUrl?.startsWith('blob:')) { - URL.revokeObjectURL(previewUrl) - } -} - -/** Settles a pending chip into the scope's draft. False when the user already removed it. */ -export function settleNativeChatPendingAttachment( - scopeKey: string, - id: string, - path: string, - connectionId?: string | null -): boolean { - if (!takeNativeChatPendingAttachment(scopeKey, id)) { - return false - } - appendNativeChatAttachmentCache( - scopeKey, - [{ id, path, ...(connectionId ? { connectionId } : {}) }], - { fromUser: true } - ) - return true -} - export function readNativeChatAttachmentCache( scopeKey: string ): NativeChatComposerImageAttachment[] { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 8762e7c18387..17d847af73a5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -553,7 +553,8 @@ describe('useNativeChatExternalAttachments', () => { expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ '/srv/agent-session-attachments/u2/notes.md' ]) - expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() + // The client never reads its own disk for these paths: the server stores the bytes. + expect(mocks.stat).not.toHaveBeenCalled() }) it('does not insert a file whose chip the user removed while it uploaded', async () => { From 12a65ab4a8e8cd634895a23e2c0b5ab9c20f19ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:28:59 -0700 Subject: [PATCH 30/40] test(native-chat): give the claims test main's provider handle and message source --- .../agent-session-attachment-claims.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts index 1f95711bd345..36e4e42f05c3 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -20,6 +20,8 @@ import { agentSessionAttachmentReferences, agentSessionAttachmentStoreRoot } from './agent-session-attachment-references' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' const UPLOAD_A = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' const UPLOAD_B = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' @@ -82,7 +84,7 @@ describe('claims written with the message', () => { workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } let stateDirectory: string let storeRoot: string @@ -217,6 +219,7 @@ describe('claims written with the message', () => { body: missing, fingerprint: 'fp', hostInstance: 'p', + source: USER_MESSAGE_SOURCE, requireAttachments: true }) .catch((caught: unknown) => caught) @@ -230,6 +233,7 @@ describe('claims written with the message', () => { body, fingerprint: 'fp', hostInstance: 'p', + source: USER_MESSAGE_SOURCE, requireAttachments: true }) const replacement = await open('session-d') @@ -238,6 +242,7 @@ describe('claims written with the message', () => { body, fingerprint: 'fp-d', hostInstance: 'p', + source: USER_MESSAGE_SOURCE, carriedFrom: IDENTITY.sessionId }) expect(claims()).toEqual([ @@ -255,6 +260,7 @@ describe('claims written with the message', () => { body, fingerprint: 'fp', hostInstance: 'p', + source: USER_MESSAGE_SOURCE, requireAttachments: true }) await rm(join(storeRoot, UPLOAD_A), { recursive: true }) From 235239b954ea97ef33cfbed4f226c7da30eff6aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:49:24 -0700 Subject: [PATCH 31/40] fix(native-chat): a paste still uploading dies with its tab or workspace A pasted image still uploading to a paired server sits in the pending chip cache, which by design outlives the composer and its tab. Removing the workspace deleted its saved drafts but left those chips, so the upload finishing afterwards recreated and saved the deleted draft. With the workspace's tabs gone it had no owner either, so no later workspace cleanup could ever remove it. A pending chip now records the owner its draft would have, resolved the way the draft store resolves one when the chip is added. Workspace removal and a user's tab close drop pending chips by the same owner, conversation and tab matches they delete drafts by, and a chip that settles after its chat's tab closed writes its draft under the owner it was begun in. --- .../native-chat-composer-draft-store.ts | 21 +- ...tive-chat-pending-attachment-cache.test.ts | 77 ++++++- .../native-chat-pending-attachment-cache.ts | 57 +++++- .../native-chat-launch-draft-teardown.test.ts | 21 ++ .../workspace-chat-draft-removal.test.ts | 188 ++++++++++++++---- .../teardown/removed-worktree-chat-drafts.ts | 11 +- .../src/store/terminals/terminal-tab-close.ts | 2 + 7 files changed, 331 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index b8ca9d888b00..60dddf06d2f7 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -69,6 +69,8 @@ export type NativeChatComposerDraftChange = { images?: readonly NativeChatComposerDraftImage[] /** Text shown but never saved while the draft still holds exactly it. */ unsavedText?: string + /** A new draft's owner when its chat may no longer be open to name it: the one it was begun in. */ + owner?: NativeChatComposerDraftOwner } const EMPTY_DRAFT: DraftRecord = { text: '', images: [], savedAt: 0 } @@ -82,6 +84,13 @@ export function setNativeChatComposerDraftOwnerResolver( resolveOwner = resolver } +/** The owner a new draft in this scope would record now. */ +export function resolveNativeChatComposerDraftOwner( + scopeKey: string +): NativeChatComposerDraftOwner | undefined { + return resolveOwner?.(scopeKey) +} + /** Composers render from the store; this tells one that its pane's draft changed. */ export function subscribeToNativeChatComposerDraft( scopeKey: string, @@ -156,7 +165,7 @@ export function updateNativeChatComposerDraft( return } // Why stamped once: a conversation never moves to another workspace, so its owner stays true. - const owner = current.owner ?? resolveOwner?.(scopeKey) + const owner = current.owner ?? change.owner ?? resolveOwner?.(scopeKey) const record: DraftRecord = { text, ...(document ? { document } : {}), @@ -192,14 +201,20 @@ function withAddition( */ export function appendToNativeChatComposerDraft( scopeKey: string, - addition: NativeChatComposerDraftAddition + addition: NativeChatComposerDraftAddition, + owner?: NativeChatComposerDraftOwner ): boolean { const before = records.get(scopeKey) const current = readNativeChatComposerDraft(scopeKey) const { text, images } = withAddition(current, addition) updateNativeChatComposerDraft( scopeKey, - { text, images, ...(text === current.text ? {} : { document: undefined }) }, + { + text, + images, + ...(text === current.text ? {} : { document: undefined }), + ...(owner ? { owner } : {}) + }, 'immediate', (loaded) => withAddition(loaded, addition, { once: true }) ) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts index ef4e976c4e9e..3dccd946f89b 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts @@ -2,13 +2,33 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests, + dropNativeChatPendingAttachmentsForTab, + dropNativeChatPendingAttachmentsOwnedBy, nativeChatPendingAttachmentSnapshot, revealNativeChatPendingAttachment, + settleNativeChatPendingAttachment, subscribeToNativeChatPendingAttachments, takeNativeChatPendingAttachment } from './native-chat-pending-attachment-cache' +import { + clearNativeChatComposerDraftsForTests, + deleteNativeChatComposerDraftsOwnedBy, + readNativeChatComposerDraft, + setNativeChatComposerDraftOwnerResolver, + structuredAgentSessionDraftScopeKey +} from './native-chat-composer-draft-store' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' + +afterEach(() => { + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) + +const OWNER: NativeChatComposerDraftOwner = { workspaceId: 'wt-1', executionHostId: 'local' } -afterEach(() => clearNativeChatPendingAttachmentsForTests()) +function addPending(scopeKey: string, id = scopeKey): void { + addNativeChatPendingAttachment(scopeKey, { id, path: '', pending: true }) +} describe('the pane pending attachment cache', () => { it('keeps a chip with no composer subscribed, so one a prompt unmounted can still settle', () => { @@ -53,4 +73,59 @@ describe('the pane pending attachment cache', () => { expect(listener).toHaveBeenCalledTimes(2) unsubscribe() }) + + it('settles into a draft owned by the workspace it began in, after its chat stopped naming one', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + let open = true + setNativeChatComposerDraftOwnerResolver((scopeKey) => + open && scopeKey === conversation ? OWNER : undefined + ) + addPending(conversation) + open = false + + expect(settleNativeChatPendingAttachment(conversation, conversation, '/store/a.png')).toBe(true) + expect(readNativeChatComposerDraft(conversation).images).toHaveLength(1) + deleteNativeChatComposerDraftsOwnedBy(OWNER) + expect(readNativeChatComposerDraft(conversation).images).toEqual([]) + }) + + it('drops the chips begun in a removed workspace on that host only', () => { + const resolved = new Map([ + ['tab-1:leaf', OWNER], + ['tab-2:leaf', { ...OWNER, executionHostId: 'ssh:box' }], + ['tab-3:leaf', { ...OWNER, workspaceId: 'wt-2' }] + ]) + setNativeChatComposerDraftOwnerResolver((scopeKey) => resolved.get(scopeKey)) + for (const scopeKey of [...resolved.keys(), 'tab-4:leaf']) { + addPending(scopeKey) + } + + dropNativeChatPendingAttachmentsOwnedBy(OWNER) + + expect( + ['tab-1:leaf', 'tab-2:leaf', 'tab-3:leaf', 'tab-4:leaf'].map( + (scopeKey) => nativeChatPendingAttachmentSnapshot(scopeKey).length + ) + ).toEqual([0, 1, 1, 1]) + expect(settleNativeChatPendingAttachment('tab-1:leaf', 'tab-1:leaf', '/store/a.png')).toBe( + false + ) + expect(readNativeChatComposerDraft('tab-1:leaf').images).toEqual([]) + }) + + it('drops the chips of every pane in a closed tab, never a conversation’s', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + for (const scopeKey of ['tab:1:leaf-a', 'tab:1:leaf-b', 'tab:10:leaf-a', conversation]) { + addPending(scopeKey) + } + + dropNativeChatPendingAttachmentsForTab('agent-session') + dropNativeChatPendingAttachmentsForTab('tab:1') + + expect( + ['tab:1:leaf-a', 'tab:1:leaf-b', 'tab:10:leaf-a', conversation].map( + (scopeKey) => nativeChatPendingAttachmentSnapshot(scopeKey).length + ) + ).toEqual([0, 0, 1, 1]) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index 1e5f06f989ce..3d4e477d0cee 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -4,14 +4,23 @@ // them, and each settles into the scope's draft whichever composer, if any, is showing. They are // never saved with the draft, so a restored draft cannot bring back an upload as if it were // attached. Every chip dies with its operation: settled or dropped when the save or upload settles -// (each bounded by its call timeout), or removed by the user. +// (each bounded by its call timeout), removed by the user, or dropped with the drafts of a tab or +// workspace the user closed or removed, so none settles into a draft nothing could find or delete. import { useCallback, useSyncExternalStore } from 'react' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { appendToNativeChatComposerDraft } from './native-chat-composer-draft-store' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' +import { + appendToNativeChatComposerDraft, + nativeChatDraftScopeTabId, + resolveNativeChatComposerDraftOwner, + structuredAgentSessionIdOfDraftScope +} from './native-chat-composer-draft-store' const EMPTY: readonly NativeChatComposerImageAttachment[] = Object.freeze([]) const pendingCache = new Map() +// Why: a chip can outlive its tab, which is what names the draft's owner, so it is named at once. +const pendingOwners = new Map() const listeners = new Map void>>() /** The scope's pending chips; the same array until they change, so a reader can subscribe to it. */ @@ -24,6 +33,7 @@ export function nativeChatPendingAttachmentSnapshot( function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { if (next.length === 0) { pendingCache.delete(scopeKey) + pendingOwners.delete(scopeKey) } else { pendingCache.set(scopeKey, next) } @@ -62,6 +72,10 @@ export function addNativeChatPendingAttachment( chip: NativeChatComposerImageAttachment ): void { const { previewUrl: _previewUrl, ...pending } = chip + const owner = resolveNativeChatComposerDraftOwner(scopeKey) ?? pendingOwners.get(scopeKey) + if (owner) { + pendingOwners.set(scopeKey, owner) + } writePending(scopeKey, [...nativeChatPendingAttachmentSnapshot(scopeKey), pending]) } @@ -89,13 +103,15 @@ export function settleNativeChatPendingAttachment( path: string, connectionId?: string | null ): boolean { + const owner = pendingOwners.get(scopeKey) if (!takeNativeChatPendingAttachment(scopeKey, id)) { return false } - appendToNativeChatComposerDraft(scopeKey, { - images: [{ id, path, ...(connectionId ? { connectionId } : {}) }], - fromUser: true - }) + appendToNativeChatComposerDraft( + scopeKey, + { images: [{ id, path, ...(connectionId ? { connectionId } : {}) }], fromUser: true }, + owner + ) return true } @@ -119,6 +135,35 @@ export function clearNativeChatPendingAttachments(scopeKey: string): void { } } +function dropPendingWhere( + matches: (scopeKey: string, owner: NativeChatComposerDraftOwner | undefined) => boolean +): void { + for (const scopeKey of pendingCache.keys()) { + if (matches(scopeKey, pendingOwners.get(scopeKey))) { + writePending(scopeKey, []) + } + } +} + +/** The chips of every pane in a closed tab, matched as its drafts are; never a conversation's. */ +export function dropNativeChatPendingAttachmentsForTab(tabId: string): void { + dropPendingWhere( + (scopeKey) => + structuredAgentSessionIdOfDraftScope(scopeKey) === null && + nativeChatDraftScopeTabId(scopeKey) === tabId + ) +} + +/** The chips begun in a removed workspace, open and closed chats alike. */ +export function dropNativeChatPendingAttachmentsOwnedBy(owner: NativeChatComposerDraftOwner): void { + dropPendingWhere( + (_scopeKey, pendingOwner) => + pendingOwner?.workspaceId === owner.workspaceId && + pendingOwner.executionHostId === owner.executionHostId + ) +} + export function clearNativeChatPendingAttachmentsForTests(): void { pendingCache.clear() + pendingOwners.clear() } diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index 915ef64300e6..ca3c982eeace 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -51,6 +51,10 @@ import { structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { + addNativeChatPendingAttachment, + settleNativeChatPendingAttachment +} from '@/components/native-chat/native-chat-pending-attachment-cache' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' @@ -190,6 +194,23 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(readNativeChatComposerDraft(`${TAB2}:leaf-a`).text).toBe('kept') }) + it('a user tab close drops the uploads still on their way in that tab’s panes only', () => { + const store = createTestStore() + seedDrafts(store) + for (const scopeKey of [`${TAB1}:leaf-b`, `${TAB2}:leaf-b`]) { + addNativeChatPendingAttachment(scopeKey, { id: scopeKey, path: '', pending: true }) + } + + store.getState().closeTab(TAB2, { reason: 'pty-exit' }) + store.getState().closeTab(TAB1) + + const settled = [`${TAB1}:leaf-b`, `${TAB2}:leaf-b`].map((scopeKey) => + settleNativeChatPendingAttachment(scopeKey, scopeKey, '/store/a.png') + ) + expect(settled).toEqual([false, true]) + expect(readNativeChatComposerDraft(`${TAB1}:leaf-b`).images).toEqual([]) + }) + function seedStructuredChat(store: ReturnType) { const chat = makeUnifiedTab({ id: 'chat-tab', diff --git a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts index 0f81e7f6c573..bac3dc738cd6 100644 --- a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts @@ -43,11 +43,17 @@ import { } from './store-test-helpers' import { clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, readNativeChatComposerDraft, setNativeChatComposerDraftOwnerResolver, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + settleNativeChatPendingAttachment +} from '@/components/native-chat/native-chat-pending-attachment-cache' import { resolveNativeChatDraftOwner } from '@/lib/native-chat-draft-owner' const WT1 = 'repo1::/path/wt1' @@ -102,12 +108,75 @@ function draftTexts(): string[] { return [CHAT, PANE, OTHER].map((key) => readNativeChatComposerDraft(key).text) } +/** A pasted image still uploading in each scope; its chip's id is the scope's. */ +function pasteStillUploading(...scopeKeys: string[]): void { + for (const scopeKey of scopeKeys) { + addNativeChatPendingAttachment(scopeKey, { id: scopeKey, path: '', pending: true }) + } +} + +/** Each upload completes, as one does after its composer is gone; answers which scopes it reached. */ +function uploadsComplete(...scopeKeys: string[]): string[] { + return scopeKeys.filter((scopeKey) => + settleNativeChatPendingAttachment(scopeKey, scopeKey, `/store/${scopeKey}.png`) + ) +} + +function draftImageCount(scopeKey: string): number { + return readNativeChatComposerDraft(scopeKey).images.length +} + +function trackDraftOwners(store: ReturnType): void { + setNativeChatComposerDraftOwnerResolver((scopeKey) => + resolveNativeChatDraftOwner(store.getState(), scopeKey) + ) +} + +const FOLDER: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'group-a', + name: 'Folder', + folderPath: '/workspace/folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 +} + +function seedFolderWorkspace(store: ReturnType): void { + store.setState({ + projectGroups: [ + { + id: 'group-a', + name: 'A', + parentPath: '/workspace', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + executionHostId: 'local' + } + ], + folderWorkspaces: [FOLDER] + }) + seedWorkspace(store, folderWorkspaceKey(FOLDER.id)) +} + describe('workspace chat drafts on removal', () => { beforeEach(() => { vi.clearAllMocks() mockApi.worktrees.remove.mockResolvedValue(undefined) mockApi.repos.remove.mockResolvedValue(undefined) clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() }) it('removing a worktree deletes its drafts even when a listing purge lands mid-removal', async () => { @@ -223,45 +292,94 @@ describe('workspace chat drafts on removal', () => { }) it('deleting a folder workspace deletes its drafts', async () => { - const folder: FolderWorkspace = { - id: 'folder-1', - projectGroupId: 'group-a', - name: 'Folder', - folderPath: '/workspace/folder', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } mockApi.folderWorkspaces.delete.mockResolvedValue(true) const store = createTestStore() - store.setState({ - projectGroups: [ - { - id: 'group-a', - name: 'A', - parentPath: '/workspace', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1, - executionHostId: 'local' - } - ], - folderWorkspaces: [folder] - }) - seedWorkspace(store, folderWorkspaceKey(folder.id)) + seedFolderWorkspace(store) + + await expect(store.getState().deleteFolderWorkspace(FOLDER.id)).resolves.toBe(true) + + expect(draftTexts()).toEqual(['', '', 'other']) + }) + + it('a paste still uploading when its folder workspace is deleted never writes a draft back', async () => { + mockApi.folderWorkspaces.delete.mockResolvedValue(true) + const store = createTestStore() + seedFolderWorkspace(store) + trackDraftOwners(store) + pasteStillUploading(CHAT, PANE, OTHER) - await expect(store.getState().deleteFolderWorkspace(folder.id)).resolves.toBe(true) + await expect(store.getState().deleteFolderWorkspace(FOLDER.id)).resolves.toBe(true) + expect(uploadsComplete(CHAT, PANE, OTHER)).toEqual([OTHER]) + expect([CHAT, PANE, OTHER].map(draftImageCount)).toEqual([0, 0, 1]) expect(draftTexts()).toEqual(['', '', 'other']) }) + + it('drops the uploads of its open chats and panes even when no owner was named', async () => { + const store = createTestStore() + seedWorktree(store) + pasteStillUploading(CHAT, PANE, OTHER) + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(uploadsComplete(CHAT, PANE, OTHER)).toEqual([OTHER]) + expect([CHAT, PANE, OTHER].map(draftImageCount)).toEqual([0, 0, 1]) + }) + + it('drops the upload of a chat whose tab closed while it was still on its way', async () => { + const store = createTestStore() + seedWorktree(store) + trackDraftOwners(store) + pasteStillUploading(CHAT) + store.getState().closeUnifiedTab('chat-tab') + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(uploadsComplete(CHAT)).toEqual([]) + expect(draftImageCount(CHAT)).toBe(0) + }) + + it('deletes a draft an upload wrote after its chat’s tab closed, by the owner it began in', async () => { + const store = createTestStore() + seedWorktree(store) + trackDraftOwners(store) + const later = structuredAgentSessionDraftScopeKey('session-later') + seedStore(store, { + unifiedTabsByWorktree: { + [WT1]: [ + ...(store.getState().unifiedTabsByWorktree[WT1] ?? []), + makeUnifiedTab({ + id: 'later-tab', + entityId: 'session-later', + contentType: 'agent-session', + worktreeId: WT1, + groupId: 'group-1' + }) + ] + } + }) + // As at startup: the saved drafts have loaded before any upload settles. + await hydrateNativeChatComposerDrafts() + pasteStillUploading(later) + store.getState().closeUnifiedTab('later-tab') + expect(uploadsComplete(later)).toEqual([later]) + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(draftImageCount(later)).toBe(0) + }) + + it('a removal the host refuses keeps the uploads, which still reach their drafts', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = createTestStore() + seedWorktree(store) + trackDraftOwners(store) + pasteStillUploading(CHAT, PANE) + mockApi.worktrees.remove.mockRejectedValue(new Error('Permission denied')) + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(uploadsComplete(CHAT, PANE)).toEqual([CHAT, PANE]) + expect([CHAT, PANE].map(draftImageCount)).toEqual([1, 1]) + }) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts index 14ae90b6e746..2579a1322f29 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts @@ -7,6 +7,11 @@ import { deleteNativeChatComposerDraftsOwnedBy, structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' +import { + clearNativeChatPendingAttachments, + dropNativeChatPendingAttachmentsForTab, + dropNativeChatPendingAttachmentsOwnedBy +} from '@/components/native-chat/native-chat-pending-attachment-cache' /** Which unsent chat drafts a workspace owns: every draft that recorded it as its owner, open and * closed chats alike, plus its open tabs' conversation keys and terminal tab ids (each tab's pane @@ -47,15 +52,19 @@ export function captureWorkspaceChatDraftKeys( return { owners, conversations, terminalTabIds } } -/** Only after the delete succeeded: a refused or failed one keeps the drafts. */ +/** Only after the delete succeeded: a refused or failed one keeps the drafts. Chips still on their + * way go too, or one settling later would write a draft back that nothing could delete. */ export function deleteWorkspaceChatDrafts(keys: WorkspaceChatDraftKeys): void { for (const owner of keys.owners) { deleteNativeChatComposerDraftsOwnedBy(owner) + dropNativeChatPendingAttachmentsOwnedBy(owner) } for (const key of keys.conversations) { deleteNativeChatComposerDraft(key) + clearNativeChatPendingAttachments(key) } for (const tabId of keys.terminalTabIds) { deleteNativeChatComposerDraftsForTab(tabId) + dropNativeChatPendingAttachmentsForTab(tabId) } } diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 254a8c51c4e7..86fc37ab6638 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -18,6 +18,7 @@ import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { removePaneKeysByTabPrefix } from '../slices/agent-status-pane-keyed-records' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' +import { dropNativeChatPendingAttachmentsForTab } from '@/components/native-chat/native-chat-pending-attachment-cache' export function createTerminalTabCloseActions( set: TerminalStoreSet, @@ -255,6 +256,7 @@ export function createTerminalTabCloseActions( // workspace is removed in Orca; drafts have no budget that retires them. if (closeReason === 'user') { deleteNativeChatComposerDraftsForTab(tabId) + dropNativeChatPendingAttachmentsForTab(tabId) } // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) From 94f09d77454c0f82765dfa45169f2d1a73089050 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:14:33 -0700 Subject: [PATCH 32/40] fix(native-chat): the claim type uses the journal's own SQLite type --- .../agent-session-journal/journal-submission-hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts index 1a278547ad3c..84be3ab91c98 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts @@ -1,4 +1,4 @@ -import type Database from 'better-sqlite3' +import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { JournalQueuedMessages } from './journal-queued-messages' import type { JournalRowTransactionHook } from './journal-row-writer' From 5572a750dacc687b85ad31198cadecfd1dd8bf30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:47:51 -0700 Subject: [PATCH 33/40] refactor(native-chat): read the pending-image handlers off the composer's attachments Main's multi-file picker (#23956) and this branch together took NativeChatComposer over the 400-line limit; reading the two pending-image handlers the way the neighbouring ones already are keeps it at the limit. --- .../src/components/native-chat/NativeChatComposer.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d89515af0c9e..68aabe7a58f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -167,9 +167,7 @@ const NativeChatComposerPane = forwardRef Date: Tue, 6 Oct 2026 15:01:52 -0700 Subject: [PATCH 34/40] test(native-chat): pass the launch-args resolver main now requires in the attachment grant test --- src/main/runtime/structured-claude-attachment-grant.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts index be3dfca4cbde..9971f93ccdbf 100644 --- a/src/main/runtime/structured-claude-attachment-grant.test.ts +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -71,6 +71,7 @@ describe("structured Claude's read grant for chat attachments", () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => directory, resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveLaunchArgs: () => [], resolveEnvironment: async () => ({}) }) From 79bdfd79ca4dd60cd6e5e347448ba713e65e76b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:04:23 -0700 Subject: [PATCH 35/40] fix(native-chat): grant the attachment store beside folders the saved Claude Arguments add Main (#25721) now builds additionalDirectories from the user's --add-dir arguments; this branch's attachment-store grant replaced that list instead of adding to it. Both are granted now. Moves the Claude session-id derivation to its own module to keep the resolver under the line limit. --- ...laude-structured-launch-resolution.test.ts | 17 ++++++++++++++++ .../claude-structured-launch-resolution.ts | 20 ++++++++----------- .../claude/claude-structured-session-id.ts | 10 ++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 src/main/claude/claude-structured-session-id.ts diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 48f1b04b07f4..d0baf0e8b925 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -441,6 +441,23 @@ describe('claude structured launch resolution', () => { expect(launch.cwd).toBe('/repos/workspace-1') }) + it('grants the attachment store beside folders the saved Arguments add', async () => { + const launch = await resolverFor( + record(), + undefined, + false, + { claude: '' }, + async () => true, + () => ['--add-dir', '/extra'], + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual([ + '/extra', + '/state/agent-session-attachments' + ]) + }) + it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index f2a76308303a..e1b1c93052af 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { join } from 'node:path' import type { Options as ClaudeAgentSdkOptions, @@ -38,6 +37,9 @@ import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-sup import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' +import { claudeSessionIdForOrcaSession } from './claude-structured-session-id' + +export { claudeSessionIdForOrcaSession } export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -56,7 +58,6 @@ export type ClaudeStructuredSdkOptions = Pick< | 'allowDangerouslySkipPermissions' | 'sessionId' | 'resume' - | 'additionalDirectories' > /** @@ -276,14 +277,6 @@ export async function assertClaudeAuthSwitchSettled( } } -export function claudeSessionIdForOrcaSession(sessionId: string): string { - const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) - bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 - bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 - const hex = bytes.toString('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { @@ -351,7 +344,11 @@ export function createClaudeStructuredLaunchResolver( claudeConfigDir: record.accountHome.path }))) const configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) - const { additionalDirectories } = configured + // The user's own --add-dir folders, plus the host's chat attachment store. + const additionalDirectories = [ + ...configured.additionalDirectories, + ...(deps.attachmentDirectory ? [deps.attachmentDirectory] : []) + ] const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) @@ -380,7 +377,6 @@ export function createClaudeStructuredLaunchResolver( ...permission.extraArgs, ...thinkingDisplayArgs }, - ...(deps.attachmentDirectory ? { additionalDirectories: [deps.attachmentDirectory] } : {}), // Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping. ...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId }) }, diff --git a/src/main/claude/claude-structured-session-id.ts b/src/main/claude/claude-structured-session-id.ts new file mode 100644 index 000000000000..abdda0721f8e --- /dev/null +++ b/src/main/claude/claude-structured-session-id.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto' + +/** A stable UUID (v4 layout) for the Claude conversation behind an Orca chat. */ +export function claudeSessionIdForOrcaSession(sessionId: string): string { + const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} From 5aaa993d9622237479b57a5b9ddfd941e600d622 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:40:51 -0700 Subject: [PATCH 36/40] test(native-chat): run the attachment store's SQLite tests in the Node runtime project --- config/scripts/vitest-sqlite-runtime-files.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 109048a5e3b8..7c30e34c04bd 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -33,6 +33,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-structured-question-order.test.ts', 'src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts', 'src/main/ipc/ssh-host-partition-session-export.test.ts', + 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', + 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts', @@ -44,6 +46,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', @@ -178,6 +181,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', + 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', 'src/main/ssh-expired-lease-pane-readoption.test.ts', 'src/main/ssh-reattach-pane-cardinality.test.ts', From 8e6c9b273cfd6ebe5fd902d521eab7c230b1292c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:43:35 -0700 Subject: [PATCH 37/40] test(native-chat): follow main's attach ownership recheck (#25749) in the attachment tests --- .../native-chat-external-attachments-destination.test.tsx | 1 + .../native-chat/use-native-chat-external-attachments.test.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx index 2b0fe6351877..a1e675872ce9 100644 --- a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx @@ -68,6 +68,7 @@ function Probe({ worktreeId }: { worktreeId?: string }): React.JSX.Element { structuredWorktreeId: worktreeId, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, + pendingChips: attachments.pendingChips, setNotice }) api = { ...attachments, ...external } diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index b9d8ce37256e..44e48edb2abf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -714,7 +714,9 @@ describe('useNativeChatExternalAttachments', () => { await act(async () => probe.latest().attachExternalPaths(['/local/a.txt'])) expect(mocks.resolveNativeChatAttachmentOwnerForWorktree).toHaveBeenCalled() expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() - expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt']) + expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'], undefined, { + destinationIsCurrent: expect.any(Function) + }) }) }) }) From be41bf028f2339e30d11c54178068914dff1a6c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:46:58 -0700 Subject: [PATCH 38/40] test(native-chat): a named upload chip still shows when the agent takes no images --- ...-native-chat-composer-image-input.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx index 661669ab6775..93e289e44625 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx @@ -93,4 +93,29 @@ describe('structured composer image input', () => { expect(result.pendingChip).toBeNull() expect(result.draft).toContain('/tmp/shot.png') }) + + it('still shows a named upload chip when the agent takes no images', async () => { + const container = document.createElement('div') + document.body.append(container) + let api: AttachmentApi | null = null + const root = createRoot(container) + await act(async () => { + root.render( + createElement(Probe, { + acceptsImages: false, + onReady: (next) => { + api = next + } + }) + ) + }) + const latest = (): AttachmentApi => { + if (!api) { + throw new Error('Probe did not render') + } + return api + } + expect(latest().beginPendingImageAttachment(undefined, 'notes.txt')).not.toBeNull() + act(() => root.unmount()) + }) }) From 9c9c264185b01f6a41cde928e13298419ce7ce24 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:01:39 -0700 Subject: [PATCH 39/40] fix(native-chat): a paired-server image paste failure keeps its error apart, as main's notice card does --- ...ve-chat-composer-paste-session-attachments.test.tsx | 10 ++++++++++ .../native-chat/use-native-chat-composer-paste.ts | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx index e00cb91f2c91..421462ae1447 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -215,6 +215,16 @@ describe('pasting into a structured chat on a paired server', () => { expect(probe.chips).toHaveLength(0) }) + it('keeps the save error apart from the image-paste notice', async () => { + mocks.saveClipboardImageAsTempFile.mockRejectedValue( + new Error("Error invoking remote method 'ui:saveClipboardImageAsTempFile': Error: disk full") + ) + const setNotice = vi.fn() + const probe = await renderPaste({ setNotice }) + await act(async () => probe.api().handlePaste(imagePasteEvent())) + expect(setNotice).toHaveBeenLastCalledWith('Image paste failed.', 'disk full') + }) + it('pastes rich text into a chat on an older server without a refusal beside the text', async () => { mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ ok: false, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index c92e45a8b4b6..1701c069b342 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -97,8 +97,13 @@ export function useNativeChatComposerPaste({ setNotice: (notice, cause, errorText) => { void (cause === 'serverTooOld' ? besidePastedText() : Promise.resolve(false)).then( (quiet) => { - if (!quiet && canPaste()) { + if (quiet || !canPaste()) { + return + } + if (errorText) { setNotice(notice, errorText) + } else { + setNotice(notice) } } ) From 1b137cf720d04e5a17813745621fc0bbe26f71bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:03:48 -0700 Subject: [PATCH 40/40] fix(native-chat): the attachment sweep reads the chat list directly now that records have no import still owed --- ...ed-agent-session-attachment-wiring.test.ts | 6 +----- ...uctured-agent-session-attachment-wiring.ts | 20 ++++++------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts index f5f6df4269aa..02323d20fc80 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -31,13 +31,9 @@ function install(directory = stateDirectory): void { isSessionUnreadable: () => false } // Written by a newer Orca: the sweep reads no claims, so nothing here touches the connection. - const journal: Pick< - JournalHostDatabase, - 'readOnly' | 'isClosed' | 'db' | 'legacyRecordImportOwed' - > = { + const journal: Pick = { readOnly: true, isClosed: false, - legacyRecordImportOwed: false, get db(): Database.Database { throw new Error('not opened in this test') } diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts index 8c1b1947a65b..9ee32ec10f41 100644 --- a/src/main/runtime/structured-agent-session-attachment-wiring.ts +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -21,10 +21,7 @@ let sweeper: AgentSessionAttachmentSweeper | null = null export function installAgentSessionAttachments(deps: { stateDirectory: string store: Pick - journalDatabase: Pick< - JournalHostDatabase, - 'readOnly' | 'isClosed' | 'db' | 'legacyRecordImportOwed' - > + journalDatabase: Pick logger: StructuredAgentSessionLogger }): void { stopAgentSessionAttachments() @@ -49,16 +46,11 @@ export function installAgentSessionAttachments(deps: { deps.journalDatabase.readOnly || deps.journalDatabase.isClosed ? null : deps.journalDatabase.db, - // Records still owed their import are missing from the list, so no claim reads as orphaned. - recordedSessionIds: () => - deps.journalDatabase.legacyRecordImportOwed - ? null - : { - // Readable or not: an unreadable chat's claims still protect its uploads. - has: (sessionId) => - deps.store.getRecord(sessionId) !== null || - deps.store.isSessionUnreadable(sessionId) - } + recordedSessionIds: () => ({ + // Readable or not: an unreadable chat's claims still protect its uploads. + has: (sessionId) => + deps.store.getRecord(sessionId) !== null || deps.store.isSessionUnreadable(sessionId) + }) }, { initialDelayMs: FIRST_SWEEP_DELAY_MS,