Repository navigation
feat(native-chat): attach files to a structured chat on a paired server #25146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
66 commits
Select commit
Hold shift + click to select a range
f189c43
feat(native-chat): a paired server keeps a store for chat attachments
brennanb2025 6707980
feat(native-chat): upload attached files into a paired server's chat …
brennanb2025 9e25d1f
feat(native-chat): attach files to a structured chat on a paired server
brennanb2025 c9529ac
test(native-chat): type the attachment test doubles without bare casts
brennanb2025 27dcb7b
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ffad820
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 7f04bf0
refactor(native-chat): keep the clipboard RPC on its own upload bookk…
brennanb2025 98187b3
feat(native-chat): claim chat attachments when the host admits a message
brennanb2025 d10dfaf
feat(native-chat): let structured Claude read the host's chat attachm…
brennanb2025 467404c
fix(native-chat): skip a dropped folder before staging walks into it
brennanb2025 ac570f0
fix(native-chat): pin the browser client's chat paste to its server
brennanb2025 8deee88
refactor(native-chat): let the host's claim be the only attachment check
brennanb2025 1706734
chore(native-chat): keep the claim hook beside the draft consume and …
brennanb2025 23a1eaf
test(native-chat): pin the attachment store grant in the Claude launc…
brennanb2025 e3e74a7
fix(native-chat): create the attachment store at install and claim on…
brennanb2025 c13bbd8
refactor(native-chat): reuse the newer-Orca notice and drop leftover …
brennanb2025 7933ea7
fix(native-chat): give a message refused for an expired attachment ba…
brennanb2025 7606667
fix(native-chat): keep uploads across a prompt, say why a file did no…
brennanb2025 a6ff388
fix(native-chat): keep a paste's upload across a prompt; pin the Clau…
brennanb2025 dff514a
test(native-chat): type the grant hand-off captures without casts
brennanb2025 bf41b08
test(native-chat): pin that the composer files a paste's upload acros…
brennanb2025 17b6706
fix(native-chat): retry a held rename at commit and keep cut names Wi…
brennanb2025 5bf9679
fix(native-chat): keep attachments still on their way in the pane's a…
brennanb2025 af18c94
test(native-chat): a dropped file still uploading stays pending acros…
brennanb2025 fdc5373
fix(native-chat): reveal a rich-text image in a composer that came ba…
brennanb2025 2bead96
fix(native-chat): never evict a pane's attachments while a composer s…
brennanb2025 82c5880
fix(native-chat): never evict the scope just written when every older…
brennanb2025 690970c
fix(native-chat): keep the attachment store out of the RPC method tab…
brennanb2025 f8f1e1c
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ccfa30e
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ca5bcb5
refactor(native-chat): the submission hook gets its own module
brennanb2025 3c6bec3
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ba43a87
refactor(native-chat): fit the attachment sweep and paste upload into…
brennanb2025 491ec81
fix(native-chat): pending chips sit beside main's saved draft, which …
brennanb2025 c704cee
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 12a65ab
test(native-chat): give the claims test main's provider handle and me…
brennanb2025 235239b
fix(native-chat): a paste still uploading dies with its tab or workspace
brennanb2025 f503dd8
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 83aa2e5
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 94f09d7
fix(native-chat): the claim type uses the journal's own SQLite type
brennanb2025 700ceb2
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 aee5adb
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 5572a75
refactor(native-chat): read the pending-image handlers off the compos…
brennanb2025 c686bf4
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 3e4d704
test(native-chat): pass the launch-args resolver main now requires in…
brennanb2025 79bdfd7
fix(native-chat): grant the attachment store beside folders the saved…
brennanb2025 02cebe3
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 837b626
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 5aaa993
test(native-chat): run the attachment store's SQLite tests in the Nod…
brennanb2025 679a623
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 8e6c9b2
test(native-chat): follow main's attach ownership recheck (#25749) in…
brennanb2025 25a05c3
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ecd71f7
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ae64b29
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 846dfc5
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 4d09aa3
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 ee9d8d7
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 be41bf0
test(native-chat): a named upload chip still shows when the agent tak…
brennanb2025 3682e9a
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 58348b6
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 b915afc
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 9c9c264
fix(native-chat): a paired-server image paste failure keeps its error…
brennanb2025 1b137cf
fix(native-chat): the attachment sweep reads the chat list directly n…
brennanb2025 d547b1c
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 30461ad
Merge remote-tracking branch 'origin/main' into brennanb2025/rsc-chat…
brennanb2025 e1b4055
Merge branch 'main' of https://github.com/stablyai/orca into HEAD
brennanb2025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)}` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AgentSessionAttachmentPathUploadResult> => { | ||
| 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() | ||
| } | ||
| } | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown> | ||
| 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<string, unknown>, | ||
| _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) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Skip the tests that create symlinks on Windows. Both tests call
symlink. Windows CI environments without elevation or Developer Mode cannot create symlinks, so these tests can fail there.src/main/ipc/agent-session-attachment-upload.test.ts#L130-L140: change the test toit.skipIf(process.platform === 'win32')(...).src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts#L195-L202: change the test toit.skipIf(process.platform === 'win32')(...).Based on learnings: tests that create symlinks "should be guarded with
it.skipIf(process.platform === 'win32')".📍 Affects 2 files
src/main/ipc/agent-session-attachment-upload.test.ts#L130-L140(this comment)src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts#L195-L202Source: Learnings