diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 0edefd1c8e2e..fc93f232acbb 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -35,6 +35,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ipc/ssh-host-partition-session-export.test.ts', 'src/main/ipc/ssh-host-server-on-connect-wiring.test.ts', 'src/main/ipc/ssh-managed-server-move-conversion.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', @@ -46,6 +48,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-hung-start-stop.test.ts', @@ -187,6 +190,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', diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 5427756baf2c..d0baf0e8b925 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -63,7 +63,8 @@ function resolverFor( // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, hasTranscript: () => Promise = async () => true, - resolveLaunchArgs?: () => string[] + resolveLaunchArgs?: () => string[], + attachmentDirectory?: string ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, @@ -73,7 +74,8 @@ function resolverFor( resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), - ...(resolveEnv ? { resolveEnv } : {}) + ...(resolveEnv ? { resolveEnv } : {}), + ...(attachmentDirectory ? { attachmentDirectory } : {}) }) } @@ -424,6 +426,38 @@ 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, + undefined, + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual(['/state/agent-session-attachments']) + 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 de64c7e648d1..f916e284d877 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,5 +1,4 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { createHash } from 'node:crypto' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -44,6 +43,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' @@ -135,6 +137,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 this CLI takes the thinking-display flag. Absent ⇒ the flag is never passed. */ thinkingDisplay?: Pick /** Whether Claude wrote a transcript for this id; defaults to the transcript resolver. */ @@ -198,14 +203,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 { @@ -265,7 +262,11 @@ export function createClaudeStructuredLaunchResolver( env: claudeProbeEnv(sources) }) 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' ) 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)}` +} 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..80300d92674e --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -0,0 +1,166 @@ +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' +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' + const uploadId = String(params.uploadId) + try { + const result = + method === 'agentSessionAttachment.uploadStart' + ? await serverStore.startUpload({ + callerKey, + sessionId: String(params.sessionId), + name: String(params.name), + byteLength: Number(params.byteLength) + }) + : method === 'agentSessionAttachment.uploadAppend' + ? await serverStore.appendChunk({ + callerKey, + uploadId, + offset: Number(params.offset), + contentBase64: String(params.contentBase64) + }) + : method === 'agentSessionAttachment.uploadCommit' + ? await serverStore.commitUpload({ callerKey, uploadId }) + : await serverStore.abortUpload({ callerKey, uploadId }) + return { ok: true, result } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: { code: 'internal', message } } + } + } +})) + +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'), + { hasSession: () => true } + ) + 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.rootDir)).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 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') + ]) + 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..258c5d355d9a --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -0,0 +1,216 @@ +// 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 { 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, + 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 + +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 +} + +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) + } + // 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 +} + +/** 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) + } + }) +} + +/** 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. + */ +export async function uploadExternalPathsToAgentSessionAttachments( + context: UploadContext, + paths: readonly string[] +): Promise { + 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 }) + 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 de02a93e7ac2..6aa3502adb78 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -11,6 +11,7 @@ import { } from '../local-file-access-resolution' 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' import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' @@ -99,4 +100,5 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) + registerAgentSessionAttachmentUploadHandlers() } diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 079549e47111..00a5788537e5 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -35,6 +35,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) @@ -72,16 +104,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 @@ -93,7 +125,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/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..9401bf7e9a4a --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -0,0 +1,280 @@ +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' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +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].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + 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', () => { + 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].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) +}) + +describe('claims written with the message', () => { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-c', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', 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..ac41b427e58b --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -0,0 +1,201 @@ +// 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 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 +} + +/** + * 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 uploadIds = agentSessionAttachmentReferences(root, claim.body) + if (uploadIds.size === 0) { + return + } + if (claim.required) { + if ( + [...uploadIds].some( + (uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId) + ) + ) { + throw new AgentSessionAttachmentExpiredError() + } + insertClaims(db, uploadIds, claim.sessionId, claim.now) + return + } + db.exec(`SAVEPOINT ${CLAIM_SAVEPOINT}`) + try { + insertClaims(db, 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: Pick, 'has'> | 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-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts new file mode 100644 index 000000000000..f08896ad4a5d --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts @@ -0,0 +1,94 @@ +// 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 } +} + +// 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, '/') + return platform === 'win32' ? forward.toLowerCase() : forward +} + +/** + * 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 +): 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-])`, + 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 rootStart = rootEnd - root.length + const uploadId = match[1]?.toLowerCase() + if ( + uploadId && + rootStart >= 0 && + !PATH_CHARACTER.test(text.charAt(rootStart - 1)) && + normalizedRoot(text.slice(rootStart, rootEnd), platform) === ownRoot + ) { + uploadIds.add(uploadId) + } + } + } + return uploadIds +} 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.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts new file mode 100644 index 000000000000..fc0c57d30b24 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -0,0 +1,231 @@ +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 +const caller = 'client-a' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachments-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId.startsWith('session-') + }) +}) + +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 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(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. + 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.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 () => { + 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.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) + }) + + 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.rootDir)).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.rootDir)).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.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') + await expect(store.readPreview(join(uploadDir, 'nested', 'x.png'))).rejects.toThrow( + '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 new file mode 100644 index 000000000000..a9626e86afaf --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -0,0 +1,225 @@ +// 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. Which chats hold +// an upload is recorded by the message that names it (`agent-session-attachment-claims.ts`). + +import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { + sanitizeAgentSessionAttachmentName, + type AgentSessionAttachmentUploadCommitResult +} 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, + assertPreviewWithinTransportBudget, + previewableBinaryByteLimit +} from '../../runtime/runtime-file-commands-mobile-file-list-limit' +import { ChunkedUploadRegistry, nextChunkedUploadLength } from './chunked-upload-registry' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + parseAgentSessionAttachmentStorePath +} from './agent-session-attachment-references' + +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' + +function isTooLarge(error: unknown): boolean { + return error instanceof Error && error.message === PREVIEW_TOO_LARGE +} + +type InFlightUpload = { + callerKey: string + uploadDir: string + name: string + expectedLength: number + receivedLength: number + 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, + 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, + private readonly options: AgentSessionAttachmentStoreOptions + ) {} + + /** 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 }> { + 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, + uploadDir: join(this.rootDir, id), + name, + expectedLength: args.byteLength, + receivedLength: 0, + writing: false + })) + const uploadDir = join(this.rootDir, 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') + } + // 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') + } + const finalPath = join(upload.uploadDir, upload.name) + // 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) + throw error + } finally { + this.uploads.delete(args.uploadId) + } + } + + 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, 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 ( + !IMAGE_FILE_MIME_TYPES[extname(filePath).toLowerCase()] || + !parseAgentSessionAttachmentStorePath(this.rootDir, filePath) + ) { + throw new Error(NOT_AN_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 (!read.isBinary || !read.mimeType?.startsWith('image/')) { + throw new Error(NOT_AN_IMAGE) + } + return assertPreviewWithinTransportBudget( + { content: read.content, isBinary: true, isImage: true, mimeType: read.mimeType }, + maxContentBytes + ) + } + + 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(() => {}) +} 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..e414633a66bc --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts @@ -0,0 +1,199 @@ +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 } 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, + 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 stateDirectory: string +let store: AgentSessionAttachmentStore +let db: Database.Database +let recorded: Set | null + +beforeEach(async () => { + 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() + 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( + uploadId: string, + ageMs: number, + file: string = 'shot.png' +): Promise { + const uploadDir = join(store.rootDir, 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) +} + +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(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 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('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('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('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('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: 'session-1', + name: 'a.png', + byteLength: 1 + }) + 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 new file mode 100644 index 000000000000..cb685a018939 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts @@ -0,0 +1,157 @@ +// 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. +// - 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, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type Database from '../../sqlite/sync-database' +import { + 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 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 chat's claims can be judged orphaned. */ + recordedSessionIds: () => Pick, 'has'> | null +} + +export type AgentSessionAttachmentSweepResult = { removed: string[] } + +export async function sweepAgentSessionAttachments( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + now = Date.now() +): Promise { + const removed: string[] = [] + // 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)) + } + 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) + } + 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 + } + 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 } +} + +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 { + 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 { + let running = false + let stopped = false + const run = (): void => { + if (running || stopped) { + return + } + running = true + void sweepAgentSessionAttachments(store, facts) + .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/native-chat/agent-session-attachments/chunked-upload-registry.ts b/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts new file mode 100644 index 000000000000..729fc089c639 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/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/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 04483d099583..ba961541a7e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -14,6 +14,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. */ @@ -80,6 +81,7 @@ export function openJournalDatabase(dbPath: string): 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 } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index e87de366fff2..aaea0228b6e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -28,14 +28,21 @@ export class JournalHostDatabase { /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false - private constructor(opened: OpenJournalDatabase) { + private constructor( + opened: OpenJournalDatabase, + /** Where the database lives; per-runtime end records sit beside it. */ + readonly stateDirectory: string + ) { this.connection = opened.db this.readOnly = opened.readOnly } static open(stateDirectory: string): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) - return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) + return new JournalHostDatabase( + openJournalDatabase(journalDatabasePath(stateDirectory)), + stateDirectory + ) } get isClosed(): boolean { 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 9614c7548ff6..5b0e2580c45e 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 @@ -16,7 +16,7 @@ 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 { JournalOperationReceipt } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { holdQueuedMessages } from './queued-message-holds' import { @@ -45,6 +45,7 @@ import { settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' +import type { JournalAttachmentClaim } from './journal-submission-hook' import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ @@ -66,6 +67,7 @@ export type JournalQueuedMessagesDeps = { * does — no call site can forget. In-transaction consume and the returned * transition already ride their row's own commit. */ committed: () => void + claimAttachments: JournalAttachmentClaim } export class JournalQueuedMessages { @@ -110,8 +112,10 @@ export class JournalQueuedMessages { } /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused; - * `holdReason` carries a hold of its own over with it. `receipt`: the send's ledger answer, - * committed with the draft only when this inserts it. */ + * `holdReason` carries a hold of its own over with it. + * `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. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ insert( input: { messageId: string @@ -119,11 +123,13 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string + requireAttachments?: true holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt ): Promise { const { sessionId } = this.deps + const { requireAttachments, ...draft } = input let inserted = false return this.transact( (db) => { @@ -133,10 +139,11 @@ export class JournalQueuedMessages { // gets here, so an existing row is the same accept landing twice. return existing } + this.deps.claimAttachments(db, input.body, requireAttachments === true) inserted = true const { epoch, lastSequence } = this.deps.state() const row = insertQueuedMessage(db, { - ...input, + ...draft, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() @@ -363,15 +370,6 @@ export class JournalQueuedMessages { } } -/** The per-append hook converting one draft inside the append's own transaction. */ -export function queuedMessageConsumeHook( - queuedMessages: JournalQueuedMessages, - consumedAs: string, - consume: JournalSubmissionConsume -): JournalRowTransactionHook { - return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) -} - export class QueuedMessageNotConsumableError extends Error { constructor( readonly messageId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 506547beb10f..5acba78506a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -23,6 +23,8 @@ import { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import type { JournalWriteBody } from './journal-write-queue' +import type { JournalAttachmentClaim } from './journal-submission-hook' +import { claimAgentSessionAttachmentsInTransaction } from '../agent-session-attachments/agent-session-attachment-claims' export type JournalStoreHost = { /** Fires the journal's commit listener for a durable change that appended no @@ -75,6 +77,15 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal cursor: host.cursor, adopt: host.adopt }) + const claimAttachments: JournalAttachmentClaim = (db, body, required) => + claimAgentSessionAttachmentsInTransaction(db, { + // Uploaded attachments are stored beside the journal database. + stateDirectory: host.database().stateDirectory, + sessionId: host.identity.sessionId, + body, + required, + now: host.now() + }) const queuedMessages = new JournalQueuedMessages({ sessionId: host.identity.sessionId, now: host.now, @@ -83,7 +94,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, state: host.state, reopenFloor: () => host.journal().reopenFloor(), - committed: host.notifyCommitted + committed: host.notifyCommitted, + claimAttachments }) const rowWriter = new JournalRowWriter({ sessionId: host.identity.sessionId, @@ -120,7 +132,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, identity: host.identity, rowWriter, - queuedMessages + queuedMessages, + claimAttachments }), itemAppender: new JournalItemAppender({ state: host.state, 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..84be3ab91c98 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts @@ -0,0 +1,30 @@ +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' +import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' + +/** Claims the stored chat attachments a message written to this journal names, in the write's own + * transaction; `required` refuses the whole write when one is no longer stored. */ +export type JournalAttachmentClaim = ( + db: Database.Database, + body: AgentJournalMessageItem, + required: boolean +) => void + +/** 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, + claimAttachments: JournalAttachmentClaim, + input: Pick, + consume: JournalSubmissionConsume | undefined +): JournalRowTransactionHook { + return (db) => { + if (consume) { + queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId }) + } + claimAttachments(db, input.body, input.origin === 'client' && !consume) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index c79fa6030315..1fc0ac21d0da 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -5,7 +5,8 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages } from './journal-queued-messages' +import { journalSubmissionHook, type JournalAttachmentClaim } from './journal-submission-hook' import type { JournalReducerState } from './journal-reducer' import { journalDispatchRowBuilder, journalSubmissionRowBuilder } from './journal-row-builders' import type { @@ -24,6 +25,7 @@ export type JournalSubmissionWriterDeps = { identity: AgentSessionJournalIdentity rowWriter: JournalRowWriter queuedMessages: JournalQueuedMessages + claimAttachments: JournalAttachmentClaim } export class JournalSubmissionWriter { @@ -42,10 +44,10 @@ export class JournalSubmissionWriter { /** The send's ledger answer, committed with this row. */ receipt?: JournalOperationReceipt ): Promise { - const { identity, queuedMessages, rowWriter, state } = this.deps + const { claimAttachments, identity, queuedMessages, rowWriter, state } = this.deps return rowWriter.append( journalSubmissionRowBuilder(state, identity, input, consume), - consume && queuedMessageConsumeHook(queuedMessages, input.clientMessageId, consume), + journalSubmissionHook(queuedMessages, claimAttachments, input, consume), receipt ) } 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..9228f6f0743f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -0,0 +1,145 @@ +// 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('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 = { + 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 fe8b31efe901..bceef9cb308e 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 @@ -31,6 +31,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. */ @@ -200,6 +202,8 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + /** A person's send at a chat surface: every attachment it names must still be stored. */ + userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -235,16 +239,25 @@ 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, - // In the session that will send it: the reducer aliases the provider's echo by exactly this. - fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }, - ctx.operationReceipt - ) + let row: QueuedMessageRow + try { + row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + // In the session that will send it: the reducer aliases the provider's echo by exactly this. + fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance(), + ...(params.userSend ? { requireAttachments: true } : {}) + }, + ctx.operationReceipt + ) + } 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-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 0fa49460d5e9..7ce3d43b4798 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -14,6 +14,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + userSend?: true }, immediate: () => Promise> ): Promise> { 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 4f575c1473be..200ce5a338ab 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 @@ -44,6 +44,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' import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -86,6 +87,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. A dispatch to such a * child throws only when the start failed before the write (a write's own failure is an outcome, @@ -159,6 +171,9 @@ export async function performSend( ctx.operationReceipt ) } 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 failed transaction that will not roll back). diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 9f87aab0b906..3370db3f2de5 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -49,6 +49,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 { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual' import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' import { ARTIFACT_METHODS } from './artifacts' @@ -70,6 +71,7 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS, ...STRUCTURED_AGENT_SESSION_VISUAL_METHODS, ...STRUCTURED_AGENT_SESSION_AGENTS_METHODS, ...AGENT_LAUNCH_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..e2b33bc9bb6a --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -0,0 +1,216 @@ +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 } 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 { + 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'), { + hasSession: (sessionId) => sessionId === 'session-alpha' + }) + 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({ + // 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 + }) + 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 + ) + 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)}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result shape its method returns. + return response.result as 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) + }) + + 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.rootDir)).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) + }) + + 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 new file mode 100644 index 000000000000..2c45d75c662e --- /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 { remoteFileContentBudget } from './files-remote-content-budget' +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 +): 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) + // 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-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a0c0089c8d33..20d9079bd117 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -17,6 +17,7 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +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 { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -140,6 +141,7 @@ function createClaudeAdapter( readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) } : {}), + attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, onChildWorkEvidence: (sessionId, evidence) => 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..02323d20fc80 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -0,0 +1,67 @@ +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-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' +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, + isSessionUnreadable: () => false + } + // Written by a newer Orca: the sweep reads no claims, so nothing here touches the connection. + const journal: Pick = { + readOnly: true, + isClosed: 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 new file mode 100644 index 000000000000..9ee32ec10f41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -0,0 +1,68 @@ +// 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 } 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 '../native-chat/agent-session-attachments/agent-session-attachment-sweep' +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' + +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: Pick + journalDatabase: Pick + logger: StructuredAgentSessionLogger +}): void { + stopAgentSessionAttachments() + 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, + { + database: () => + deps.journalDatabase.readOnly || deps.journalDatabase.isClosed + ? null + : deps.journalDatabase.db, + 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, + 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 d8a5698b0254..d2d7b3b21511 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -32,6 +32,10 @@ import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-sessio import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' import { openAgentSessionRecordStoreOnce, releaseAgentSessionRecordStore, @@ -195,6 +199,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 @@ -322,6 +327,12 @@ async function installOnJournal( }) } setStructuredAgentSessionHost(host) + installAgentSessionAttachments({ + stateDirectory: deps.stateDirectory, + store, + journalDatabase, + logger: deps.logger + }) return { host, adapter, 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..9971f93ccdbf --- /dev/null +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -0,0 +1,82 @@ +// 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' + +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() + 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 }), + resolveLaunchArgs: () => [], + 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/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 288c7a7ddd59..04f04a50ccc8 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -37,6 +37,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'] @@ -102,6 +104,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.readClaudeManagedAccountGate ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } : {}), + ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), ...(deps.claudeThinkingDisplay ? { thinkingDisplay: deps.claudeThinkingDisplay } : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { 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..8db77235abf8 --- /dev/null +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -0,0 +1,120 @@ +// 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') + } + // 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 +} + +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 () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. + 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' } }) + // 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') + }) +}) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 3a599e4ac5e2..223b89c79a63 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -6,11 +6,14 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat import { getAppEnvironment } from '../../shared/app-environment' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { nativeChatPasteFolder } from './native-chat-paste-files' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null runtimeEnvironmentId?: string | null + /** With `runtimeEnvironmentId`: store the image as an attachment of this structured chat. */ + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste: kept in Orca's paste folder so its draft can bring it back. */ forNativeChatDraft?: boolean } diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f5fb9032f2dc..05a09a54bb81 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -39,6 +39,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +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' @@ -59,6 +60,15 @@ 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) { + return uploadPastedImageToAgentSessionAttachments( + args.agentSessionAttachment, + runtimeEnvironmentId, + app.getPath('userData'), + buffer + ) + } // 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 737a1b87594a..5e822328195a 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -27,6 +27,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' import type { PrepareDroppedPathsRequest, PreparedDroppedPaths @@ -201,6 +205,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 c60917d427a1..6fc8e9b69d8c 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -7,6 +7,10 @@ import type { import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' 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 { @@ -211,6 +215,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 b7d9345daf70..68dd74dd0b0f 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 /** A native-chat composer paste, kept where its draft can bring it back. */ forNativeChatDraft?: boolean }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index e18fa1513948..1baa264a784a 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 /** A native-chat composer paste, kept where its draft can bring it back. */ forNativeChatDraft?: boolean }) => Promise 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..f5509b5b10df --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -0,0 +1,256 @@ +// @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, 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' +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(() => { + 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 { + clearNativeChatDraftCacheForTests, + writeNativeChatDraftCache +} from './native-chat-draft-cache' +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(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, + 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 }]) + }) + + 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() + + // 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) + }) + + 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/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index a915e70327d1..871eb6f5a13b 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 dac43fa42c12..a895159bba8f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -175,10 +175,7 @@ const NativeChatComposerPane = forwardRef !attachment.hidden) const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) const optionCount = autocomplete.mode === 'slash' @@ -278,12 +286,13 @@ export function NativeChatComposerField({ '[contain:paint]' )} > - {imageAttachments.length > 0 ? ( + {shownAttachments.length > 0 ? (
- {imageAttachments.map((attachment) => ( + {shownAttachments.map((attachment) => ( ))} diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 286365db2176..b8d9400235e8 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -24,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( + + ) } async function clickOn(target: Element): Promise { @@ -65,6 +74,58 @@ 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/u1/shot.png' + renderPreview({ id: 'a1', path }, 'env-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(), { + kind: 'chat-image' + }) + expect(screen.getByRole('img', { name: 'shot.png' }).getAttribute('src')).toBe( + 'blob:from-server' + ) + }) + + 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') + + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith( + '/repo/docs/shot.png', + '/repo/docs/shot.png', + undefined, + undefined, + { kind: 'chat-image' } + ) + }) + + 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() + }) + it('offers the full-size file, not the clipboard thumbnail, to the chat copy menu', () => { mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-1') renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 0ef722cd3a84..a46a6c2eeddb 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -1,22 +1,29 @@ -import { useEffect, useRef, useState } from 'react' -import { Image as ImageIcon, ImageOff, Loader2, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { FileText, Image as ImageIcon, ImageOff, 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 { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' -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' import { chatImageAccess } from '@/lib/local-file-access' 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 { if (attachment.unavailableName !== undefined) { @@ -28,7 +35,13 @@ export function NativeChatImageAttachmentPreview({ /> ) } - return + return ( + + ) } function attachmentLabel(path: string): string { @@ -98,7 +111,11 @@ function NativeChatUnavailableImageChip({ ) } -function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.Element { +function NativeChatImageThumbnail({ + attachment, + hostEnvironmentId, + onRemove +}: Props): React.JSX.Element { const [isOpen, setIsOpen] = useState(false) const [isNearViewport, setIsNearViewport] = useState(false) const thumbnailRef = useRef(null) @@ -124,11 +141,27 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El return () => observer.disconnect() }, []) const isPending = attachment.pending === true + // A stored attachment is a path on the paired server, read back through that server. + const readEnvironmentId = + hostEnvironmentId && isAgentSessionAttachmentStorePath(attachment.path) + ? hostEnvironmentId + : undefined + const hostReadContext = useMemo( + () => + readEnvironmentId + ? { + settings: { activeRuntimeEnvironmentId: readEnvironmentId }, + worktreeId: null, + worktreePath: null + } + : undefined, + [readEnvironmentId] + ) const localSrc = useLocalImageSrc( !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, attachment.path, attachment.connectionId, - undefined, + hostReadContext, // Why chat-image: a draft handed off from the host queue may carry paths a paired client chose. chatImageAccess() ) @@ -137,11 +170,16 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El const thumbnailSrc = attachment.previewUrl ?? localSrc const fullSizeSrc = localSrc ?? attachment.previewUrl const filename = attachmentLabel(attachment.path) - const pendingLabel = translate( - 'components.native-chat.composer.imageSaving', - 'Saving pasted image…' - ) - const label = isPending ? pendingLabel : filename + const pendingLabel = attachment.pendingName + ? translate('components.native-chat.composer.uploadingFile', 'Uploading {{name}}…', { + name: attachment.pendingName + }) + : translate('components.native-chat.composer.imageSaving', 'Saving pasted image…') + 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 // The thumbnail may be the downscaled clipboard preview; copy only the file. const copySrc = copyableNativeChatImageSrc(localSrc) @@ -167,8 +205,15 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El alt={label} className={`size-full object-cover${isPending ? ' opacity-50' : ''}`} /> + ) : attachment.pendingName ? ( + + + + {attachment.pendingName} + + ) : ( - + )} {isPending ? ( 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 44f5e737f2a4..67029a9c0c7f 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 @@ -70,6 +70,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/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index c477f343ca20..e5468ef2c62e 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, @@ -20,14 +23,20 @@ vi.mock('sonner', () => ({ })) 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, + 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 +234,78 @@ 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: + 'This needs a newer Orca on the computer running this chat. Update Orca there, then try again.' + }) + }) +}) + +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 ad09b01ead41..7d9e0dfc6cf0 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,23 @@ -// 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 { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' +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 { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' @@ -26,11 +36,21 @@ export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { worktreePath: string } +/** 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 +} + 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 @@ -97,6 +117,30 @@ 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 nativeChatWorktreeNotReadyNotice(): string { return translate( 'components.native-chat.composer.worktreeNotReady', @@ -118,6 +162,22 @@ export function nativeChatAttachmentUnreadableNotice(): string { ) } +/** 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(/[^.!?。!?]$/u, '$&.') + 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 nativeChatTooManyAttachmentsNotice(): string { return translate( 'components.native-chat.composer.tooManyAttachments', @@ -126,12 +186,7 @@ export function nativeChatTooManyAttachmentsNotice(): string { ) } -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 @@ -168,3 +223,51 @@ 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)) { + // 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, + 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. The chips show progress, + * so nothing else does; the caller reports what did not attach. + */ +export function uploadNativeChatSessionAttachmentPaths( + paths: string[], + target: AgentSessionAttachmentUploadTarget +): 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 new file mode 100644 index 000000000000..d0fd3b9268de --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -0,0 +1,89 @@ +import { translate } from '@/i18n/i18n' +import { setNativeChatPasteFailure } from './native-chat-composer-notice' +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; cause: NativeChatClipboardImageFailureCause } +> { + if (owner.kind === 'local') { + return { ok: true, args: { forNativeChatDraft: true } } + } + if (owner.kind === 'ssh') { + return { ok: true, args: { connectionId: owner.connectionId } } + } + const prepared = await prepareNativeChatSessionAttachmentUpload(owner) + if (!prepared.ok) { + 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: { + /** `errorText`: the failure's own words, kept apart from Orca's notice. */ + setNotice: ( + notice: string, + cause: NativeChatClipboardImageFailureCause, + errorText?: string + ) => 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') + 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, 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) { + // A failed save must be visible: over SSH it fails whenever the + // connection drops, and a silent no-op reads as a broken paste. + setNativeChatPasteFailure( + (notice, errorText) => { + if (notice !== null) { + report.setNotice(notice, 'failed', errorText) + } + }, + 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-draft-image-check.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts index 37c7d0124f84..efa65db9abf3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts @@ -1,8 +1,11 @@ // Which restored draft images are known to be gone. A paste in Orca's paste folder asks main -// whether it is still kept, which holds only for a file really inside that folder; any other image -// goes through the existing existence check (the workspace's read rules locally, the host over SSH). +// whether it is still kept, which holds only for a file really inside that folder; a file a paired +// server stored for the chat is that server's to check, which its claim at send does; any other +// image goes through the existing existence check (the workspace's read rules locally, the host +// over SSH). import { useEffect, useSyncExternalStore } from 'react' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import type { NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' import { isKeptLocalPaste, @@ -37,7 +40,7 @@ export async function findMissingNativeChatComposerDraftImages( } await Promise.all( checkable - .filter((image) => !isKeptLocalPaste(image)) + .filter((image) => !isKeptLocalPaste(image) && !isAgentSessionAttachmentStorePath(image.path)) .map(async (image) => { try { const exists = await pathExists({ 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 adedcc4d856c..4e93db226d93 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 @@ -444,8 +444,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() @@ -560,6 +564,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-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index cd4a11faf776..dc9e0a3d139e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -3,6 +3,7 @@ import { basename } from '@/lib/path' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { dirtyScopes, hasLocalChange, @@ -54,11 +55,15 @@ export function isKeptLocalPaste(image: NativeChatComposerDraftImage): boolean { } /** What storage keeps: not an unsaved launch-seed copy, and a paste outside Orca's paste folder - * (over SSH, or from before it) only by name. Null when nothing is left. */ + * (over SSH, or from before it) only by name. A paste a paired server stored for the chat stays + * whole: the server keeps it, and its claim at send refuses one it no longer has. Null when + * nothing is left. */ function savedForm(record: DraftRecord): StoredNativeChatComposerDraft | null { const { unsavedText, ...saved } = record const images = saved.images.map((image) => - isNativeChatPastedImagePath(image.path) && !isKeptLocalPaste(image) + isNativeChatPastedImagePath(image.path) && + !isKeptLocalPaste(image) && + !isAgentSessionAttachmentStorePath(image.path) ? unavailableNativeChatComposerDraftImage(image) : image ) 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 93cb58ba7ba6..c25c67cd0343 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 @@ -376,6 +376,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-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-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 73953f509a9e..bd87875d3031 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 @@ -81,6 +81,7 @@ function ComposerProbe({ 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-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index 08d31a4b11a6..efa532c88a14 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 @@ -52,4 +52,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-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 5d24fc155cc6..a6d2dbd5c05f 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -12,6 +12,7 @@ import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' +import { clearNativeChatPendingAttachmentsForTests } from './native-chat-pending-attachment-cache' export function readNativeChatDraftCache(scopeKey: string): string { return readNativeChatComposerDraft(scopeKey).text @@ -119,4 +120,5 @@ export function appendNativeChatAttachmentCache( export function clearNativeChatAttachmentCacheForTests(): void { clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() } 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/native-chat-pending-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts new file mode 100644 index 000000000000..3dccd946f89b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts @@ -0,0 +1,131 @@ +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' } + +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', () => { + addNativeChatPendingAttachment('pane-a', { + id: 'a1', + path: '', + pending: true, + previewUrl: 'blob:preview' + }) + + // The composer keeps its own preview; the cache never holds one. + expect(nativeChatPendingAttachmentSnapshot('pane-a')).toEqual([ + { id: 'a1', path: '', pending: true } + ]) + expect(takeNativeChatPendingAttachment('pane-a', 'a1')).toEqual({ + id: 'a1', + path: '', + pending: true + }) + expect(nativeChatPendingAttachmentSnapshot('pane-a')).toEqual([]) + }) + + it('answers undefined for a chip the user already removed, so its file never attaches', () => { + addNativeChatPendingAttachment('pane-b', { id: 'b1', path: '', pending: true }) + takeNativeChatPendingAttachment('pane-b', 'b1') + + expect(takeNativeChatPendingAttachment('pane-b', 'b1')).toBeUndefined() + }) + + it('shows a hidden chip and tells every subscriber, with a new snapshot only on change', () => { + const listener = vi.fn() + const unsubscribe = subscribeToNativeChatPendingAttachments('pane-c', listener) + addNativeChatPendingAttachment('pane-c', { id: 'c1', path: '', pending: true, hidden: true }) + const hidden = nativeChatPendingAttachmentSnapshot('pane-c') + + expect(nativeChatPendingAttachmentSnapshot('pane-c')).toBe(hidden) + revealNativeChatPendingAttachment('pane-c', 'c1') + + expect(nativeChatPendingAttachmentSnapshot('pane-c')).toEqual([ + { id: 'c1', path: '', pending: true } + ]) + 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 new file mode 100644 index 000000000000..3d4e477d0cee --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -0,0 +1,169 @@ +// A composer's chips still on their way (a save, an upload, a server's answer), per pane scope, +// owned here rather than by the composer. A prompt card unmounts the composer, so anything owed to +// the message must outlive it: the composer that comes back shows the same chips, Send waits for +// 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), 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 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. */ +export function nativeChatPendingAttachmentSnapshot( + scopeKey: string +): readonly NativeChatComposerImageAttachment[] { + return pendingCache.get(scopeKey) ?? EMPTY +} + +function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { + if (next.length === 0) { + pendingCache.delete(scopeKey) + pendingOwners.delete(scopeKey) + } else { + pendingCache.set(scopeKey, next) + } + listeners.get(scopeKey)?.forEach((listener) => listener()) +} + +export function subscribeToNativeChatPendingAttachments( + 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) + } + } +} + +/** 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, + 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]) +} + +/** Removes a pending chip and returns it; undefined when the user already removed it. */ +export function takeNativeChatPendingAttachment( + scopeKey: string, + id: string +): NativeChatComposerImageAttachment | undefined { + const current = nativeChatPendingAttachmentSnapshot(scopeKey) + const taken = current.find((attachment) => attachment.id === id) + if (taken) { + writePending( + scopeKey, + current.filter((attachment) => attachment !== taken) + ) + } + 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 { + const owner = pendingOwners.get(scopeKey) + if (!takeNativeChatPendingAttachment(scopeKey, id)) { + return false + } + appendToNativeChatComposerDraft( + scopeKey, + { images: [{ id, path, ...(connectionId ? { connectionId } : {}) }], fromUser: true }, + owner + ) + 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) + if (current.some((attachment) => attachment.id === id && attachment.hidden)) { + writePending( + scopeKey, + current.map((attachment) => { + const { hidden: _hidden, ...shown } = attachment + return attachment.id === id ? shown : attachment + }) + ) + } +} + +export function clearNativeChatPendingAttachments(scopeKey: string): void { + if (pendingCache.has(scopeKey)) { + writePending(scopeKey, []) + } +} + +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/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..0d655d8de8a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -0,0 +1,122 @@ +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, + nativeChatAttachmentOwnerChangedNotice, + prepareNativeChatSessionAttachmentUpload, + uploadNativeChatSessionAttachmentPaths, + type NativeChatRuntimeSessionAttachmentOwner +} from './native-chat-attachment-upload' + +/** 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) => void + /** Removes the chip; false when the user already removed it, so its file must not attach. */ + drop: (id: string) => boolean + /** 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 +} + +/** + * 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 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[] + owner: NativeChatRuntimeSessionAttachmentOwner + chips: NativeChatPendingAttachmentChips + /** The composer was disabled or torn down meanwhile. */ + isAbandoned: () => boolean + ownerStillCurrent: () => boolean + setNotice: (notice: string | null) => void +}): Promise { + 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) { + args.chips.drop(chipId) + } + } + let stored: Map + let reasons: Map + try { + const prepared = await prepareNativeChatSessionAttachmentUpload(args.owner) + if (!prepared.ok) { + dropAll() + args.setNotice(prepared.notice) + return + } + const result = await uploadNativeChatSessionAttachmentPaths( + pending.map(({ path }) => path), + prepared.target + ) + stored = new Map(result.uploaded.map(({ sourcePath, path }) => [sourcePath, path])) + 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() + return + } + if (!args.ownerStillCurrent()) { + dropAll() + args.setNotice(nativeChatAttachmentOwnerChangedNotice()) + return + } + 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({ name: basename(path), reason: reasons.get(path) ?? '' }) + } + continue + } + if (isNativeChatImageAttachmentPath(storedPath)) { + args.chips.resolve(chipId, storedPath, null) + continue + } + // Other files become `@path` references, as every attach does, unless their chip was removed. + if (args.chips.drop(chipId)) { + references.push(storedPath) + } + } + // Inserting at the caret clears the notice, so what failed is said after. + if (references.length > 0) { + args.chips.attachReferences(references) + } + if (notAttached.length > 0) { + // 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/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 6523ff17fa63..036e6b746430 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 @@ -530,3 +530,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 2bfa6a087373..d6498a38031f 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 @@ -6,6 +6,22 @@ import { appendNativeChatAttachmentCache, readNativeChatAttachmentCache } 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 a message's text and images into a composer, after whatever is there. */ export function returnMessageToComposer( @@ -62,9 +78,38 @@ export function useStructuredAgentSessionWithdrawnRestore( /** Entries a Stop took out of the outbox here, before the host held them; false when the * composer could not take them. */ byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => boolean + /** 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( - () => ({ byStop: (entries) => restoreUnsentMessages(composerScopeKey, entries) }), + () => ({ + byStop: (entries) => restoreUnsentMessages(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. + for (const entry of returned) { + returnMessageToComposer( + composerScopeKey, + `withdrawn-${entry.clientMessageId}`, + entry.body.blocks + ) + } + return { + entries: disposition.entries.filter((entry) => !returned.includes(entry)), + error: agentSessionWriteNoticeParts(refusal, 'send') + } + } + }), [composerScopeKey] ) } 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 ecb90b5d0568..5208dfe6e8ac 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 @@ -10,6 +10,8 @@ import { } from './use-native-chat-composer-attachments' import * as draftCache from './native-chat-draft-cache' 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' vi.mock('@/i18n/i18n', () => ({ @@ -450,7 +452,122 @@ describe('useNativeChatComposerAttachments', () => { act(() => probe.root.unmount()) }) - it('excludes a pending chip from the scope cache while a settled chip persists', async () => { + it('tells a finishing upload that the user removed its chip', async () => { + const probe = await renderProbe('pty-1') + const begun: { removed?: string | null; kept?: string | null } = {} + act(() => { + begun.removed = probe.latest().pendingChips.begin(undefined, 'report.pdf') + begun.kept = probe.latest().pendingChips.begin(undefined, 'notes.md') + }) + const { removed, kept } = begun + if (!removed || !kept) { + throw new Error('expected pending chips') + } + act(() => probe.latest().removeImageAttachment(removed)) + + const live: boolean[] = [] + act(() => { + live.push(probe.latest().pendingChips.drop(removed), probe.latest().pendingChips.drop(kept)) + }) + expect(live).toEqual([false, true]) + expect(probe.latest().imageAttachments).toEqual([]) + 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('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('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 }) + 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 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(() => { @@ -460,10 +577,14 @@ describe('useNativeChatComposerAttachments', () => { probe.latest().attachResolvedPaths(['/tmp/settled.png']) }) - 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() + // 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 caec3b8118f8..b3fe6e13bf24 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,17 +1,15 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { appendNativeChatAttachmentCache } from './native-chat-draft-cache' import { useCallback, useEffect, useMemo, useRef, - useState, useSyncExternalStore, type RefObject } from 'react' -import { translate } from '@/i18n/i18n' import { nativeChatComposerTargetIsRemote, + nativeChatLocalAttachmentUnsupportedNotice, type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -24,6 +22,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 type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachments, + revealNativeChatPendingAttachment, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment, + useNativeChatPendingAttachments +} from './native-chat-pending-attachment-cache' +import { appendNativeChatAttachmentCache, 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 = { attachmentScopeKey: string @@ -62,9 +72,15 @@ export function useNativeChatComposerAttachments({ clearImageAttachments: () => void flushPendingAttachments: () => void removeImageAttachment: (id: string) => 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 - dropPendingImageAttachment: (id: string) => void + revealPendingImageAttachment: (id: string, previewUrl?: string) => void + dropPendingImageAttachment: (id: string) => boolean + pendingChips: NativeChatPendingAttachmentChips } { const subscribe = useCallback( (listener: () => void) => subscribeToNativeChatComposerDraft(attachmentScopeKey, listener), @@ -77,44 +93,35 @@ export function useNativeChatComposerAttachments({ // Why: a restored paste shows only once main confirms it is still kept, so until the restore // check is done it waits like a chip still saving, instead of flashing before a placeholder. const restoring = useRestoredNativeChatComposerDraftImageCheck(attachmentScopeKey, subscribe) - // Chips still being written, and the clipboard previews this composer minted, are its own. - const [local, setLocal] = useState(NO_LOCAL_ATTACHMENTS) - // Read by callbacks between renders; only they change it, always together with the state. - const localRef = useRef(local) - const updateLocal = useCallback((next: LocalAttachments) => { - localRef.current = next - setLocal(next) - }, []) + // 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 pending = useNativeChatPendingAttachments(attachmentScopeKey) + const { previews, setPreview, releasePreview, releaseAllPreviews } = + useNativeChatComposerAttachmentPreviews(settled, pending) const imageAttachments = useMemo( () => [ ...settled.map((image) => { - const previewUrl = local.previews.get(image.id) + const previewUrl = previews.get(image.id) if (restoring && isKeptLocalPaste(image)) { return { ...image, pending: true } } return previewUrl ? { ...image, previewUrl } : image }), - ...local.pending + ...pending.map((chip) => { + const previewUrl = previews.get(chip.id) + return previewUrl ? { ...chip, previewUrl } : chip + }) ], - [local, restoring, settled] + [pending, previews, restoring, settled] ) - // A preview whose image left the draft (sent, or removed elsewhere) is released. + const imageAttachmentCounter = useRef(0) + const mountedRef = useRef(true) useEffect(() => { - const current = localRef.current - const gone = [...current.previews.keys()].filter( - (id) => !settled.some((image) => image.id === id) - ) - if (gone.length === 0) { - return - } - const previews = new Map(current.previews) - for (const id of gone) { - releasePreviewUrl(previews.get(id)) - previews.delete(id) + mountedRef.current = true + return () => { + mountedRef.current = false } - updateLocal({ ...current, previews }) - }, [settled, updateLocal]) - const imageAttachmentCounter = useRef(0) + }, []) const nextAttachmentId = useCallback((): string => { imageAttachmentCounter.current += 1 @@ -135,12 +142,7 @@ 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( @@ -176,9 +178,10 @@ 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 => { - // Without image input the saved paste is attached by path once it lands, so no image chip. - if (disabledRef.current || !acceptsImages) { + (previewUrl?: string, pendingName?: string, options?: { hidden?: true }): string | null => { + // Without image input the saved paste is attached by path once it lands, so no image chip; + // a named upload chip is a file, not an image, and still shows. + if (disabledRef.current || (!acceptsImages && pendingName === undefined)) { return null } if (attachmentTargetBlocked()) { @@ -186,83 +189,94 @@ export function useNativeChatComposerAttachments({ return null } const id = nextAttachmentId() - const current = localRef.current - updateLocal({ - ...current, - pending: [...current.pending, { id, path: '', previewUrl, pending: true }] + addNativeChatPendingAttachment(attachmentScopeKey, { + id, + path: '', + pending: true, + ...(pendingName ? { pendingName } : {}), + ...(options?.hidden ? { hidden: true } : {}) }) + setPreview(id, previewUrl) return id }, [ acceptsImages, + attachmentScopeKey, attachmentTargetBlocked, disabledRef, nextAttachmentId, noteAttachmentTargetBlocked, - updateLocal + setPreview ] ) - /** Drops this composer's own copy of a chip; the store is not touched. */ - const forgetLocalAttachment = useCallback( - (id: string, keepPreview = false): NativeChatComposerImageAttachment | undefined => { - const current = localRef.current - const pending = current.pending.find((attachment) => attachment.id === id) - const previewUrl = pending?.previewUrl ?? current.previews.get(id) - if (!pending && previewUrl === undefined) { - return undefined - } - const previews = new Map(current.previews) - previews.delete(id) - if (keepPreview && previewUrl) { - previews.set(id, previewUrl) - } else { - releasePreviewUrl(previewUrl) - } - updateLocal({ - pending: current.pending.filter((attachment) => attachment.id !== id), - previews - }) - return pending + const resolvePendingImageAttachment = useCallback( + (id: string, path: string, connectionId?: string | null) => { + settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId) }, - [updateLocal] + [attachmentScopeKey] ) - const resolvePendingImageAttachment = useCallback( - (id: string, path: string, connectionId?: string | null) => { - if (forgetLocalAttachment(id, true)) { - appendNativeChatAttachmentCache( - attachmentScopeKey, - [{ id, path, ...(connectionId ? { connectionId } : {}) }], - { fromUser: true } - ) - } + const revealPendingImageAttachment = useCallback( + (id: string, previewUrl?: string) => { + setPreview(id, previewUrl) + revealNativeChatPendingAttachment(attachmentScopeKey, id) }, - [attachmentScopeKey, forgetLocalAttachment] + [attachmentScopeKey, setPreview] ) // A pending chip was never saved, so dropping one, even late from a replaced composer, leaves - // the store alone. + // the draft alone. const dropPendingImageAttachment = useCallback( - (id: string) => { - forgetLocalAttachment(id) + (id: string): boolean => { + releasePreview(id) + return takeNativeChatPendingAttachment(attachmentScopeKey, id) !== undefined }, - [forgetLocalAttachment] + [attachmentScopeKey, releasePreview] + ) + + const pendingChips = useMemo( + (): NativeChatPendingAttachmentChips => ({ + begin: beginPendingImageAttachment, + resolve: resolvePendingImageAttachment, + drop: dropPendingImageAttachment, + // 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 + ] ) return { + pendingChips, imageAttachments, attachResolvedPaths, clearImageAttachments: () => { - const current = localRef.current - current.pending.forEach((attachment) => releasePreviewUrl(attachment.previewUrl)) - current.previews.forEach(releasePreviewUrl) - updateLocal(NO_LOCAL_ATTACHMENTS) + releaseAllPreviews() + clearNativeChatPendingAttachments(attachmentScopeKey) updateNativeChatComposerDraft(attachmentScopeKey, { images: [] }, 'immediate') }, flushPendingAttachments, removeImageAttachment: (id) => { - if (forgetLocalAttachment(id)) { + releasePreview(id) + // A chip still on its way is removed before it settles, so its file never joins the message. + if (takeNativeChatPendingAttachment(attachmentScopeKey, id)) { return } const images = readNativeChatComposerDraft(attachmentScopeKey).images @@ -274,24 +288,11 @@ export function useNativeChatComposerAttachments({ }, beginPendingImageAttachment, resolvePendingImageAttachment, + revealPendingImageAttachment, dropPendingImageAttachment } } -type LocalAttachments = { - pending: NativeChatComposerImageAttachment[] - previews: ReadonlyMap -} - -const NO_LOCAL_ATTACHMENTS: LocalAttachments = { pending: [], previews: 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) - } -} - export { readNativeChatAttachmentCache, appendNativeChatAttachmentCache, 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()) + }) }) 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..421462ae1447 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -0,0 +1,303 @@ +// @vitest-environment happy-dom +// Pastes into a structured chat on a paired server land in that server's attachment store, on +// 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 { 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.', + 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; hidden?: true } + +const sessionOwner: NativeChatAttachmentOwner = { + kind: 'runtime-session', + 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/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[]; begun: () => number; revealed: () => number }> { + const chips: Chip[] = [] + let counter = 0 + let revealed = 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: (_preview, _name, options) => { + counter += 1 + 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) { + Object.assign(chip, { path, pending: false }) + } + }, + 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, + begun: () => counter, + revealed: () => revealed + } +} + +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 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 }]) + }) + + 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'))) + 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 }]) + }) + + 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) + }) + + 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) + }) + + 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, + 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') + // 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 () => { + 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') + }) + + 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 636ba4017f85..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 @@ -1,7 +1,7 @@ -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 { translate } from '@/i18n/i18n' import { setNativeChatPasteFailure } from './native-chat-composer-notice' import type { AgentType } from '../../../../shared/agent-status-types' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' @@ -17,6 +17,10 @@ import { nativeChatWorktreeNotReadyNotice, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { + ownerAcceptsClipboardImage, + saveNativeChatClipboardImage +} from './native-chat-clipboard-image-save' export type UseNativeChatComposerPasteArgs = { targetKey?: string @@ -29,21 +33,20 @@ 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 setNotice: (notice: string | null, errorText?: string) => 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, @@ -52,6 +55,7 @@ export function useNativeChatComposerPaste({ attachResolvedPaths, beginPendingImageAttachment, resolvePendingImageAttachment, + revealPendingImageAttachment, dropPendingImageAttachment, insertTypedText, setCaret, @@ -61,28 +65,17 @@ 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, 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(() => { @@ -91,35 +84,31 @@ 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( - 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 } : { forNativeChatDraft: true } - ) - 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()) { - setNativeChatPasteFailure( - setNotice, - error, - translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') + ( + owner: NativeChatAttachmentOwner, + besidePastedText: () => Promise, + ready?: () => void + ) => + saveNativeChatClipboardImage(owner, { + ready, + setNotice: (notice, cause, errorText) => { + void (cause === 'serverTooOld' ? besidePastedText() : Promise.resolve(false)).then( + (quiet) => { + if (quiet || !canPaste()) { + return + } + if (errorText) { + setNotice(notice, errorText) + } else { + setNotice(notice) + } + } ) } - return { status: 'failed' } - } - }, + }), [canPaste, setNotice] ) @@ -196,20 +185,23 @@ 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. - const previewUrl = ownerAcceptsClipboardImage(owner) - ? URL.createObjectURL(imageFile) - : undefined - const pendingId = previewUrl ? beginPendingImageAttachment(previewUrl) : null - if (previewUrl && !pendingId) { - 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 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' + const chip = startImageChip(owner, imageFile, { + hidden: awaitServer, + canShow: () => ownerAcceptsClipboardImage(owner) && canPaste() + }) + const pendingId = chip.id void (async () => { - const saved = await saveClipboardImageForOwner(owner) + const saved = await saveClipboardImageForOwner( + owner, + async () => Boolean(text), + awaitServer ? chip.reveal : undefined + ) + if (keepStoreUploadAfterUnmount(pendingId, saved)) { + return + } if (saved.status !== 'saved' || !canPaste()) { if (pendingId) { lifetime.pending.delete(pendingId) @@ -224,7 +216,6 @@ export function useNativeChatComposerPaste({ })() }, [ - beginPendingImageAttachment, canPaste, lifetime, caret, @@ -232,6 +223,8 @@ export function useNativeChatComposerPaste({ insertTypedText, showPasteUnavailable, resolveAttachmentOwner, + keepStoreUploadAfterUnmount, + startImageChip, saveClipboardImageForOwner, setCaret, setNotice, @@ -286,14 +279,20 @@ 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 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) @@ -314,6 +313,8 @@ export function useNativeChatComposerPaste({ lifetime, dropPendingImageAttachment, insertTypedText, + keepStoreUploadAfterUnmount, + track, showPasteUnavailable, resolveAttachmentOwner, saveClipboardImageForOwner, 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 a5d2abc0b31b..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 @@ -9,7 +9,14 @@ const mocks = vi.hoisted(() => ({ stat: vi.fn(), resolveNativeChatAttachmentOwner: vi.fn(), resolveNativeChatAttachmentOwnerForWorktree: vi.fn(), - uploadNativeChatAttachmentPaths: vi.fn() + uploadNativeChatAttachmentPaths: vi.fn(), + prepareNativeChatSessionAttachmentUpload: 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', () => ({ @@ -22,10 +29,15 @@ 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 type { AgentSessionAttachmentPathUploadResult } from '../../../../shared/agent-session-attachments' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' type HookApi = ReturnType @@ -37,16 +49,29 @@ 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(() => true), + attachReferences: 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 { @@ -54,8 +79,10 @@ function Probe({ useNativeChatExternalAttachments({ terminalTabId: 'tab-1', structuredWorktreeId, + structuredSession, disabled, attachResolvedPaths, + pendingChips, setNotice }) ) @@ -70,7 +97,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 @@ -89,7 +118,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 @@ -470,4 +501,222 @@ 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 target = { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + + /** `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((id: string) => !removed.has(id)), + attachReferences: vi.fn() + } + } + + 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: `'${sourcePath.split('/').at(-1)}' is 60 MB, over the 50 MB chat attachment limit` + })) + } + } + + 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', 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( + uploaded([ + ['/Users/me/shot.png', '/srv/agent-session-attachments/u1/shot.png'], + ['/Users/me/notes.md', '/srv/agent-session-attachments/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/u1/shot.png', + null + ) + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) + // 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 () => { + 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(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) + }) + + 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(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() + }) + + 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(chips.attachReferences).not.toHaveBeenCalled() + expect(notices.at(-1)).toBe('needs newer server') + }) + + 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)[] = [] + 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(chips.resolve).not.toHaveBeenCalled() + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') + 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 () => { + 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'], undefined, { + destinationIsCurrent: expect.any(Function) + }) + }) + }) }) 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 b23318be3141..b3f92042869a 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 @@ -13,15 +13,23 @@ 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' import { userNamedFileAccess } from '@/lib/local-file-access' import { findTerminalTabWorktreeId } from './native-chat-file-link' 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 @@ -30,15 +38,24 @@ export type UseNativeChatExternalAttachmentsArgs = { 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 ) } @@ -50,8 +67,10 @@ function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerW export function useNativeChatExternalAttachments({ terminalTabId, structuredWorktreeId, + structuredSession, disabled, attachResolvedPaths, + pendingChips, setNotice }: UseNativeChatExternalAttachmentsArgs): { attachExternalPaths: (paths: string[]) => void @@ -65,19 +84,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( @@ -116,6 +152,17 @@ export function useNativeChatExternalAttachments({ isSameComposerWorkspace(capturedWorkspace, workspaceRef.current) && capturedWorktreeId === currentWorktreeId() && nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner()) + if (owner.kind === 'runtime-session') { + void attachNativeChatSessionAttachmentPaths({ + paths, + owner, + chips: pendingChipsRef.current, + isAbandoned: () => disabledRef.current, + ownerStillCurrent, + setNotice + }) + return + } if (owner.kind !== 'ssh') { void (async () => { const readablePaths: string[] = [] 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..f801303dab14 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -0,0 +1,128 @@ +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 pending in the scope's attachment cache, where its result settles for + * the composer's return (`native-chat-pending-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, beginPendingImageAttachment, revealPendingImageAttachment } = args + const { 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 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) + } + // Through the scope's cache, so it shows even in a composer that came back since. + const reveal = (): void => { + if (!id) { + return + } + const previewUrl = lifetime.active ? URL.createObjectURL(imageFile) : undefined + if (previewUrl) { + 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)) { + return false + } + if (saved.status === 'saved' && saved.tempPath) { + resolvePendingImageAttachment(pendingId, saved.tempPath, null) + } else { + dropPendingImageAttachment(pendingId) + } + return true + }, + [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] + ) + return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } +} 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 10e4c781c0fe..5002dd49121e 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 @@ -100,6 +100,22 @@ describe('attachment guard follows what the host claims', () => { }) }) +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() + }) +}) + // The pane brings the latest into view on this: a conversation command at the press, a message // once admitted, and neither for a refusal or a command the chat does not run. describe('reports the sends that bring the latest into view', () => { 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 0f4a1298c15f..2bff57f735e0 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 @@ -69,7 +69,8 @@ export function useNativeChatStructuredComposerSend({ }: UseNativeChatStructuredComposerSendArgs): NativeChatStructuredComposerSend { return useCallback( async (text, attachments = imageAttachments, sentFrom): Promise => { - 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 = isNativeChatStructuredHostCommand(text, agent, structuredTransport) 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 2f118fb534a9..a832df64930c 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 @@ -188,7 +188,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 @@ -196,7 +197,7 @@ export function useStructuredAgentSessionOutbox(args: { recordFailures(getStructuredAgentSessionOutbox(sessionId), disposition.entries) commitStructuredAgentSessionOutbox(sessionId, disposition.entries) }, - [recordFailures, sessionId] + [recordFailures, restoreWithdrawn, sessionId] ) const [drains, setDrains] = useState(0) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a2f89fb1fce8..99c8479abe59 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2906,6 +2906,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.", @@ -2938,6 +2939,7 @@ "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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 031038f149b2..167d35400bfc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18058,6 +18058,8 @@ "cannotRunHere": "Orca can't run this agent in a chat here.", "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.", @@ -18155,6 +18157,9 @@ "skillScopeBuiltIn": "Built-in", "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", "draftNotSaved": "This draft couldn't be saved yet. Orca keeps trying.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d9cafabd60dd..6c91fcda740e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17655,6 +17655,8 @@ "cannotRunHere": "Orca no puede ejecutar este agente en un chat aquí.", "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ó.", @@ -17698,6 +17700,9 @@ "stopDictation": "Detener dictado", "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", "copyImage": "Copiar imagen", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9999b2f99758..e61768d24f4b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17757,6 +17757,8 @@ "cannotRunHere": "Orca ne peut pas exécuter cet agent dans un chat ici.", "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é.", @@ -17815,6 +17817,9 @@ "skillScopeBuiltIn": "Intégrée", "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", "draftNotSaved": "Ce brouillon n'a pas encore pu être enregistré. Orca continue d'essayer.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ca313e7942ba..8d7461e6bcbb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17691,6 +17691,8 @@ "cannotRunHere": "Orca はここでこの Agent をチャットで実行できません。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこれをチャットの履歴に保存できませんでした。", + "attachmentExpired": "この添付ファイルは期限切れです。", + "reattachFile": "削除してから、もう一度添付してください。", "conversationCleared": "この会話はクリアされました。", "openCurrentConversation": "続けるには、現在の会話を開いてください。", "clearUnfinished": "前回の /clear は完了しませんでした。", @@ -17734,6 +17736,9 @@ "stopDictation": "ディクテーションを停止する", "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", + "attachFailed": "{{files}} を添付できませんでした。", + "attachFailedBecause": "{{files}} を添付できませんでした。{{reason}}", + "uploadingFile": "{{name}} をアップロードしています…", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", "draftNotSaved": "この下書きはまだ保存できていません。Orca は保存を再試行し続けます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 49daef9ca7c8..dd5bf6513e9c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17691,6 +17691,8 @@ "cannotRunHere": "Orca는 여기에서 이 에이전트를 채팅으로 실행할 수 없습니다.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 내용을 채팅 기록에 저장하지 못했습니다.", + "attachmentExpired": "이 첨부 파일이 만료되었습니다.", + "reattachFile": "제거한 후 다시 첨부하세요.", "conversationCleared": "이 대화는 지워졌습니다.", "openCurrentConversation": "계속하려면 현재 대화를 여세요.", "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", @@ -17734,6 +17736,9 @@ "stopDictation": "받아쓰기 중지", "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", + "attachFailed": "{{files}}을(를) 첨부할 수 없습니다.", + "attachFailedBecause": "{{files}}을(를) 첨부할 수 없습니다. {{reason}}", + "uploadingFile": "{{name}} 업로드 중…", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", "draftNotSaved": "이 초안을 아직 저장하지 못했습니다. Orca가 계속 다시 시도합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b19f3a7a1013..89a8f27d8328 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17656,6 +17656,8 @@ "cannotRunHere": "Orca 无法在此处以聊天方式运行此智能体。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将此内容保存到聊天历史记录中。", + "attachmentExpired": "此附件已过期。", + "reattachFile": "请将其移除后重新附加。", "conversationCleared": "此对话已被清除。", "openCurrentConversation": "请打开当前对话以继续。", "clearUnfinished": "上一次 /clear 未完成。", @@ -17699,6 +17701,9 @@ "stopDictation": "停止听写", "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", + "attachFailed": "无法附加 {{files}}。", + "attachFailedBecause": "无法附加 {{files}}。{{reason}}", + "uploadingFile": "正在上传 {{name}}…", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", "draftNotSaved": "此草稿尚未保存成功。Orca 会继续尝试。", 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 f05599752912..3cd9943fa40e 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' import type { LocalFileAccess } from '../../../shared/local-file-access' const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 @@ -99,6 +100,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') 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 198a2e114730..9361391a9e67 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' import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' export function createTerminalTabCloseActions( @@ -264,6 +265,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) 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..4b1e6b56af93 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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', () => ({ + 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 new file mode 100644 index 000000000000..b32c0bc63405 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts @@ -0,0 +1,89 @@ +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + agentSessionPastedImageName, + type AgentSessionAttachmentUploadCommitResult, + type AgentSessionAttachmentUploadTarget +} from '../../../../shared/agent-session-attachments' +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: AgentSessionAttachmentUploadTarget +): Promise { + const padding = contentBase64.endsWith('==') ? 2 : contentBase64.endsWith('=') ? 1 : 0 + const byteLength = (contentBase64.length / 4) * 3 - padding + const { uploadId } = await callPinned<{ uploadId: string }>( + target, + 'agentSessionAttachment.uploadStart', + { + sessionId: target.sessionId, + name: agentSessionPastedImageName(Date.now(), createBrowserUuid()), + byteLength + } + ) + 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 callPinned(target, 'agentSessionAttachment.uploadAppend', { + uploadId, + offset: (charOffset / 4) * 3, + contentBase64: contentBase64.slice( + charOffset, + charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) + }) + } + const stored = await callPinned( + target, + 'agentSessionAttachment.uploadCommit', + { uploadId } + ) + return stored.path + } catch (error) { + // 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-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index ec7c0a7e1798..e11ac26b0eb1 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -168,6 +168,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 b015e1647b4e..b70be2880e4b 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,15 @@ export function createWebUiApi(): NonNullable['ui']> { if (!contentBase64) { return null } + if (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) }, clipboardHasImage, 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 new file mode 100644 index 000000000000..d2fabcabd0e0 --- /dev/null +++ b/src/shared/agent-session-attachments.ts @@ -0,0 +1,114 @@ +// 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 + +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 { + 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, 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() + let visible = cleaned.replace(/^\.+/, '').replace(/[. ]+$/, '') + if (visible.length === 0) { + return 'attachment' + } + 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 extensionAt = visible.lastIndexOf('.') + const extension = + extensionAt > 0 && utf8ByteLength(visible.slice(extensionAt)) <= 16 + ? visible.slice(extensionAt) + : '' + const stem = visible.slice(0, visible.length - extension.length) + 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, + * 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/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 90cff98dd605..81124af85f6a 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -26,6 +26,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-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 9206ffd7a25c..e11950e73f75 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -66,6 +66,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 3796de6eaf05..3e0e3277c036 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -44,6 +44,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { cannotRunHere: "Orca can't run this agent in a chat here.", 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/shared/protocol-version.ts b/src/shared/protocol-version.ts index 873b5773ebcf..f600123fd29f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -270,6 +270,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, @@ -397,6 +401,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 5058efda5665..d425c20d38dc 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -41,6 +41,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 { ReadVisualParams } from './agent-session-visual-params' import { @@ -633,6 +639,11 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, '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,