Files
orca/src/shared/agent-session-attachments.ts
T
Brennan Benson 726c572da7 Attach a long paste as a text file, and say a message is too large before it is sent (#30398)
* fix(native-chat): attach a long paste as a text file, and say a message is too large before it is sent

A paste of 32 KB or more (or one that would push the message past the 256 KB
send limit) into a structured chat is saved as a file the agent reads, through
the same pending-chip and @path route a dropped file takes: Orca's paste folder
here, or the chat's attachment store on its paired server. Claude gets the paste
folder as an allowed directory. A message still over the limit is handed back
before it is sent, on desktop and the phone, with words naming the limit instead
of the generic 'not sent, send it again'.

* test: Claude launch parity snapshot grants the paste folder

* fix(native-chat): a long paste replaces the selection at once and settles in place

A long paste now puts an inline Pasted text pill where it went, replacing any
selected text immediately, and the saved file (or inline text, or the original
selection on failure) replaces that same pill when the save finishes, in its own
chat's draft. Nothing reads the caret later, so order and position hold. The
paste never joins the image row, so image numbers are unchanged. The size check
measures the real message body with the selection replaced and images included.
A server that keeps no files takes the paste inline when the message still fits.
A pending paste a restart lost shows Paste not saved, and Send says so.

* fix(native-chat): type the pasted-text parser and the composer's missing draft scope

* fix(native-chat): admit paste references before goals and commands

* fix(native-chat): resolve staged paste paths without editing the draft

* fix(native-chat): consolidate paste imports for code quality checks

* fix(native-chat): retain checked string types for saved paste paths

* fix(native-chat): retain completed paste facts through history and delimit paths

* fix(native-chat): keep structured question rendering within file limit

* fix(native-chat): pasted or inserted text that looks like a paste token stays literal

* fix(web): keep the browser's UI bridge under the file length limit

* style: format the files #29982 merged unformatted

* fix(native-chat): retain oversized drafts before send admission

* fix(native-chat): keep the composer handle's merge resolution to main's destructure
2026-10-22 10:16:32 -07:00

145 lines
5.8 KiB
TypeScript

// Files attached to a structured chat: kept in a store owned by the host that runs the chat (a
// paired server's clients upload the bytes; this machine's own pastes are written in place), and
// the agent only ever sees the host path.
import type { ImportSkipReason } from './filesystem-import-result-types'
/** Directory name of a host'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
/** Bytes a padded base64 string decodes to, without decoding it. */
export function base64DecodedByteLength(base64: string): number {
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0
return (base64.length / 4) * 3 - padding
}
/** The appends that carry a base64 body: whole groups at byte offsets, and one empty append for an
* empty file, which the store still expects before a commit. */
export function* agentSessionAttachmentAppends(
contentBase64: string
): Generator<{ offset: number; contentBase64: string }> {
let charOffset = 0
do {
yield {
offset: (charOffset / 4) * 3,
contentBase64: contentBase64.slice(
charOffset,
charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS
)
}
charOffset += AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS
} while (charOffset < contentBase64.length)
}
export type AgentSessionAttachmentUploadStartResult = { uploadId: string }
export type AgentSessionAttachmentUploadCommitResult = {
path: string
name: string
byteLength: number
}
/** Whether `filePath` names a file in some host's attachment store. Only routes a preview read;
* the host 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`
}
/** `orca-paste-…txt`, the name a long text paste saved as a file gets, so it reads as "Pasted text". */
export function agentSessionPastedTextName(now: number, unique: string): string {
return `orca-paste-${now}-${unique}.txt`
}