From 9962899186947f4e5c0842920a46967d1dce30f2 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sat, 5 Sep 2026 22:49:27 +0800 Subject: [PATCH 1/9] feat(client-core): add plain-send turn.submit and drop the echo overlay --- packages/client/core/AGENTS.md | 7 ++- .../core/src/__tests__/conversation.test.ts | 27 +++++++++ packages/client/core/src/client.ts | 12 ++++ .../client/core/src/client/control-channel.ts | 17 ++++++ .../core/src/client/pending-registry.ts | 8 +++ .../client/core/src/conversation-store.ts | 26 +-------- packages/client/core/src/conversation.ts | 15 ++++- .../integration/conversation-client.test.ts | 53 +++++++++++++++++- .../conversation-store-projection.test.ts | 56 ++++++++++++++++++- 9 files changed, 189 insertions(+), 32 deletions(-) diff --git a/packages/client/core/AGENTS.md b/packages/client/core/AGENTS.md index d2759091a..bfdbcb270 100644 --- a/packages/client/core/AGENTS.md +++ b/packages/client/core/AGENTS.md @@ -36,9 +36,10 @@ Rules the projection store enforces — keep them when touching it: no watermark, supersedes nothing, and takes its baseline from the first stamped event. - **A stamped repeat stays in the `EventBuffer`** (attach replays resolved asks): dropping it would read as a gap. Only unstamped repeats are deduped. -- **The echo's attachment blocks win** for the row sharing its identity: durable prompts are - text-only until attachment refs land, so the store overlays the buffered echo's content on a read - row with the same id. Remove this with the attachment store. +- **Durable user rows carry attachment refs** (`resource_link` with an `attachment:` URI). The live + echo is text-only; do not overlay echo content onto a read row — that leaked inline base64 and + hid the durable refs. Pending drafts render from the client's blob cache until submit roots the + attachment. - Live user echoes carry no envelope `turnId` (they precede turn tracking); never bucket by it. `history-unavailable` read items become `ConversationItem`s of that kind under the current turn: diff --git a/packages/client/core/src/__tests__/conversation.test.ts b/packages/client/core/src/__tests__/conversation.test.ts index 061ac44b8..d959f280e 100644 --- a/packages/client/core/src/__tests__/conversation.test.ts +++ b/packages/client/core/src/__tests__/conversation.test.ts @@ -297,6 +297,33 @@ describe('buildConversation', () => { }); }); + it('keeps durable attachment refs when a later echo for the same row is text-only', () => { + const messageId = 'msg-turn-1' as MessageId; + const link = { + type: 'resource_link' as const, + uri: 'attachment:att-1', + name: 'shot.png', + }; + const c = buildConversation([ + { + type: 'user-message', + messageId, + content: [{ type: 'text', text: 'describe this' }, link], + }, + { + type: 'user-message', + messageId, + content: [{ type: 'text', text: 'describe this' }], + }, + ]); + + expect(c.items).toHaveLength(1); + expect(c.items[0]).toMatchObject({ + id: messageId, + blocks: [{ type: 'text', text: 'describe this' }, link], + }); + }); + it('rewinds the selected prompt and every later conversation event before replacement', () => { const c = buildConversation([ { type: 'status', status: 'idle' }, diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index a9c371ff3..b3da6ed6b 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -68,6 +68,7 @@ import type { StartOptions, TerminalMetadata, TerminalReplayEvent, + TurnSubmitInput, UploadId, WireMessage, WorkspaceFile, @@ -116,6 +117,7 @@ import type { RandomUUID, RequestAck, SessionStartResult, + TurnSubmitResult, } from './client/pending-registry'; import { PendingRegistry, resolveRandomUUID } from './client/pending-registry'; import { TerminalChannel } from './client/terminal-channel'; @@ -127,6 +129,7 @@ export type { AttachmentReadBytes, } from './client/attachment-channel'; export type { Sha256Hex } from './client/blob-cache'; +export { base64ToBytes } from './client/blob-cache'; export type { BrowserCommandExecutor } from './client/browser-host-channel'; export type { ConversationReadClientOptions, @@ -141,6 +144,7 @@ export type { PluginList, PluginMutation, SessionStartResult, + TurnSubmitResult, } from './client/pending-registry'; type EventCb = (entry: SequencedAgentEvent) => void; @@ -474,6 +478,9 @@ export class LinkCodeClient { ...(p.cursor !== undefined && { cursor: p.cursor }), }); break; + case 'turn.submitted': + this.pending.resolve('turnSubmit', p.replyTo, { turnId: p.turnId }); + break; case 'conversation.graph.changed': this.graphChanges.note(p.sessionId, { graphRevision: p.graphRevision, @@ -836,6 +843,11 @@ export class LinkCodeClient { return this.control.readConversation(sessionId, opts); } + /** See {@link ControlChannel.submitTurn}. */ + submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise { + return this.control.submitTurn(sessionId, input); + } + /** The newest `conversation.graph.changed` seen for the session on this connection. */ latestGraphChange(sessionId: SessionId): ConversationGraphChange | undefined { return this.graphChanges.get(sessionId); diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index c49be734c..2a4d63d16 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -63,6 +63,7 @@ import type { StandaloneSkillScope, StartOptions, TurnId, + TurnSubmitInput, WirePayload, WorkspaceFile, WorkspaceId, @@ -70,6 +71,7 @@ import type { WorkspaceRecord, WorkspaceScript, } from '@linkcode/schema'; +import { OperationIdSchema } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { @@ -81,6 +83,7 @@ import type { PluginMutation, RequestAck, SessionStartResult, + TurnSubmitResult, } from './pending-registry'; import { sendCorrelated } from './pending-registry'; @@ -182,6 +185,20 @@ export class ControlChannel { })); } + /** + * Plain send onto the active leaf. `parentTurnId` is omitted on purpose — explicit-parent + * submit is a later client. Idempotency is a fresh `operationId` per call. + */ + submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise { + return this.sendCorrelated('turnSubmit', (clientReqId) => ({ + kind: 'turn.submit', + clientReqId, + sessionId, + operationId: OperationIdSchema.parse(`op-${clientReqId}`), + input, + })); + } + /** One page of the host-composed projection toward a leaf. Only the final page carries the live * tail and the `(epoch, seq)` watermark; `readConversationProjection` walks the whole read. */ readConversation( diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index 5abfe21ff..abac13972 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -36,6 +36,7 @@ import type { SimulatorStreamCodec, StandaloneSkill, TerminalMetadata, + TurnId, WirePayload, WorkspaceFile, WorkspaceRecord, @@ -88,6 +89,11 @@ export type ConversationReadPage = Omit< 'kind' | 'replyTo' >; +/** `turn.submitted` without its correlation fields. Plain-send only — parent/revision is later. */ +export interface TurnSubmitResult { + readonly turnId: TurnId; +} + export type AttachmentUploadBegun = Omit< Extract, 'kind' | 'replyTo' @@ -133,6 +139,7 @@ export interface PendingValueMap { historyRead: AgentHistoryReadResult; conversationGraph: ConversationGraphSnapshot; conversationRead: ConversationReadPage; + turnSubmit: TurnSubmitResult; configGet: ProvidersConfig; accountsGet: Accounts; accountModels: AccountModel[]; @@ -201,6 +208,7 @@ export class PendingRegistry { historyRead: new Map(), conversationGraph: new Map(), conversationRead: new Map(), + turnSubmit: new Map(), configGet: new Map(), accountsGet: new Map(), accountModels: new Map(), diff --git a/packages/client/core/src/conversation-store.ts b/packages/client/core/src/conversation-store.ts index 3b5eae240..30e60f709 100644 --- a/packages/client/core/src/conversation-store.ts +++ b/packages/client/core/src/conversation-store.ts @@ -1,4 +1,4 @@ -import type { AgentEvent, ContentBlock, ConversationWatermark, SessionId } from '@linkcode/schema'; +import type { AgentEvent, ConversationWatermark, SessionId } from '@linkcode/schema'; import { compareConversationWatermarks, userRowMessageId } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; import { noop } from 'foxact/noop'; @@ -110,20 +110,13 @@ function createProjectionStore( }; const foldSeed = (): void => { - const echoes = attachmentBearingEchoes(client.eventsSnapshot(sessionId)); for (let i = 0, len = seed.items.length; i < len; i++) { const item = seed.items[i]; if (!('event' in item)) { builder.unavailable(); continue; } - const { event } = item; - if (event.type !== 'user-message') { - fold(event, item.ts); - continue; - } - const content = echoes.get(event.messageId); - fold(content === undefined ? event : { ...event, content }, item.ts); + fold(item.event, item.ts); } }; @@ -195,21 +188,6 @@ function createProjectionStore( }; } -/** Live user echoes carry the prompt's attachment blocks while durable rows are text-only until - * attachment refs land: for the row sharing an echo's identity, the echo's content wins. */ -function attachmentBearingEchoes( - events: readonly SequencedAgentEvent[], -): Map { - const byId = new Map(); - for (let i = 0, len = events.length; i < len; i++) { - const { event } = events[i]; - if (event.type === 'user-message' && event.content.some((block) => block.type !== 'text')) { - byId.set(event.messageId, event.content); - } - } - return byId; -} - type UserMessageEvent = Extract; interface SeedUserMessageQueue { messages: UserMessageEvent[]; diff --git a/packages/client/core/src/conversation.ts b/packages/client/core/src/conversation.ts index e9136a9a1..7a58f2ee1 100644 --- a/packages/client/core/src/conversation.ts +++ b/packages/client/core/src/conversation.ts @@ -18,6 +18,7 @@ import type { ToolCallUpdate, UsageReport, } from '@linkcode/schema'; +import { attachmentIdFromUri } from '@linkcode/schema'; /** * Conversation view-model: folds the daemon's flat, append-only `AgentEvent[]` into the structured @@ -171,6 +172,16 @@ export interface ConversationViewModel { export type Conversation = ConversationViewModel; +/** A `conversation.read` user row names stored attachments as `attachment:` resource links. The + * live echo is text-only — never let that echo replace a row that already carries those refs. */ +function hasAttachmentRef(blocks: readonly ContentBlock[]): boolean { + for (let i = 0, len = blocks.length; i < len; i++) { + const block = blocks[i]; + if (block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined) return true; + } + return false; +} + /** Append a content block, concatenating consecutive text blocks for smooth streaming. Pure: * returns a fresh array so previously emitted snapshots never observe the append. */ function appendBlock(blocks: readonly ContentBlock[], block: ContentBlock): ContentBlock[] { @@ -375,9 +386,11 @@ function createConversationProjection(): ConversationBuilder { if (existing !== undefined) { const item = items[existing]; if (item.kind === 'message' && item.role === 'user') { + const incoming = event.content; + const keepRefs = hasAttachmentRef(item.blocks) && !hasAttachmentRef(incoming); items[existing] = { ...item, - blocks: [...event.content], + blocks: keepRefs ? item.blocks : [...incoming], branchCursor: event.branchCursor ?? item.branchCursor, receivedAt: receivedAt ?? item.receivedAt, }; diff --git a/packages/client/core/tests/integration/conversation-client.test.ts b/packages/client/core/tests/integration/conversation-client.test.ts index 84890f987..d56ee8e1d 100644 --- a/packages/client/core/tests/integration/conversation-client.test.ts +++ b/packages/client/core/tests/integration/conversation-client.test.ts @@ -1,5 +1,5 @@ import type { RunId, SessionId, TurnId } from '@linkcode/schema'; -import { WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { AttachmentIdSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import { createLocalTransportPair, createWireMessage } from '@linkcode/transport'; import { wait } from 'foxts/wait'; import { describe, expect, it } from 'vitest'; @@ -9,6 +9,7 @@ import { createConnectedLocalClient } from '../support/local-client'; const sessionId = 'sess-conv' as SessionId; const leafTurnId = 'turn-leaf' as TurnId; +const rOperationId = /^op-creq-/; describe('LinkCodeClient conversation graph API', () => { it('advertises the graph path only for hosts at or above its wire version', async () => { @@ -138,4 +139,54 @@ describe('LinkCodeClient conversation graph API', () => { client.dispose(); serverTransport.close(); }); + + it('resolves a plain-send turn.submit without parent or revision', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const submitted: unknown[] = []; + serverTransport.onMessage((msg) => { + const p = msg.payload; + if (p.kind !== 'turn.submit') return; + submitted.push(p); + serverTransport.send( + createWireMessage({ + kind: 'turn.submitted', + replyTo: p.clientReqId, + turnId: leafTurnId, + }), + ); + }); + + const attachmentId = AttachmentIdSchema.parse('att-1'); + await expect( + client.submitTurn(sessionId, { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }), + ).resolves.toEqual({ turnId: leafTurnId }); + expect(submitted).toHaveLength(1); + expect(submitted[0]).toEqual( + expect.objectContaining({ + kind: 'turn.submit', + sessionId, + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }), + ); + expect(submitted[0]).not.toHaveProperty('parentTurnId'); + expect(submitted[0]).not.toHaveProperty('expectedGraphRevision'); + expect(submitted[0]).toEqual( + expect.objectContaining({ operationId: expect.stringMatching(rOperationId) }), + ); + + client.dispose(); + serverTransport.close(); + }); }); diff --git a/packages/client/core/tests/integration/conversation-store-projection.test.ts b/packages/client/core/tests/integration/conversation-store-projection.test.ts index 862001a41..5ec355a1a 100644 --- a/packages/client/core/tests/integration/conversation-store-projection.test.ts +++ b/packages/client/core/tests/integration/conversation-store-projection.test.ts @@ -246,7 +246,7 @@ describe('projection conversation store', () => { h.close(); }); - it('takes the attachment blocks of the live echo that shares a row’s identity', async () => { + it('keeps the durable row’s blocks even when a live echo carries attachments', async () => { const h = await harness(); h.send( echo(1, 'describe this', { content: [{ type: 'text', text: 'describe this' }, IMAGE] }), @@ -257,11 +257,61 @@ describe('projection conversation store', () => { ); await tick(); - const store = h.store(seedOf([userRow(1, 'describe this')], { epoch: 1, seq: 1 })); + const link: ContentBlock = { + type: 'resource_link', + uri: 'attachment:att-1', + name: 'shot.png', + }; + const store = h.store( + seedOf( + [ + { + turnId: turn(1), + ts: 1_700_000_000_001, + event: echo(1, 'describe this', { + content: [{ type: 'text', text: 'describe this' }, link], + }), + }, + ], + { epoch: 1, seq: 1 }, + ), + ); + const [row] = store.getSnapshot().items; + expect(row.kind === 'message' && row.blocks).toEqual([ + { type: 'text', text: 'describe this' }, + link, + ]); + h.close(); + }); + + it('does not let a live echo above the watermark replace durable attachment refs', async () => { + const h = await harness(); + const link: ContentBlock = { + type: 'resource_link', + uri: 'attachment:att-1', + name: 'shot.png', + }; + h.send(echo(1, 'describe this'), { epoch: 1, seq: 2 }); + await tick(); + + const store = h.store( + seedOf( + [ + { + turnId: turn(1), + ts: 1_700_000_000_001, + event: echo(1, 'describe this', { + content: [{ type: 'text', text: 'describe this' }, link], + }), + }, + ], + { epoch: 1, seq: 1 }, + ), + ); const [row] = store.getSnapshot().items; expect(row.kind === 'message' && row.blocks).toEqual([ { type: 'text', text: 'describe this' }, - IMAGE, + link, ]); h.close(); }); From c69bbfea83d87dcda95811595d7673f77ba59e4d Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sat, 5 Sep 2026 22:52:34 +0800 Subject: [PATCH 2/9] feat(ui,workbench): composer and timeline on attachment references --- .../src/renderer/src/shell/desktop-shell.tsx | 5 +- .../workbench/src/mock/dev-mock-host.ts | 48 +++- .../__tests__/prompt-attachments.test.ts | 107 +++++++ .../src/surface/prompt-attachments.ts | 195 +++++++++++++ .../workbench/src/surface/workbench.tsx | 263 ++++++++++++------ .../integration/dev-mock-attachments.test.ts | 70 ++--- packages/presentation/i18n/src/locales/en.ts | 4 + .../presentation/i18n/src/locales/zh-cn.ts | 4 + .../__tests__/content-block-view.test.tsx | 61 +++- .../src/chat/__tests__/user-message.test.tsx | 51 +++- .../ui/src/chat/attachment-card.tsx | 64 +++++ .../ui/src/chat/attachment-preview.tsx | 99 +++++++ .../ui/src/chat/content-block-view.tsx | 50 +++- packages/presentation/ui/src/chat/index.ts | 2 + .../presentation/ui/src/chat/user-message.tsx | 20 +- .../__tests__/attachment-capability.test.ts | 43 +++ .../__tests__/new-session-surface.test.tsx | 3 - .../ui/src/shell/attachment-capability.ts | 24 ++ .../ui/src/shell/composer-attachments.ts | 6 + .../presentation/ui/src/shell/composer.tsx | 57 +++- .../ui/src/shell/conversation-surface.tsx | 14 +- packages/presentation/ui/src/shell/index.ts | 1 + .../ui/src/shell/new-session-surface.tsx | 14 +- .../presentation/ui/src/shell/shell-frame.tsx | 10 +- 24 files changed, 1049 insertions(+), 166 deletions(-) create mode 100644 packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts create mode 100644 packages/client/workbench/src/surface/prompt-attachments.ts create mode 100644 packages/presentation/ui/src/chat/attachment-card.tsx create mode 100644 packages/presentation/ui/src/chat/attachment-preview.tsx create mode 100644 packages/presentation/ui/src/shell/__tests__/attachment-capability.test.ts create mode 100644 packages/presentation/ui/src/shell/attachment-capability.ts diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index a15d80afa..a4a62df8a 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -80,7 +80,6 @@ export function DesktopShell({ newSessionWorkspaceId, onNewSessionWorkspaceChange, runtimeCues, - attachmentSupport, agentCatalogs, selectableHarnesses, accountModels, @@ -115,6 +114,7 @@ export function DesktopShell({ mentionItems, onMentionQueryChange, conversationComposer, + onPrepareAttachment, onRespondPermission, onRespondQuestion, onHostArtifact, @@ -421,7 +421,6 @@ export function DesktopShell({ workspaceId={newSessionWorkspaceId} onWorkspaceChange={onNewSessionWorkspaceChange} runtimeCues={runtimeCues} - attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} selectableHarnesses={selectableHarnesses} accountModels={accountModels} @@ -438,6 +437,7 @@ export function DesktopShell({ onPickDirectory={pickDirectory} onRegisterWorkspace={onRegisterWorkspace} onPickAttachmentFiles={pickAttachmentFiles} + onPrepareAttachment={onPrepareAttachment} /> ) : ( // Keyed per session: switching resets the composer draft and scroll without touching the shell. @@ -450,7 +450,6 @@ export function DesktopShell({ agentLabel={agentLabel} accountModels={active ? accountModels?.[active.kind] : undefined} accountId={active?.accountId} - attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} onOpenProviderSettings={onOpenProviderSettings} diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index c750cf9cd..b53a23fa3 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -47,6 +47,7 @@ import { AGENT_INPUT_CAPABILITIES, ATTACHMENT_UPLOAD_CHUNK_BYTES, AttachmentIdSchema, + attachmentUri, blobIdFromSha256, declaredMimeTypeMatches, managedAgentAssetId, @@ -160,6 +161,8 @@ interface MockSession extends SessionInfo { interface MockTurn { graph: ConversationGraphTurn; content: ContentBlock[]; + /** `conversation.read` user-row content; the live echo stays text-only. */ + readContent?: ContentBlock[]; } interface MockJournalEntry { @@ -269,7 +272,7 @@ export class DevMockHost { private readonly attachmentBlobs = new Map(); private readonly attachmentRecords = new Map< string, - { blobId: BlobId; sizeBytes: number; name: string } + { blobId: BlobId; sizeBytes: number; name: string; mimeType?: string; kind: string } >(); private readonly attachmentBegins = new Map(); /** The daemon's `isReachable` roots: sessions whose prompt or resource names the attachment. */ @@ -1427,6 +1430,7 @@ export class DevMockHost { } const content = turnSubmitContent(p.input); const turn = this.beginTurn(session, content, p.input.type === 'prompt' ? undefined : p.input); + turn.readContent = this.projectTurnSubmit(p.input); this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: turn.graph.turnId }); if (p.input.type === 'prompt') { const result = await this.streamMockReply(session, content); @@ -2066,6 +2070,8 @@ export class DevMockHost { blobId, sizeBytes: upload.declaredSize, name: upload.name, + mimeType: upload.mimeType, + kind: upload.attachmentKind, }); this.send({ kind: 'attachment.upload.committed', @@ -2102,10 +2108,37 @@ export class DevMockHost { blobId: blobIdFromSha256(digest), sizeBytes: bytes.byteLength, name: payload.name, + mimeType: payload.mimeType, + kind: payload.mimeType?.startsWith('image/') ? 'image' : 'file', }); return attachmentId; } + /** Durable `conversation.read` row: refs become `attachment:` links, never bytes. */ + private projectTurnSubmit(input: TurnSubmitInput): ContentBlock[] { + if (input.type !== 'prompt') return turnSubmitContent(input); + const content: ContentBlock[] = []; + for (let i = 0, len = input.blocks.length; i < len; i++) { + const block = input.blocks[i]; + if (block.type === 'text') { + content.push(textBlock(block.text)); + continue; + } + const record = this.attachmentRecords.get(block.attachmentId); + content.push({ + type: 'resource_link', + uri: attachmentUri(block.attachmentId), + name: record?.name ?? block.attachmentId, + ...(record?.mimeType !== undefined && { mimeType: record.mimeType }), + ...(record !== undefined && { + size: record.sizeBytes, + description: record.kind, + }), + }); + } + return content; + } + /** Root an attachment in a session, the way persisting a prompt or a resource does on the daemon. */ private rootAttachment(sessionId: SessionId, attachmentId: AttachmentId): void { const rooted = this.attachmentSessions.get(attachmentId) ?? new Set(); @@ -2188,6 +2221,17 @@ export class DevMockHost { } } +function projectMockReadEvent(session: MockSession, entry: MockJournalEntry): AgentEvent { + const { event } = entry; + if (event.type !== 'user-message' || entry.turnId === undefined) return event; + for (let i = 0, len = session.graphTurns.length; i < len; i++) { + const turn = session.graphTurns[i]; + if (turn.graph.turnId !== entry.turnId || turn.readContent === undefined) continue; + return { ...event, content: turn.readContent }; + } + return event; +} + function turnSubmitContent(input: TurnSubmitInput): ContentBlock[] { switch (input.type) { case 'prompt': @@ -2235,7 +2279,7 @@ function readMockProjection(session: MockSession): ConversationReadItem[] { epoch: entry.epoch, seq: entry.seq, ts: entry.ts, - event: entry.event, + event: projectMockReadEvent(session, entry), }); if ( entry.turnId !== undefined && diff --git a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts new file mode 100644 index 000000000..a6c7b26fa --- /dev/null +++ b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts @@ -0,0 +1,107 @@ +import type { Conversation } from '@linkcode/client-core'; +import type { ContentBlock, SessionId, TurnId } from '@linkcode/schema'; +import { AttachmentIdSchema, userRowMessageId } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + isStoredAttachmentBlock, + notePendingUserAttachments, + overlayPendingUserAttachments, + promptBlocksFromComposer, +} from '../prompt-attachments'; + +const sessionId = 'sess-1' as SessionId; +const messageId = userRowMessageId('turn-1' as TurnId); + +const EMPTY: Conversation = { + items: [], + status: null, + usage: null, + usageReport: null, + currentModeId: null, + approvalPolicy: null, + currentModel: null, + currentEffort: null, + availableCommands: null, + availableModels: null, + capabilities: null, + stopReason: null, + pendingPermissionIds: [], + pendingQuestionIds: [], +}; + +describe('promptBlocksFromComposer', () => { + it('keeps text and converts stored attachment links to refs', () => { + const attachmentId = AttachmentIdSchema.parse('att-1'); + expect( + promptBlocksFromComposer([ + { type: 'text', text: 'look' }, + { type: 'resource_link', uri: `attachment:${attachmentId}`, name: 'shot.png' }, + { type: 'image', data: 'cG5n', mimeType: 'image/png' }, + ]), + ).toEqual([ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ]); + }); +}); + +describe('overlayPendingUserAttachments', () => { + it('fills a text-only echo from pending store refs and yields to a durable row', () => { + const link: ContentBlock = { + type: 'resource_link', + uri: 'attachment:att-1', + name: 'shot.png', + }; + notePendingUserAttachments(sessionId, messageId, [link]); + const echo: Conversation = { + ...EMPTY, + items: [ + { + kind: 'message', + id: messageId, + turnId: 'turn-1', + role: 'user', + blocks: [{ type: 'text', text: 'look' }], + isStreaming: false, + }, + ], + }; + expect(overlayPendingUserAttachments(echo, sessionId).items[0]).toMatchObject({ + blocks: [{ type: 'text', text: 'look' }, link], + }); + + const durable: Conversation = { + ...EMPTY, + items: [ + { + kind: 'message', + id: messageId, + turnId: 'turn-1', + role: 'user', + blocks: [{ type: 'text', text: 'look' }, link], + isStreaming: false, + }, + ], + }; + expect(overlayPendingUserAttachments(durable, sessionId).items[0]).toMatchObject({ + blocks: [{ type: 'text', text: 'look' }, link], + }); + }); + + it('detects stored attachment links', () => { + expect( + isStoredAttachmentBlock({ + type: 'resource_link', + uri: 'attachment:att-1', + name: 'shot.png', + }), + ).toBe(true); + expect( + isStoredAttachmentBlock({ + type: 'resource_link', + uri: 'file:///tmp/a.ts', + name: 'a.ts', + }), + ).toBe(false); + }); +}); diff --git a/packages/client/workbench/src/surface/prompt-attachments.ts b/packages/client/workbench/src/surface/prompt-attachments.ts new file mode 100644 index 000000000..d02d98955 --- /dev/null +++ b/packages/client/workbench/src/surface/prompt-attachments.ts @@ -0,0 +1,195 @@ +import type { Conversation, LinkCodeClient } from '@linkcode/client-core'; +import { base64ToBytes } from '@linkcode/client-core'; +import type { + AttachmentId, + ContentBlock, + MessageId, + PromptBlock, + SessionId, +} from '@linkcode/schema'; +import { AttachmentIdSchema, attachmentIdFromUri, attachmentUri } from '@linkcode/schema'; +import type { ComposerAttachment } from '@linkcode/ui'; + +const objectUrls = new Map(); +const OBJECT_URL_CAP = 8; + +function blobUrlFor(bytes: Uint8Array, mimeType?: string): string { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return URL.createObjectURL(new Blob([copy], { type: mimeType || undefined })); +} + +/** Timeline preview URLs, LRU-capped. Composer tray URLs are owned by the tray and revoked there. */ +export function attachmentObjectUrl( + attachmentId: string, + bytes: Uint8Array, + mimeType?: string, +): string { + const existing = objectUrls.get(attachmentId); + if (existing) { + objectUrls.delete(attachmentId); + objectUrls.set(attachmentId, existing); + return existing; + } + const url = blobUrlFor(bytes, mimeType); + objectUrls.set(attachmentId, url); + for (const [oldest, stale] of objectUrls) { + if (oldest === attachmentId || objectUrls.size <= OBJECT_URL_CAP) break; + objectUrls.delete(oldest); + URL.revokeObjectURL(stale); + } + return url; +} + +export function revokeAttachmentObjectUrls(): void { + for (const url of objectUrls.values()) URL.revokeObjectURL(url); + objectUrls.clear(); +} + +export function isStoredAttachmentBlock(block: ContentBlock): boolean { + return block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined; +} + +export function promptBlocksFromComposer(content: readonly ContentBlock[]): PromptBlock[] { + const blocks: PromptBlock[] = []; + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }); + continue; + } + if (block.type !== 'resource_link') continue; + const id = attachmentIdFromUri(block.uri); + if (id === undefined) continue; + blocks.push({ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse(id) }); + } + return blocks; +} + +export function storedAttachmentBlocks(content: readonly ContentBlock[]): ContentBlock[] { + return content.filter(isStoredAttachmentBlock); +} + +function storedAttachmentResourceLink( + attachmentId: AttachmentId, + name: string, + mimeType: string | undefined, + sizeBytes: number, + kind: string, +): ContentBlock { + return { + type: 'resource_link', + uri: attachmentUri(attachmentId), + name, + ...(mimeType !== undefined && mimeType.length > 0 && { mimeType }), + size: sizeBytes, + description: kind, + }; +} + +export async function stageStoreAttachment( + client: LinkCodeClient, + file: File, + pending: ComposerAttachment, +): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + const kind = pending.kind === 'image' ? 'image' : 'file'; + const { attachmentId } = await client.putAttachment({ + bytes, + name: file.name, + mimeType: file.type || undefined, + attachmentKind: kind, + }); + return { + ...pending, + status: 'ready', + url: blobUrlFor(bytes, file.type), + block: storedAttachmentResourceLink(attachmentId, file.name, file.type, file.size, kind), + }; +} + +export async function stageStoreAttachmentFromBase64( + client: LinkCodeClient, + pending: ComposerAttachment, + content: string, + mimeType: string | undefined, + size: number, +): Promise { + const bytes = base64ToBytes(content); + const kind = pending.kind === 'image' ? 'image' : 'file'; + const { attachmentId } = await client.putAttachment({ + bytes, + name: pending.name, + mimeType, + attachmentKind: kind, + }); + return { + ...pending, + status: 'ready', + mimeType, + sizeBytes: size, + url: blobUrlFor(bytes, mimeType), + block: storedAttachmentResourceLink(attachmentId, pending.name, mimeType, size, kind), + }; +} + +type PendingKey = `${string}:${string}`; + +const pendingByRow = new Map(); +let pendingVersion = 0; +const pendingListeners = new Set<() => void>(); + +function pendingKey(sessionId: SessionId, messageId: string): PendingKey { + return `${sessionId}:${messageId}`; +} + +function bumpPending(): void { + pendingVersion += 1; + for (const listener of pendingListeners) listener(); +} + +export function notePendingUserAttachments( + sessionId: SessionId, + messageId: MessageId, + blocks: readonly ContentBlock[], +): void { + const refs = storedAttachmentBlocks(blocks); + if (refs.length === 0) return; + pendingByRow.set(pendingKey(sessionId, messageId), refs); + bumpPending(); +} + +export function subscribePendingUserAttachments(onStoreChange: () => void): () => void { + pendingListeners.add(onStoreChange); + return () => { + pendingListeners.delete(onStoreChange); + }; +} + +export function pendingUserAttachmentsVersion(): number { + return pendingVersion; +} + +export function overlayPendingUserAttachments( + conversation: Conversation, + sessionId: SessionId | null, +): Conversation { + if (!sessionId) return conversation; + const items = conversation.items; + let changed = false; + const next = items.slice(); + for (let i = 0, len = items.length; i < len; i++) { + const item = items[i]; + if (item.kind !== 'message' || item.role !== 'user') continue; + const extra = pendingByRow.get(pendingKey(sessionId, item.id)); + if (extra === undefined) continue; + if (item.blocks.some(isStoredAttachmentBlock)) { + pendingByRow.delete(pendingKey(sessionId, item.id)); + continue; + } + changed = true; + next[i] = { ...item, blocks: [...item.blocks, ...extra] }; + } + if (!changed) return conversation; + return { ...conversation, items: next }; +} diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index ca4183bc3..4cc3349e4 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -9,7 +9,12 @@ import type { WorkspaceId, WorkspaceRecord, } from '@linkcode/schema'; -import { MessageIdSchema, workspaceKind } from '@linkcode/schema'; +import { + AttachmentIdSchema, + MessageIdSchema, + userRowMessageId, + workspaceKind, +} from '@linkcode/schema'; import { archiveWorkspace, cancelTurn, @@ -27,7 +32,7 @@ import { updateWorkspace, } from '@linkcode/sdk'; import type { - AttachmentSupportByAgent, + AttachmentPreview, ComposerAttachment, ComposerDirectiveControls, ConversationComposerController, @@ -39,17 +44,19 @@ import type { ThreadGroupViewModel, } from '@linkcode/ui'; import { + AttachmentPreviewProvider, attachmentFromReadFile, extractPinnedGroup, failedComposerAttachmentFromPath, groupThreadsByWorkspace, + resetAttachmentPreviews, selectCurrentPlan, useKeyboardShortcutLabel, } from '@linkcode/ui'; import { noop } from 'foxact/noop'; import { useSet } from 'foxact/use-set'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { extractErrorMessage, isErrorLikeObject } from 'foxts/extract-error-message'; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'use-intl'; import { useAgentRuntimeOnboarding } from '../agent-runtime/onboarding'; import { captureProductEvent } from '../analytics/product-analytics'; @@ -73,6 +80,19 @@ import { RuntimeTerminalBlock } from '../terminal/block'; import { useWorkspaces } from '../workspace/hooks'; import { submitActiveSessionInput } from './active-session-input'; import { useNewSessionDefaultsStore } from './new-session-defaults-store'; +import { + attachmentObjectUrl, + isStoredAttachmentBlock, + notePendingUserAttachments, + overlayPendingUserAttachments, + pendingUserAttachmentsVersion, + promptBlocksFromComposer, + revokeAttachmentObjectUrls, + stageStoreAttachment, + stageStoreAttachmentFromBase64, + subscribePendingUserAttachments, +} from './prompt-attachments'; +import { useSessionSelectionStore } from './selection-store'; import type { WorkbenchShellComponent } from './shell'; import { DefaultWorkbenchShell } from './shell'; import { newlyConfirmedStartupSelection, reflectedStartupSelection } from './startup-selection'; @@ -83,15 +103,6 @@ import { useWorkbenchKeyboardShortcuts } from './use-workbench-keyboard-shortcut import type { WorkbenchSessions } from './use-workbench-sessions'; import { useWorkbenchSessions } from './use-workbench-sessions'; -// TODO(backend): replace this frontend stub with attachment support advertised by each session. -const ATTACHMENT_SUPPORT: AttachmentSupportByAgent = { - 'claude-code': true, - codex: true, - opencode: true, - pi: true, - // Headless streaming-json has no image prompt path verified yet. -}; - async function handleHostArtifact(content: string, mimeType: string): Promise<{ url: string }> { const { data } = await hostArtifact({ content, mimeType }); return { url: data.url }; @@ -251,12 +262,22 @@ function WorkbenchSessionSurface({ const { data: providers } = useData(getProviderConfig, {}); const selectableHarnesses = providers === undefined ? null : selectableHarnessKinds(providers); const sdkClient = useWorkbenchSdkClient(); + const client = sdkClient.raw; const activeSessionId = sessions.activeId; + useSyncExternalStore(subscribePendingUserAttachments, pendingUserAttachmentsVersion); + const displayedConversation = overlayPendingUserAttachments(conversation, activeSessionId); // Announce observation of the focused session so the daemon replays buffered per-session state // this client missed (e.g. the approval-policy advertisement after a reload). Fire-and-forget. useEffect(() => { if (activeSessionId) sdkClient.raw.attachSession(activeSessionId); }, [sdkClient, activeSessionId]); + useEffect( + () => () => { + revokeAttachmentObjectUrls(); + resetAttachmentPreviews(); + }, + [activeSessionId], + ); const { data: workspaces, isLoading: workspacesLoading, @@ -331,8 +352,30 @@ function WorkbenchSessionSurface({ return submitActiveSessionInput(input, turnInputMutation.trigger); } + async function submitPrompt(sessionId: SessionId, content: ContentBlock[]): Promise { + if (!client.supportsConversationGraph) { + await submitActiveSessionInput({ type: 'prompt', content }, turnInputMutation.trigger); + return; + } + const blocks = promptBlocksFromComposer(content); + if (blocks.length === 0) { + await submitActiveSessionInput({ type: 'prompt', content }, turnInputMutation.trigger); + return; + } + try { + const { turnId } = await client.submitTurn(sessionId, { type: 'prompt', blocks }); + notePendingUserAttachments(sessionId, userRowMessageId(turnId), content); + } catch (error) { + if (!isRequestFailureReportedInConversation(error)) onError(error); + throw error; + } + } + function handleSend(content: ContentBlock[]): Promise { - return submitActiveInput({ type: 'prompt', content }).then(() => { + onClearError(); + const { selectedId: sessionId, draft } = useSessionSelectionStore.getState(); + if (draft || !sessionId) return Promise.reject(new Error('No active session')); + return submitPrompt(sessionId, content).then(() => { captureProductEvent('turn submitted', { input_kind: 'prompt' }); }); } @@ -345,11 +388,12 @@ function WorkbenchSessionSurface({ if (active?.historyCapabilities?.branch !== true) { throw new Error('Prompt editing is unavailable for this session'); } + const stripped = content.filter((block) => !isStoredAttachmentBlock(block)); await rewriteMutation.trigger({ sourceSessionId: active.sessionId, sourceMessageId: MessageIdSchema.parse(messageId), branchCursor, - content, + content: stripped, }); sessions.refresh(); } @@ -399,8 +443,11 @@ function WorkbenchSessionSurface({ submission.branch, ); // The first input rides behind the started session, like any conversation send. - void turnInputMutation - .trigger({ sessionId, input: submission.input }) + const firstTurn = + submission.input.type === 'prompt' + ? submitPrompt(sessionId, submission.input.content) + : turnInputMutation.trigger({ sessionId, input: submission.input }).then(noop); + void firstTurn .then(() => { captureProductEvent('turn submitted', { input_kind: submission.input.type }); // Some process-per-turn adapters can confirm a startup override only after their first @@ -425,10 +472,24 @@ function WorkbenchSessionSurface({ async function handleReadAttachmentFile(path: string): Promise { try { const { data } = await readWorkspaceFile({ cwd: '/', path }); - return attachmentFromReadFile(data, { + const inline = attachmentFromReadFile(data, { tooLarge: tComposer('attachmentTooLarge'), unsupportedType: tComposer('attachmentUnsupportedType'), }); + if ( + !client.supportsAttachmentStore || + inline.status !== 'ready' || + data.encoding !== 'base64' + ) { + return inline; + } + return await stageStoreAttachmentFromBase64( + client, + inline, + data.content, + data.mimeType, + data.size, + ); } catch (err) { return failedComposerAttachmentFromPath( path, @@ -437,6 +498,27 @@ function WorkbenchSessionSurface({ } } + function handlePrepareAttachment( + file: File, + pending: ComposerAttachment, + ): Promise { + return stageStoreAttachment(client, file, pending); + } + + async function resolveAttachmentPreview(attachmentId: string): Promise { + if (!activeSessionId) return null; + try { + const { bytes } = await client.getAttachmentBytes( + activeSessionId, + AttachmentIdSchema.parse(attachmentId), + ); + return { url: attachmentObjectUrl(attachmentId, bytes) }; + } catch (error) { + if (isErrorLikeObject(error) && 'code' in error && error.code === 'not_found') return null; + throw error; + } + } + function handleModeChange(modeId: string): Promise { if (!sessions.activeId) return Promise.reject(new Error('No active session')); onClearError(); @@ -499,6 +581,7 @@ function WorkbenchSessionSurface({ const conversationComposer: ConversationComposerController = { onSend: handleSend, onStop: handleStopTurn, + onPrepareAttachment: client.supportsAttachmentStore ? handlePrepareAttachment : undefined, directiveControls, onModeChange: handleModeChange, onApprovalPolicyChange: handleApprovalPolicyChange, @@ -652,76 +735,78 @@ function WorkbenchSessionSurface({ } return ( - - ) : undefined - } - attachmentSupport={ATTACHMENT_SUPPORT} - threadGroups={threadGroups} - workspaces={projectWorkspaces} - workspacesLoading={workspacesLoading} - sessionsLoading={sessions.isLoading} - chatWorkspace={chatWorkspace} - activeSession={active} - draft={draft} - newSessionWorkspaceId={newSessionWorkspaceId} - onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} - accountModels={accountModels} - selectableHarnesses={selectableHarnesses} - agentCatalogs={agentCatalogs} - newSessionPreferredEfforts={newSessionPreferredEfforts} - newSessionPreferredBranches={newSessionPreferredBranches} - NewSessionBranchPickerComponent={RuntimeNewSessionBranchPicker} - runtimeCues={onboarding.cues} - onDownloadAgent={onboarding.download} - onContinueUnverified={onboarding.acknowledgeUnverified} - conversation={conversation} - onEditPrompt={handleEditPrompt} - respondingRequestIds={respondingRequestIds} - responseErrors={visibleResponseErrors} - header={{ - title: active ? (active.title ?? tk(active.kind)) : 'Link Code', - subtitle: active?.cwd, - sessionId: active?.sessionId ?? null, - usage: conversation.usage, - }} - navigation={{ - canGoBack: sessions.canGoBack, - canGoForward: sessions.canGoForward, - onBack: sessions.goBack, - onForward: sessions.goForward, - }} - errorMessage={errorMessage} - pinnedSessionIds={pinnedSessionIds} - collapsedSections={collapsedSections} - onSelectSession={sessions.select} - onCloseSession={sessions.close} - onToggleSessionPinned={toggleSessionPinned} - onReorderGroups={handleReorderGroups} - onReorderThreads={handleReorderThreads} - onStartDraft={sessions.startDraft} - onSubmitDraft={handleSubmitDraft} - onRegisterWorkspace={handleRegisterWorkspace} - onRenameWorkspace={handleRenameWorkspace} - onArchiveWorkspace={handleArchiveWorkspace} - onToggleGroupCollapsed={toggleGroupCollapsed} - onToggleSectionCollapsed={toggleSectionCollapsed} - onTogglePreviewExpanded={handleTogglePreviewExpanded} - mentionItems={mentionItems} - onMentionQueryChange={onMentionQueryChange} - conversationComposer={conversationComposer} - onRespondPermission={handleRespond} - onRespondQuestion={handleRespondQuestion} - onHostArtifact={handleHostArtifact} - onHostVideoFile={handleHostVideoFile} - onReadAttachmentFile={handleReadAttachmentFile} - onOpenSearch={openCommandPalette} - searchShortcut={searchShortcut} - TerminalBlockComponent={RuntimeTerminalBlock} - BranchStatusComponent={RuntimeBranchStatus} - onDismissError={onClearError} - /> + + + ) : undefined + } + threadGroups={threadGroups} + workspaces={projectWorkspaces} + workspacesLoading={workspacesLoading} + sessionsLoading={sessions.isLoading} + chatWorkspace={chatWorkspace} + activeSession={active} + draft={draft} + newSessionWorkspaceId={newSessionWorkspaceId} + onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} + accountModels={accountModels} + selectableHarnesses={selectableHarnesses} + agentCatalogs={agentCatalogs} + newSessionPreferredEfforts={newSessionPreferredEfforts} + newSessionPreferredBranches={newSessionPreferredBranches} + NewSessionBranchPickerComponent={RuntimeNewSessionBranchPicker} + runtimeCues={onboarding.cues} + onDownloadAgent={onboarding.download} + onContinueUnverified={onboarding.acknowledgeUnverified} + conversation={displayedConversation} + onEditPrompt={handleEditPrompt} + onPrepareAttachment={client.supportsAttachmentStore ? handlePrepareAttachment : undefined} + respondingRequestIds={respondingRequestIds} + responseErrors={visibleResponseErrors} + header={{ + title: active ? (active.title ?? tk(active.kind)) : 'Link Code', + subtitle: active?.cwd, + sessionId: active?.sessionId ?? null, + usage: conversation.usage, + }} + navigation={{ + canGoBack: sessions.canGoBack, + canGoForward: sessions.canGoForward, + onBack: sessions.goBack, + onForward: sessions.goForward, + }} + errorMessage={errorMessage} + pinnedSessionIds={pinnedSessionIds} + collapsedSections={collapsedSections} + onSelectSession={sessions.select} + onCloseSession={sessions.close} + onToggleSessionPinned={toggleSessionPinned} + onReorderGroups={handleReorderGroups} + onReorderThreads={handleReorderThreads} + onStartDraft={sessions.startDraft} + onSubmitDraft={handleSubmitDraft} + onRegisterWorkspace={handleRegisterWorkspace} + onRenameWorkspace={handleRenameWorkspace} + onArchiveWorkspace={handleArchiveWorkspace} + onToggleGroupCollapsed={toggleGroupCollapsed} + onToggleSectionCollapsed={toggleSectionCollapsed} + onTogglePreviewExpanded={handleTogglePreviewExpanded} + mentionItems={mentionItems} + onMentionQueryChange={onMentionQueryChange} + conversationComposer={conversationComposer} + onRespondPermission={handleRespond} + onRespondQuestion={handleRespondQuestion} + onHostArtifact={handleHostArtifact} + onHostVideoFile={handleHostVideoFile} + onReadAttachmentFile={handleReadAttachmentFile} + onOpenSearch={openCommandPalette} + searchShortcut={searchShortcut} + TerminalBlockComponent={RuntimeTerminalBlock} + BranchStatusComponent={RuntimeBranchStatus} + onDismissError={onClearError} + /> + ); } diff --git a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts index 93d2c86af..3f5db24a0 100644 --- a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts @@ -1,12 +1,5 @@ import { LinkCodeClient } from '@linkcode/client-core'; -import type { AttachmentId, SessionId } from '@linkcode/schema'; -import { - ATTACHMENT_UPLOAD_CHUNK_BYTES, - AttachmentIdSchema, - OperationIdSchema, -} from '@linkcode/schema'; -import type { Transport } from '@linkcode/transport'; -import { createWireMessage } from '@linkcode/transport'; +import { ATTACHMENT_UPLOAD_CHUNK_BYTES, AttachmentIdSchema } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; @@ -17,39 +10,6 @@ async function connectedClient(): Promise { return client; } -/** `turn.submit` has no client-core method yet (CODE-638), so the prompt-ref root is driven raw. */ -function submitPromptRef( - transport: Transport, - sessionId: SessionId, - attachmentId: AttachmentId, -): Promise { - return new Promise((resolve, reject) => { - const clientReqId = 'creq-attachment-ref'; - const unsubscribe = transport.onMessage((message) => { - const p = message.payload; - if (!('replyTo' in p) || p.replyTo !== clientReqId) return; - unsubscribe(); - if (p.kind === 'turn.submitted') resolve(); - else reject(new Error(p.kind === 'request.failed' ? p.message : `unexpected ${p.kind}`)); - }); - transport.send( - createWireMessage({ - kind: 'turn.submit', - clientReqId, - sessionId, - operationId: OperationIdSchema.parse('op-attachment-ref'), - input: { - type: 'prompt', - blocks: [ - { type: 'text', text: 'look at this' }, - { type: 'attachment_ref', attachmentId }, - ], - }, - }), - ); - }); -} - describe('dev mock attachment store', () => { it('round-trips a multi-chunk upload and dedupes the second copy', async () => { const client = await connectedClient(); @@ -105,9 +65,35 @@ describe('dev mock attachment store', () => { await expect(client.getAttachmentBytes(sessionId, draft.attachmentId)).rejects.toThrow( 'Attachment not found', ); - await submitPromptRef(transport, sessionId, draft.attachmentId); + await client.submitTurn(sessionId, { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look at this' }, + { type: 'attachment_ref', attachmentId: draft.attachmentId }, + ], + }); const read = await client.getAttachmentBytes(sessionId, draft.attachmentId); expect(read.bytes).toEqual(bytes); + + const page = await client.readConversation(sessionId); + const userRow = page.events.find( + (item) => 'event' in item && item.event.type === 'user-message', + ); + expect( + userRow && + 'event' in userRow && + userRow.event.type === 'user-message' && + userRow.event.content, + ).toEqual([ + { type: 'text', text: 'look at this' }, + { + type: 'resource_link', + uri: `attachment:${draft.attachmentId}`, + name: 'note.txt', + size: bytes.byteLength, + description: 'file', + }, + ]); client.dispose(); }); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 35bba2d47..e399fdf4b 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -223,6 +223,7 @@ export const en = { editUnavailable: 'This message cannot be rewritten yet', editUnsupported: 'This agent does not support editing historical prompts', editBusy: 'Wait for the agent to finish before editing', + editAttachmentsUnsupported: 'Prompt attachments cannot be edited yet', editPromptLabel: 'Prompt', editCancel: 'Cancel', editSend: 'Send', @@ -367,6 +368,7 @@ export const en = { removeAttachment: 'Remove attachment', attachmentTooLarge: 'Image exceeds the 8MB limit', attachmentsTotalTooLarge: 'Attachments exceed the 12MB total limit', + attachmentLimit: 'You can attach at most {count} images', attachmentUnsupportedType: 'Only JPEG / PNG / GIF / WEBP images are supported', attachmentUnsupportedAgent: "This agent doesn't support image attachments yet", attachmentReadFailed: 'Failed to read the file', @@ -719,6 +721,8 @@ export const en = { content: { audio: '[audio]', resource: '[resource]', + attachment: 'Attachment', + attachmentUnavailable: 'Attachment unavailable', }, artifact: { streaming: 'Generating…', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 0f6b69285..372033e47 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -219,6 +219,7 @@ export const zhCN = { editUnavailable: '该消息暂时无法重写', editUnsupported: '当前智能体不支持编辑历史提示词', editBusy: '请等待当前智能体完成后再编辑', + editAttachmentsUnsupported: '带附件的提示词暂不支持编辑', editPromptLabel: '提示词', editCancel: '取消', editSend: '发送', @@ -357,6 +358,7 @@ export const zhCN = { removeAttachment: '移除附件', attachmentTooLarge: '图片超过 8MB 上限', attachmentsTotalTooLarge: '附件总大小超过 12MB 上限', + attachmentLimit: '最多添加 {count} 个图片附件', attachmentUnsupportedType: '仅支持 JPEG / PNG / GIF / WEBP 图片', attachmentUnsupportedAgent: '当前 agent 暂不支持图片附件', attachmentReadFailed: '读取文件失败', @@ -704,6 +706,8 @@ export const zhCN = { content: { audio: '[音频]', resource: '[资源]', + attachment: '附件', + attachmentUnavailable: '附件不可用', }, artifact: { streaming: '生成中…', diff --git a/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx b/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx index e3b40d4b8..a5b7200b1 100644 --- a/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx @@ -4,11 +4,15 @@ import type { ContentBlock } from '@linkcode/schema'; import { cleanup, fireEvent, render } from '@testing-library/react'; import { afterEach, expect, it, vi } from 'vitest'; import { ArtifactHostActionsContext } from '../artifacts/host-actions'; +import { AttachmentPreviewProvider, resetAttachmentPreviews } from '../attachment-preview'; import { ContentBlockView } from '../content-block-view'; vi.mock('use-intl', () => ({ useTranslations: () => (key: string) => key })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + resetAttachmentPreviews(); +}); function resourceLink(uri: string): ContentBlock { return { type: 'resource_link', uri, name: 'ARCHITECTURE.md' }; @@ -54,6 +58,61 @@ it('renders web resource links with favicon candidates', () => { expect(link.querySelector('svg')).not.toBeNull(); }); +it('renders a stored image as an attachment card when no preview resolver is mounted', () => { + const { getByText, queryByRole } = render( + , + ); + expect(queryByRole('img')).toBeNull(); + expect(queryByRole('link')).toBeNull(); + expect(getByText('shot.png').closest('[data-slot="attachment-card"]')).not.toBeNull(); + expect(getByText('image/png')).toBeDefined(); +}); + +it('does not fetch bytes for a non-image stored attachment', () => { + const resolve = vi.fn(); + const { getByText, queryByRole } = render( + + + , + ); + expect(resolve).not.toHaveBeenCalled(); + expect(queryByRole('img')).toBeNull(); + expect(getByText('notes.bin').closest('[data-slot="attachment-card"]')).not.toBeNull(); +}); + +it('renders a stored image from the preview resolver', async () => { + const { findByRole } = render( + Promise.resolve({ url: 'blob:preview' })}> + + , + ); + const image = await findByRole('img', { name: 'shot.png' }); + expect(image.getAttribute('src')).toBe('blob:preview'); +}); + it('renders unknown-scheme resource links as inert chips titled by uri', () => { const { getByText, queryByRole } = render( , diff --git a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx index b579faacc..428a57f8c 100644 --- a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx @@ -53,7 +53,7 @@ describe('UserMessage', () => { ); }); - it('edits a cursor-backed prompt and preserves its non-text blocks', async () => { + it('edits a cursor-backed prompt and preserves its inline image blocks', async () => { const onEditPrompt = vi.fn(asyncNoop); const item: Extract = { id: 'user-editable', @@ -85,6 +85,55 @@ describe('UserMessage', () => { }); }); + it('edits a cursor-backed text prompt', async () => { + const onEditPrompt = vi.fn(asyncNoop); + const item: Extract = { + id: 'user-editable', + kind: 'message', + role: 'user', + turnId: 'turn-1', + blocks: [{ type: 'text', text: 'original prompt' }], + isStreaming: false, + branchCursor: 'opaque-cursor', + }; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'edit' })); + expect(screen.queryByRole('dialog')).toBeNull(); + const editor = screen.getByRole('textbox', { name: 'editPromptLabel' }); + expect(editor.closest('[data-role="user"]')).not.toBeNull(); + expect((editor as HTMLTextAreaElement).value).toBe('original prompt'); + fireEvent.change(editor, { target: { value: 'replacement prompt' } }); + fireEvent.click(screen.getByRole('button', { name: 'editSend' })); + + await waitFor(() => { + expect(onEditPrompt).toHaveBeenCalledWith('user-editable', 'opaque-cursor', [ + { type: 'text', text: 'replacement prompt' }, + ]); + }); + }); + + it('disables editing when the row carries a stored attachment', () => { + const item: Extract = { + id: 'user-attached', + kind: 'message', + role: 'user', + turnId: 'turn-1', + blocks: [ + { type: 'text', text: 'describe this' }, + { type: 'resource_link', uri: 'attachment:att-1', name: 'shot.png' }, + ], + isStreaming: false, + branchCursor: 'opaque-cursor', + }; + + render(); + expect( + screen.getByRole('button', { name: 'editAttachmentsUnsupported' }) + .disabled, + ).toBe(true); + }); + it('cancels inline editing without changing the prompt', () => { const item: Extract = { id: 'user-editable', diff --git a/packages/presentation/ui/src/chat/attachment-card.tsx b/packages/presentation/ui/src/chat/attachment-card.tsx new file mode 100644 index 000000000..f14fb3c23 --- /dev/null +++ b/packages/presentation/ui/src/chat/attachment-card.tsx @@ -0,0 +1,64 @@ +import { Card } from 'coss-ui/components/card'; +import { FileIcon, FileImageIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { cn } from '../lib/cn'; + +export function AttachmentCard({ + name, + mimeType, + size, + kind, + previewUrl, + unavailable = false, +}: { + name: string; + mimeType?: string; + size?: number; + kind?: string; + previewUrl?: string; + unavailable?: boolean; +}): React.ReactNode { + const t = useTranslations('workbench.content'); + if (kind === 'image' && previewUrl) { + return ( + {name} + ); + } + + return ( + +
+ {kind === 'image' ? ( + + ) : ( + + )} +
+
+
{name}
+
+ {unavailable + ? t('attachmentUnavailable') + : (mimeType ?? (size === undefined ? t('attachment') : formatAttachmentSize(size)))} +
+
+
+ ); +} + +function formatAttachmentSize(size: number): string { + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MiB`; + if (size >= 1024) return `${Math.round(size / 1024)} KiB`; + return `${size} B`; +} diff --git a/packages/presentation/ui/src/chat/attachment-preview.tsx b/packages/presentation/ui/src/chat/attachment-preview.tsx new file mode 100644 index 000000000..536fd4326 --- /dev/null +++ b/packages/presentation/ui/src/chat/attachment-preview.tsx @@ -0,0 +1,99 @@ +import { createContext, useContext, useSyncExternalStore } from 'react'; + +export interface AttachmentPreview { + url?: string; + mimeType?: string; +} + +export type AttachmentPreviewResolve = (attachmentId: string) => Promise; + +const AttachmentPreviewContext = createContext(null); + +const previews = new Map(); +const inflight = new Set(); +const failedUntil = new Map(); +const retryTimers = new Set>(); +let previewVersion = 0; +let previewGeneration = 0; +const previewListeners = new Set<() => void>(); +const PREVIEW_RETRY_MS = 2000; + +function subscribePreviews(onStoreChange: () => void): () => void { + previewListeners.add(onStoreChange); + return () => { + previewListeners.delete(onStoreChange); + }; +} + +function previewStoreVersion(): number { + return previewVersion; +} + +function bumpPreviews(): void { + previewVersion += 1; + for (const listener of previewListeners) listener(); +} + +function ensurePreview(attachmentId: string, resolve: AttachmentPreviewResolve): void { + if (previews.has(attachmentId) || inflight.has(attachmentId)) return; + const retryAt = failedUntil.get(attachmentId); + if (retryAt !== undefined && retryAt > Date.now()) return; + inflight.add(attachmentId); + const generation = previewGeneration; + void resolve(attachmentId) + .then((result) => { + if (generation !== previewGeneration) return; + failedUntil.delete(attachmentId); + // `null` is a durable miss (GC / 404). Transient failures throw and are not cached. + previews.set(attachmentId, result ?? {}); + }) + .catch(() => { + if (generation !== previewGeneration) return; + failedUntil.set(attachmentId, Date.now() + PREVIEW_RETRY_MS); + const timer = setTimeout(() => { + retryTimers.delete(timer); + bumpPreviews(); + }, PREVIEW_RETRY_MS); + retryTimers.add(timer); + }) + .finally(() => { + if (generation !== previewGeneration) return; + inflight.delete(attachmentId); + bumpPreviews(); + }); +} + +export function resetAttachmentPreviews(): void { + previewGeneration += 1; + for (const timer of retryTimers) clearTimeout(timer); + retryTimers.clear(); + previews.clear(); + inflight.clear(); + failedUntil.clear(); + bumpPreviews(); +} + +export function AttachmentPreviewProvider({ + resolve, + children, +}: { + resolve?: AttachmentPreviewResolve; + children: React.ReactNode; +}): React.ReactNode { + return ( + + {children} + + ); +} + +/** `undefined` while the resolver is in flight, `null` when nothing is wired. */ +export function useAttachmentPreview(attachmentId: string): AttachmentPreview | null | undefined { + const resolve = useContext(AttachmentPreviewContext); + useSyncExternalStore(subscribePreviews, previewStoreVersion); + if (resolve === null) return null; + const cached = previews.get(attachmentId); + if (cached !== undefined) return cached; + ensurePreview(attachmentId, resolve); + return undefined; +} diff --git a/packages/presentation/ui/src/chat/content-block-view.tsx b/packages/presentation/ui/src/chat/content-block-view.tsx index 477e91f50..993363345 100644 --- a/packages/presentation/ui/src/chat/content-block-view.tsx +++ b/packages/presentation/ui/src/chat/content-block-view.tsx @@ -1,7 +1,10 @@ import type { ContentBlock } from '@linkcode/schema'; +import { attachmentIdFromUri } from '@linkcode/schema'; import { split0th } from 'foxts/split-nth'; import { useTranslations } from 'use-intl'; import { fileBasename } from './artifacts/file-kind'; +import { AttachmentCard } from './attachment-card'; +import { useAttachmentPreview } from './attachment-preview'; import { codeLanguageForResource } from './code-language'; import { FilePreviewCard } from './file-preview-card'; import { HighlightedCode } from './highlighted-code'; @@ -9,6 +12,42 @@ import { LinkChip } from './link-chip'; import { linkTargetForUri } from './link-target'; import { Markdown, SmoothMarkdown } from './markdown'; +function StoredImageAttachment({ + attachmentId, + block, +}: { + attachmentId: string; + block: Extract; +}): React.ReactNode { + const preview = useAttachmentPreview(attachmentId); + return ( + + ); +} + +function StoredAttachmentView({ + attachmentId, + block, +}: { + attachmentId: string; + block: Extract; +}): React.ReactNode { + const kind = block.description; + if (kind === 'image') { + return ; + } + return ( + + ); +} + function resourceLabel(uri: string, fallback: string): string { const visible = split0th(split0th(uri, '#'), '?'); if (visible.endsWith('/')) return fallback; @@ -50,8 +89,15 @@ export function ContentBlockView({ {t('audio')} ); - case 'resource_link': - return {block.title ?? block.name}; + case 'resource_link': { + const attachmentId = attachmentIdFromUri(block.uri); + if (attachmentId === undefined) { + return ( + {block.title ?? block.name} + ); + } + return ; + } case 'resource': { const uri = block.resource.uri; const label = resourceLabel(uri, t('resource')); diff --git a/packages/presentation/ui/src/chat/index.ts b/packages/presentation/ui/src/chat/index.ts index 7a3358f25..fd7a81e98 100644 --- a/packages/presentation/ui/src/chat/index.ts +++ b/packages/presentation/ui/src/chat/index.ts @@ -3,6 +3,8 @@ export * from './activity-run'; export * from './agent-icon'; export * from './artifact'; export * from './artifacts'; +export * from './attachment-card'; +export * from './attachment-preview'; export * from './attachments'; export * from './chat-card'; export * from './checkpoint'; diff --git a/packages/presentation/ui/src/chat/user-message.tsx b/packages/presentation/ui/src/chat/user-message.tsx index 84e107b75..a2efd149c 100644 --- a/packages/presentation/ui/src/chat/user-message.tsx +++ b/packages/presentation/ui/src/chat/user-message.tsx @@ -1,4 +1,5 @@ import type { ContentBlock } from '@linkcode/schema'; +import { attachmentIdFromUri } from '@linkcode/schema'; import { Button } from 'coss-ui/components/button'; import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; import { Textarea } from 'coss-ui/components/textarea'; @@ -62,10 +63,17 @@ export function UserMessage({ const text = contentBlocksText(item.blocks); const { copied, copyValue } = useCopyButton(text, COPY_FEEDBACK_MS); const collapsible = text.split('\n').length > COLLAPSE_LINE_COUNT; + const hasPromptAttachment = item.blocks.some( + (block) => block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined, + ); const canEdit = - promptEditState === 'enabled' && item.branchCursor !== undefined && onEditPrompt !== undefined; - const editTooltip = - item.branchCursor === undefined + promptEditState === 'enabled' && + item.branchCursor !== undefined && + onEditPrompt !== undefined && + !hasPromptAttachment; + const editTooltip = hasPromptAttachment + ? t('editAttachmentsUnsupported') + : item.branchCursor === undefined ? t('editUnavailable') : promptEditState === 'busy' ? t('editBusy') @@ -93,7 +101,11 @@ export function UserMessage({ if (!canEdit || draft.trim().length === 0 || item.branchCursor === undefined) return; setPending(true); setError(null); - const retainedBlocks = item.blocks.filter((block) => block.type !== 'text'); + const retainedBlocks = item.blocks.filter( + (block) => + block.type !== 'text' && + (block.type !== 'resource_link' || attachmentIdFromUri(block.uri) === undefined), + ); try { await onEditPrompt(item.id, item.branchCursor, [ { type: 'text', text: draft }, diff --git a/packages/presentation/ui/src/shell/__tests__/attachment-capability.test.ts b/packages/presentation/ui/src/shell/__tests__/attachment-capability.test.ts new file mode 100644 index 000000000..5999a54e0 --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/attachment-capability.test.ts @@ -0,0 +1,43 @@ +import { AGENT_INPUT_CAPABILITIES } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + composerAttachmentCapability, + composerAttachmentsSupported, +} from '../attachment-capability'; + +describe('composerAttachmentCapability', () => { + it('intersects the pre-session matrix when no live capabilities have arrived', () => { + expect(composerAttachmentsSupported('codex')).toBe(true); + expect(composerAttachmentsSupported('grok-build')).toBe(false); + expect(composerAttachmentCapability('codex')?.kinds.image).toBeDefined(); + }); + + it('keeps the matrix when a live update omits attachments (old daemon)', () => { + expect( + composerAttachmentsSupported('codex', { + slashCommands: true, + shellCommand: true, + }), + ).toBe(true); + expect( + composerAttachmentsSupported('grok-build', { + slashCommands: false, + shellCommand: false, + }), + ).toBe(false); + }); + + it('intersects a live attachments declaration instead of the matrix', () => { + expect( + composerAttachmentsSupported('grok-build', { + slashCommands: false, + shellCommand: false, + attachments: AGENT_INPUT_CAPABILITIES.codex.attachments, + }), + ).toBe(true); + }); + + it('stays off when no harness is picked', () => { + expect(composerAttachmentsSupported(undefined)).toBe(false); + }); +}); diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index b9e3b9387..e32333186 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -454,7 +454,6 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { const onSubmit = vi.fn().mockReturnValue(pendingSubmission); render( { const onSubmit = vi.fn().mockReturnValue(pendingSubmission); render( Promise; + /** Uploads a dropped/pasted file into the attachment store. Absent: decode to an inline image. */ + onPrepareAttachment?: (file: File, pending: ComposerAttachment) => Promise; + /** Per-occurrence cap from the effective image capability; omitted uses no count gate. */ + maxAttachmentCount?: number; } const EMPTY_MENTION_ITEMS: MentionItem[] = []; @@ -244,6 +249,8 @@ export function Composer({ onHarnessChange, contextBar, onPickAttachmentFiles, + onPrepareAttachment, + maxAttachmentCount, }: ComposerProps): React.ReactNode { const t = useTranslations('workbench.composer'); const reducedMotion = useReducedMotion() ?? false; @@ -413,9 +420,18 @@ export function Composer({ resetDraftBookkeeping(); } if (attachmentIds.size > 0) { - setAttachments((current) => - current.filter((attachment) => !attachmentIds.has(attachment.id)), - ); + setAttachments((current) => { + const kept: ComposerAttachment[] = []; + for (let i = 0, len = current.length; i < len; i++) { + const attachment = current[i]; + if (attachmentIds.has(attachment.id)) { + releaseComposerAttachmentUrl(attachment); + continue; + } + kept.push(attachment); + } + return kept; + }); } } @@ -541,6 +557,10 @@ export function Composer({ } return; } + let charged = attachments.reduce( + (count, attachment) => (attachment.status === 'failed' ? count : count + 1), + 0, + ); let total = attachmentPayloadBytes(attachments); for (let i = 0, len = files.length; i < len; i++) { const file = files[i]; @@ -555,14 +575,26 @@ export function Composer({ toastManager.add({ title: validationError, type: 'error' }); continue; } + if (maxAttachmentCount !== undefined && charged >= maxAttachmentCount) { + toastManager.add({ + title: t('attachmentLimit', { count: maxAttachmentCount }), + type: 'error', + }); + continue; + } if (total + file.size > MAX_ATTACHMENT_TOTAL_BYTES) { toastManager.add({ title: t('attachmentsTotalTooLarge'), type: 'error' }); continue; } + charged += 1; total += file.size; const pending = pendingComposerAttachment(file); setAttachments((prev) => [...prev, pending]); - void readImageFileAsComposerAttachment(file, pending, t('attachmentReadFailed')) + const ready = + onPrepareAttachment === undefined + ? readImageFileAsComposerAttachment(file, pending, t('attachmentReadFailed')) + : onPrepareAttachment(file, pending); + void ready .then((ready) => { setAttachments((prev) => prev.map((attachment) => (attachment.id === pending.id ? ready : attachment)), @@ -583,6 +615,7 @@ export function Composer({ } function handleRemoveAttachment(attachment: ChatAttachment): void { + releaseComposerAttachmentUrl(attachment); setAttachments((prev) => prev.filter((a) => a.id !== attachment.id)); } @@ -616,6 +649,10 @@ export function Composer({ * enforces; per-file checks already ran in `attachmentFromReadFile`, so only the total recheck. */ function mergeAttachments(picked: ComposerAttachment[]): void { if (picked.length === 0) return; + let charged = attachments.reduce( + (count, attachment) => (attachment.status === 'failed' ? count : count + 1), + 0, + ); let total = attachmentPayloadBytes(attachments); const merged = picked.map((attachment) => { if (attachment.status !== 'ready') { @@ -624,6 +661,17 @@ export function Composer({ } return attachment; } + if (maxAttachmentCount !== undefined && charged >= maxAttachmentCount) { + toastManager.add({ + title: t('attachmentLimit', { count: maxAttachmentCount }), + type: 'error', + }); + return { + ...attachment, + status: 'failed' as const, + errorMessage: t('attachmentLimit', { count: maxAttachmentCount }), + }; + } if (total + (attachment.sizeBytes ?? 0) > MAX_ATTACHMENT_TOTAL_BYTES) { toastManager.add({ title: t('attachmentsTotalTooLarge'), type: 'error' }); return { @@ -632,6 +680,7 @@ export function Composer({ errorMessage: t('attachmentsTotalTooLarge'), }; } + charged += 1; total += attachment.sizeBytes ?? 0; return attachment; }); diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 22ea9aa06..b87002674 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -11,6 +11,10 @@ import { cn } from '../lib/cn'; import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; +import { + composerAttachmentCapability, + composerAttachmentsSupported, +} from './attachment-capability'; import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './composer'; import { Composer } from './composer'; import type { ComposerAttachment } from './composer-attachments'; @@ -28,6 +32,7 @@ export interface ConversationComposerController { onApprovalPolicyChange?: (policyId: string) => Promise; onModelChange?: (model: ModelOption) => Promise; onEffortChange?: (effort: EffortLevel) => Promise; + onPrepareAttachment?: (file: File, pending: ComposerAttachment) => Promise; } export interface ConversationSurfaceProps { @@ -40,8 +45,6 @@ export interface ConversationSurfaceProps { accountModels?: ModelOption[]; /** The session's account, so a reflected model id resolves against the right entry. */ accountId?: string; - /** Frontend capability stub used until attachment support is advertised by the session. */ - attachmentsSupported?: boolean; cwd?: string; /** Overrides the session's reported model (`conversation.currentModel`) in the per-turn meta. */ modelName?: string; @@ -98,7 +101,6 @@ export function ConversationSurface({ accountModels, accountId, agentLabel, - attachmentsSupported = false, cwd, modelName, respondingRequestIds, @@ -132,6 +134,8 @@ export function ConversationSurface({ const cue = agentKind === undefined ? undefined : runtimeCues?.[agentKind]; const loginCue = cue?.state === 'needs-login' ? cue : undefined; const hasPromptCard = selectPendingPromptItems(conversation).length > 0; + const attachmentCapability = composerAttachmentCapability(agentKind, conversation.capabilities); + const imageAttachmentLimits = attachmentCapability?.kinds.image; // Artifact interactions (click-to-reference) land in this surface's own composer; // the loop stays inside the presentation layer. const artifactActions = { @@ -196,7 +200,7 @@ export function ConversationSurface({ handleRef={composerRef} agentLabel={agentLabel} agentKind={agentKind} - attachmentsSupported={attachmentsSupported} + attachmentsSupported={composerAttachmentsSupported(agentKind, conversation.capabilities)} disabled={disabled} isRunning={isRunning} mentionItems={mentionItems} @@ -223,6 +227,8 @@ export function ConversationSurface({ }} onStop={composer.onStop} onPickAttachmentFiles={onPickAttachmentFiles} + onPrepareAttachment={composer.onPrepareAttachment} + maxAttachmentCount={imageAttachmentLimits?.maxCount} onModeChange={composer.onModeChange} onApprovalPolicyChange={composer.onApprovalPolicyChange} onModelChange={composer.onModelChange} diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index c6fc5e94c..763d3daca 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -2,6 +2,7 @@ export * from './agent-efforts'; export * from './agent-models'; export * from './agent-onboarding-card'; export * from './appearance-settings-panel'; +export * from './attachment-capability'; export * from './billing-settings-panel'; export * from './command-palette'; export * from './composer'; diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 15cb6d300..0efe884a1 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -39,6 +39,10 @@ import type { ModelOption } from './agent-models'; import { resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; +import { + composerAttachmentCapability, + composerAttachmentsSupported, +} from './attachment-capability'; import type { ComposerDirectiveControls, MentionItem } from './composer'; import { Composer } from './composer'; import type { ComposerAttachment } from './composer-attachments'; @@ -68,7 +72,6 @@ export interface NewSessionSubmission { input: Extract; } -export type AttachmentSupportByAgent = Readonly>>; export type AgentStartCatalogs = Readonly>>; export interface NewSessionSurfaceProps { @@ -86,8 +89,6 @@ export interface NewSessionSurfaceProps { /** Runtime availability per agent (CODE-112): a cue renders the onboarding card for the picked * harness and blocks sending until the runtime is ready; badges ride the harness submenu. */ runtimeCues?: AgentRuntimeCues; - /** Frontend capability stub used until attachment support is advertised by sessions. */ - attachmentSupport?: AttachmentSupportByAgent; agentCatalogs?: AgentStartCatalogs; /** Harnesses enabled for new threads; null while configuration is loading. */ selectableHarnesses?: AgentKind[] | null; @@ -118,6 +119,7 @@ export interface NewSessionSurfaceProps { /** Opens a native file picker and returns the picked images, ready to stage. Desktop-only — * absent on webview, where the composer's "Attach" action falls back to the Coss file input. */ onPickAttachmentFiles?: () => Promise; + onPrepareAttachment?: (file: File, pending: ComposerAttachment) => Promise; } const SELECTABLE_HARNESSES = Object.keys(AGENT_LABELS) as AgentKind[]; @@ -145,7 +147,6 @@ export function NewSessionSurface({ className, topContent, runtimeCues, - attachmentSupport, agentCatalogs, selectableHarnesses, accountModels, @@ -161,6 +162,7 @@ export function NewSessionSurface({ onPickDirectory, onRegisterWorkspace, onPickAttachmentFiles, + onPrepareAttachment, }: NewSessionSurfaceProps): React.ReactNode { const t = useTranslations('workbench.newSession'); const availableHarnesses = @@ -384,7 +386,7 @@ export function NewSessionSurface({ Promise; onRespondPermission: (requestId: string, decision: PermissionDecision) => void; onRespondQuestion: (requestId: string, outcome: QuestionOutcome) => void; /** Hosts inline artifact content on the daemon (sandboxed html previews, CODE-62). */ @@ -132,7 +131,6 @@ export function ShellFrame({ newSessionWorkspaceId, onNewSessionWorkspaceChange, runtimeCues, - attachmentSupport, agentCatalogs, selectableHarnesses, accountModels, @@ -168,6 +166,7 @@ export function ShellFrame({ onMentionQueryChange, showPlanInPromptDock, conversationComposer, + onPrepareAttachment, onRespondPermission, onRespondQuestion, onHostArtifact, @@ -224,7 +223,6 @@ export function ShellFrame({ workspaceId={newSessionWorkspaceId} onWorkspaceChange={onNewSessionWorkspaceChange} runtimeCues={runtimeCues} - attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} selectableHarnesses={selectableHarnesses} accountModels={accountModels} @@ -238,6 +236,7 @@ export function ShellFrame({ onMentionQueryChange={onMentionQueryChange} onSubmit={onSubmitDraft} onRegisterWorkspace={onRegisterWorkspace} + onPrepareAttachment={onPrepareAttachment} /> ) : ( // Keyed per session: switching resets the composer draft and scroll without touching the shell. @@ -250,7 +249,6 @@ export function ShellFrame({ agentLabel={active ? active.kind : undefined} accountModels={active ? accountModels?.[active.kind] : undefined} accountId={active?.accountId} - attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} disabled={!active || active.status === 'stopped'} isRunning={isRunning} cwd={active?.cwd} From 9609adaeee3d775a84c9e87ac392fb011d1f8b52 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sat, 5 Sep 2026 23:34:44 +0800 Subject: [PATCH 3/9] fix(workbench): overlay live echo refs and stop revoking visible previews --- .../__tests__/prompt-attachments.test.ts | 49 +++++++++++++++++++ .../src/surface/prompt-attachments.ts | 44 ++++++++++++----- .../workbench/src/surface/workbench.tsx | 5 ++ .../presentation/ui/src/shell/composer.tsx | 32 ++++++++++-- 4 files changed, 115 insertions(+), 15 deletions(-) diff --git a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts index a6c7b26fa..04ae45132 100644 --- a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts +++ b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts @@ -3,7 +3,9 @@ import type { ContentBlock, SessionId, TurnId } from '@linkcode/schema'; import { AttachmentIdSchema, userRowMessageId } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import { + clearInflightUserAttachments, isStoredAttachmentBlock, + noteInflightUserAttachments, notePendingUserAttachments, overlayPendingUserAttachments, promptBlocksFromComposer, @@ -88,6 +90,53 @@ describe('overlayPendingUserAttachments', () => { }); }); + it('fills a live echo from inflight refs without painting an older user row', () => { + const inflightSession = 'sess-inflight' as SessionId; + const olderId = userRowMessageId('turn-0' as TurnId); + const echoId = userRowMessageId('turn-2' as TurnId); + const link: ContentBlock = { + type: 'resource_link', + uri: 'attachment:att-2', + name: 'later.png', + }; + noteInflightUserAttachments(inflightSession, [link]); + const started = Date.now(); + const conversation: Conversation = { + ...EMPTY, + items: [ + { + kind: 'message', + id: olderId, + turnId: 'turn-0', + role: 'user', + blocks: [{ type: 'text', text: 'previous' }], + isStreaming: false, + receivedAt: 1, + }, + { + kind: 'message', + id: echoId, + turnId: 'turn-2', + role: 'user', + blocks: [{ type: 'text', text: 'look' }], + isStreaming: false, + receivedAt: started + 1, + }, + ], + }; + const overlaid = overlayPendingUserAttachments(conversation, inflightSession); + expect(overlaid.items[0]).toMatchObject({ + blocks: [{ type: 'text', text: 'previous' }], + }); + expect(overlaid.items[1]).toMatchObject({ + blocks: [{ type: 'text', text: 'look' }, link], + }); + clearInflightUserAttachments(inflightSession); + expect(overlayPendingUserAttachments(conversation, inflightSession).items[1]).toMatchObject({ + blocks: [{ type: 'text', text: 'look' }], + }); + }); + it('detects stored attachment links', () => { expect( isStoredAttachmentBlock({ diff --git a/packages/client/workbench/src/surface/prompt-attachments.ts b/packages/client/workbench/src/surface/prompt-attachments.ts index d02d98955..b6ba1ba06 100644 --- a/packages/client/workbench/src/surface/prompt-attachments.ts +++ b/packages/client/workbench/src/surface/prompt-attachments.ts @@ -11,7 +11,6 @@ import { AttachmentIdSchema, attachmentIdFromUri, attachmentUri } from '@linkcod import type { ComposerAttachment } from '@linkcode/ui'; const objectUrls = new Map(); -const OBJECT_URL_CAP = 8; function blobUrlFor(bytes: Uint8Array, mimeType?: string): string { const copy = new Uint8Array(bytes.byteLength); @@ -19,25 +18,16 @@ function blobUrlFor(bytes: Uint8Array, mimeType?: string): string { return URL.createObjectURL(new Blob([copy], { type: mimeType || undefined })); } -/** Timeline preview URLs, LRU-capped. Composer tray URLs are owned by the tray and revoked there. */ +/** Timeline preview URLs. Revoked on session switch. Composer tray URLs are owned by the tray. */ export function attachmentObjectUrl( attachmentId: string, bytes: Uint8Array, mimeType?: string, ): string { const existing = objectUrls.get(attachmentId); - if (existing) { - objectUrls.delete(attachmentId); - objectUrls.set(attachmentId, existing); - return existing; - } + if (existing) return existing; const url = blobUrlFor(bytes, mimeType); objectUrls.set(attachmentId, url); - for (const [oldest, stale] of objectUrls) { - if (oldest === attachmentId || objectUrls.size <= OBJECT_URL_CAP) break; - objectUrls.delete(oldest); - URL.revokeObjectURL(stale); - } return url; } @@ -136,6 +126,7 @@ export async function stageStoreAttachmentFromBase64( type PendingKey = `${string}:${string}`; const pendingByRow = new Map(); +const inflightBySession = new Map(); let pendingVersion = 0; const pendingListeners = new Set<() => void>(); @@ -159,6 +150,22 @@ export function notePendingUserAttachments( bumpPending(); } +/** Stash refs before `await submitTurn` — the echo arrives during send, `turn.submitted` after. */ +export function noteInflightUserAttachments( + sessionId: SessionId, + blocks: readonly ContentBlock[], +): void { + const refs = storedAttachmentBlocks(blocks); + if (refs.length === 0) return; + inflightBySession.set(sessionId, { blocks: refs, startedAt: Date.now() }); + bumpPending(); +} + +export function clearInflightUserAttachments(sessionId: SessionId): void { + if (!inflightBySession.delete(sessionId)) return; + bumpPending(); +} + export function subscribePendingUserAttachments(onStoreChange: () => void): () => void { pendingListeners.add(onStoreChange); return () => { @@ -190,6 +197,19 @@ export function overlayPendingUserAttachments( changed = true; next[i] = { ...item, blocks: [...item.blocks, ...extra] }; } + const inflight = inflightBySession.get(sessionId); + if (inflight !== undefined) { + for (let i = next.length - 1; i >= 0; i--) { + const item = next[i]; + if (item.kind !== 'message' || item.role !== 'user') continue; + if (item.receivedAt === undefined || item.receivedAt < inflight.startedAt) continue; + if (!item.blocks.some(isStoredAttachmentBlock)) { + next[i] = { ...item, blocks: [...item.blocks, ...inflight.blocks] }; + changed = true; + } + break; + } + } if (!changed) return conversation; return { ...conversation, items: next }; } diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 4cc3349e4..b231e7db9 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -82,7 +82,9 @@ import { submitActiveSessionInput } from './active-session-input'; import { useNewSessionDefaultsStore } from './new-session-defaults-store'; import { attachmentObjectUrl, + clearInflightUserAttachments, isStoredAttachmentBlock, + noteInflightUserAttachments, notePendingUserAttachments, overlayPendingUserAttachments, pendingUserAttachmentsVersion, @@ -362,10 +364,13 @@ function WorkbenchSessionSurface({ await submitActiveSessionInput({ type: 'prompt', content }, turnInputMutation.trigger); return; } + noteInflightUserAttachments(sessionId, content); try { const { turnId } = await client.submitTurn(sessionId, { type: 'prompt', blocks }); notePendingUserAttachments(sessionId, userRowMessageId(turnId), content); + clearInflightUserAttachments(sessionId); } catch (error) { + clearInflightUserAttachments(sessionId); if (!isRequestFailureReportedInConversation(error)) onError(error); throw error; } diff --git a/packages/presentation/ui/src/shell/composer.tsx b/packages/presentation/ui/src/shell/composer.tsx index 5b8546d19..7c38e6ff7 100644 --- a/packages/presentation/ui/src/shell/composer.tsx +++ b/packages/presentation/ui/src/shell/composer.tsx @@ -20,7 +20,7 @@ import type { EditorState, LexicalEditor } from 'lexical'; import { $getSelection, $setSelection, CLEAR_HISTORY_COMMAND } from 'lexical'; import { ShieldIcon } from 'lucide-react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; -import { useId, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import type { ChatAttachment } from '../chat/attachments'; import { Attachments } from '../chat/attachments'; @@ -267,11 +267,30 @@ export function Composer({ const commandListId = `${commandMenuId}-listbox`; const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(null); const [attachments, setAttachments] = useState([]); + const trayUrlsRef = useRef(new Set()); + const mountedRef = useRef(true); + useEffect( + () => () => { + mountedRef.current = false; + const urls = Array.from(trayUrlsRef.current); + trayUrlsRef.current.clear(); + for (let i = 0, len = urls.length; i < len; i++) URL.revokeObjectURL(urls[i]); + }, + [], + ); const submissionPendingRef = useRef(false); const [submissionPending, setSubmissionPending] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); + function rememberTrayUrl(url: string | undefined): void { + if (url?.startsWith('blob:') === true) trayUrlsRef.current.add(url); + } + function releaseTrackedUrl(attachment: { url?: string }): void { + const { url } = attachment; + if (url?.startsWith('blob:') === true) trayUrlsRef.current.delete(url); + releaseComposerAttachmentUrl(attachment); + } const hasAttachments = attachments.length > 0; const hasPendingAttachment = attachments.some((attachment) => attachment.status === 'pending'); const hasReadyAttachment = attachments.some( @@ -425,7 +444,7 @@ export function Composer({ for (let i = 0, len = current.length; i < len; i++) { const attachment = current[i]; if (attachmentIds.has(attachment.id)) { - releaseComposerAttachmentUrl(attachment); + releaseTrackedUrl(attachment); continue; } kept.push(attachment); @@ -596,11 +615,17 @@ export function Composer({ : onPrepareAttachment(file, pending); void ready .then((ready) => { + if (!mountedRef.current) { + releaseComposerAttachmentUrl(ready); + return; + } + rememberTrayUrl(ready.url); setAttachments((prev) => prev.map((attachment) => (attachment.id === pending.id ? ready : attachment)), ); }) .catch((err: unknown) => { + if (!mountedRef.current) return; const message = extractErrorMessage(err) ?? t('attachmentReadFailed'); setAttachments((prev) => prev.map((attachment) => @@ -615,7 +640,7 @@ export function Composer({ } function handleRemoveAttachment(attachment: ChatAttachment): void { - releaseComposerAttachmentUrl(attachment); + releaseTrackedUrl(attachment); setAttachments((prev) => prev.filter((a) => a.id !== attachment.id)); } @@ -684,6 +709,7 @@ export function Composer({ total += attachment.sizeBytes ?? 0; return attachment; }); + for (let i = 0, len = merged.length; i < len; i++) rememberTrayUrl(merged[i].url); setAttachments((prev) => [...prev, ...merged]); } From 91b5e4de80327d5f40ea3104b2814245641530cd Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 6 Sep 2026 20:13:16 +0800 Subject: [PATCH 4/9] fix(engine,schema): refuse commits over a swept lease and cap attachment names --- .../src/__tests__/attachment-store.test.ts | 17 +++++ apps/daemon/src/attachment-store.ts | 6 +- .../foundation/schema/src/model/attachment.ts | 14 +++- .../foundation/schema/src/model/primitives.ts | 6 +- .../foundation/schema/src/wire/attachment.ts | 6 +- .../foundation/schema/src/wire/resource.ts | 5 +- .../tests/contract/wire/attachment.test.ts | 26 +++++++ .../src/__tests__/attachment-admit.test.ts | 30 ++++++++ .../src/__tests__/attachment-upload.test.ts | 76 ++++++++++++++++++- packages/host/engine/src/attachment/admit.ts | 6 +- .../engine/src/attachment/attachment-store.ts | 16 +++- .../host/engine/src/attachment/blob-store.ts | 2 + .../engine/src/attachment/upload-service.ts | 28 +++++-- packages/host/engine/src/index.ts | 1 + 14 files changed, 215 insertions(+), 24 deletions(-) diff --git a/apps/daemon/src/__tests__/attachment-store.test.ts b/apps/daemon/src/__tests__/attachment-store.test.ts index 8ded5cccf..aad3009a9 100644 --- a/apps/daemon/src/__tests__/attachment-store.test.ts +++ b/apps/daemon/src/__tests__/attachment-store.test.ts @@ -280,4 +280,21 @@ describe('SQLite attachment store', () => { ), ).toBe(false); }); + + it('refuses a commit naming a lease the reaper already removed and writes nothing', async () => { + const { store } = await fixture(); + const draft = lease('up-late', 'abc'); + await store.beginUpload(draft); + expect(await store.sweep({ now: draft.expiresAt, graceBefore: 0 })).toEqual([]); + + await expect(async () => + store.commitAttachment({ + blob: blob('abc'), + attachment: attachment('att-late'), + uploadId: UploadIdSchema.parse('up-late'), + }), + ).rejects.toThrow('Upload lease is gone'); + expect(await store.getAttachment(AttachmentIdSchema.parse('att-late'))).toBeUndefined(); + expect(await store.getBlob(blob('abc').blobId)).toBeUndefined(); + }); }); diff --git a/apps/daemon/src/attachment-store.ts b/apps/daemon/src/attachment-store.ts index 6382d652d..c9309d524 100644 --- a/apps/daemon/src/attachment-store.ts +++ b/apps/daemon/src/attachment-store.ts @@ -4,6 +4,7 @@ import type { AttachmentSweepWindow, StoredAttachment, } from '@linkcode/engine'; +import { UploadLeaseGoneError } from '@linkcode/engine'; import type { AttachmentId, BlobId, @@ -136,10 +137,13 @@ export function createAttachmentStore(db: DaemonDatabaseClient): AttachmentStore }) .run(); if (uploadId !== undefined) { - tx.update(uploadLeases) + const claimed = tx + .update(uploadLeases) .set({ blobId: blob.blobId, attachmentId: attachment.attachmentId }) .where(eq(uploadLeases.uploadId, uploadId)) .run(); + // Throwing rolls the whole commit back: no row may outlive the pin that kept its bytes. + if (claimed.changes === 0) throw new UploadLeaseGoneError(uploadId); } }); return Promise.resolve(); diff --git a/packages/foundation/schema/src/model/attachment.ts b/packages/foundation/schema/src/model/attachment.ts index 884bf8883..2d7eab075 100644 --- a/packages/foundation/schema/src/model/attachment.ts +++ b/packages/foundation/schema/src/model/attachment.ts @@ -42,13 +42,19 @@ export type BlobRecord = z.infer; export const AttachmentKindSchema = z.string().min(1).max(32); export const MAX_ATTACHMENT_METADATA_BYTES = 4096; +/** Both ride every projected user row, so an unbounded value would outgrow a tunnel frame. */ +export const MAX_ATTACHMENT_NAME_LENGTH = 255; +export const MAX_MIME_TYPE_LENGTH = 128; + +export const AttachmentNameSchema = z.string().min(1).max(MAX_ATTACHMENT_NAME_LENGTH); +export const MimeTypeSchema = z.string().min(1).max(MAX_MIME_TYPE_LENGTH); /** Business identity of one attachment; many records may share one blob. */ export const AttachmentRecordSchema = z.object({ attachmentId: AttachmentIdSchema, kind: AttachmentKindSchema, - name: z.string().min(1), - mimeType: z.string().min(1), + name: AttachmentNameSchema, + mimeType: MimeTypeSchema, sizeBytes: z.number().int().nonnegative(), metadata: z .record(z.string(), z.unknown()) @@ -76,8 +82,8 @@ export const UploadLeaseSchema = z.object({ uploadId: UploadIdSchema, declaredSha256: Sha256HexSchema, declaredSize: z.number().int().nonnegative(), - name: z.string().min(1), - mimeType: z.string().min(1).optional(), + name: AttachmentNameSchema, + mimeType: MimeTypeSchema.optional(), kind: AttachmentKindSchema, blobId: BlobIdSchema.optional(), attachmentId: AttachmentIdSchema.optional(), diff --git a/packages/foundation/schema/src/model/primitives.ts b/packages/foundation/schema/src/model/primitives.ts index fb794f18a..bc0a7335a 100644 --- a/packages/foundation/schema/src/model/primitives.ts +++ b/packages/foundation/schema/src/model/primitives.ts @@ -44,7 +44,11 @@ export const RunIdSchema = z.string().min(1).brand<'RunId'>(); export type RunId = z.infer; /** Attachment ID: identity of an immutable prompt/session attachment. */ -export const AttachmentIdSchema = z.string().min(1).brand<'AttachmentId'>(); +/** The charset is also a materialized filename segment — anything else is a path traversal. */ +export const AttachmentIdSchema = z + .string() + .regex(/^[\w-]{1,128}$/) + .brand<'AttachmentId'>(); export type AttachmentId = z.infer; /** Operation ID: client-minted idempotency key for conversation mutations (see conversation.ts). */ diff --git a/packages/foundation/schema/src/wire/attachment.ts b/packages/foundation/schema/src/wire/attachment.ts index e2b73d85e..1d90b1907 100644 --- a/packages/foundation/schema/src/wire/attachment.ts +++ b/packages/foundation/schema/src/wire/attachment.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; import { AttachmentKindSchema, + AttachmentNameSchema, BlobIdSchema, + MimeTypeSchema, Sha256HexSchema, UploadIdSchema, } from '../model/attachment'; @@ -35,8 +37,8 @@ export const attachmentWireVariants = [ operationId: OperationIdSchema.optional(), declaredSha256: Sha256HexSchema, declaredSize: z.number().int().nonnegative().max(MAX_ATTACHMENT_BYTES), - name: z.string().min(1), - mimeType: z.string().min(1).optional(), + name: AttachmentNameSchema, + mimeType: MimeTypeSchema.optional(), /** AttachmentRecord.kind — not the frame discriminator. */ attachmentKind: AttachmentKindSchema, }), diff --git a/packages/foundation/schema/src/wire/resource.ts b/packages/foundation/schema/src/wire/resource.ts index e15f4ad50..53ab57498 100644 --- a/packages/foundation/schema/src/wire/resource.ts +++ b/packages/foundation/schema/src/wire/resource.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AttachmentNameSchema, MimeTypeSchema } from '../model/attachment'; import { MAX_ATTACHMENT_BYTES } from '../model/content'; import { SessionIdSchema } from '../model/primitives'; import { @@ -23,8 +24,8 @@ export const resourceWireVariants = [ kind: z.literal('resource.source.upload'), clientReqId: WireRequestIdSchema, sessionId: SessionIdSchema, - name: z.string().min(1), - mimeType: z.string().min(1).optional(), + name: AttachmentNameSchema, + mimeType: MimeTypeSchema.optional(), data: z.string().max(4 * Math.ceil(MAX_ATTACHMENT_BYTES / 3)), }), z.object({ diff --git a/packages/foundation/schema/tests/contract/wire/attachment.test.ts b/packages/foundation/schema/tests/contract/wire/attachment.test.ts index 660d59998..769f3ef1f 100644 --- a/packages/foundation/schema/tests/contract/wire/attachment.test.ts +++ b/packages/foundation/schema/tests/contract/wire/attachment.test.ts @@ -2,6 +2,8 @@ import { ATTACHMENT_UPLOAD_CHUNK_BASE64_MAX, ATTACHMENT_UPLOAD_CHUNK_BYTES, MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_NAME_LENGTH, + MAX_MIME_TYPE_LENGTH, WIRE_PROTOCOL_VERSION, WireMessageSchema, } from '@linkcode/schema'; @@ -187,4 +189,28 @@ describe('attachment upload/read frames', () => { }), ).toBe(true); }); + + it('caps the name and MIME type a begin may persist onto every projected row', () => { + const begin = { + kind: 'attachment.upload.begin', + clientReqId: 'request-1', + declaredSha256: sha256, + declaredSize: 1, + attachmentKind: 'file', + }; + expect(parses({ ...begin, name: 'x'.repeat(MAX_ATTACHMENT_NAME_LENGTH) })).toBe(true); + expect(parses({ ...begin, name: 'x'.repeat(MAX_ATTACHMENT_NAME_LENGTH + 1) })).toBe(false); + expect(parses({ ...begin, name: 'x', mimeType: 'a'.repeat(MAX_MIME_TYPE_LENGTH + 1) })).toBe( + false, + ); + expect( + parses({ + kind: 'resource.source.upload', + clientReqId: 'request-1', + sessionId: 'session-1', + name: 'x'.repeat(MAX_ATTACHMENT_NAME_LENGTH + 1), + data: 'YQ==', + }), + ).toBe(false); + }); }); diff --git a/packages/host/engine/src/__tests__/attachment-admit.test.ts b/packages/host/engine/src/__tests__/attachment-admit.test.ts index 4d452160f..1dd9e519c 100644 --- a/packages/host/engine/src/__tests__/attachment-admit.test.ts +++ b/packages/host/engine/src/__tests__/attachment-admit.test.ts @@ -188,3 +188,33 @@ describe('assertInlineAttachmentsSupported', () => { expect.fail('expected a typed refusal'); }); }); + +describe('admitPromptAttachments with a file kind declared', () => { + const capability = { + kinds: { + file: { mimeTypes: ['application/pdf'], maxBytes: MAX_ATTACHMENT_BYTES, maxCount: 1 }, + }, + representations: ['readonly_file'], + }; + + it('bounds an unknown kind by the file maxCount', () => { + const document = stored({ kind: 'document', mimeType: 'application/pdf', sizeBytes: 1 }); + expect(() => + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [document], + capability, + ), + ).not.toThrow(); + expect(() => + admitPromptAttachments( + [ + { type: 'attachment_ref', attachmentId: ATT_1 }, + { type: 'attachment_ref', attachmentId: ATT_1 }, + ], + [document], + capability, + ), + ).toThrow(RequestError); + }); +}); diff --git a/packages/host/engine/src/__tests__/attachment-upload.test.ts b/packages/host/engine/src/__tests__/attachment-upload.test.ts index 3a892e748..d53e00531 100644 --- a/packages/host/engine/src/__tests__/attachment-upload.test.ts +++ b/packages/host/engine/src/__tests__/attachment-upload.test.ts @@ -13,7 +13,8 @@ import { Effect } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; import { InMemoryAttachmentStore } from '../attachment/attachment-store'; import { FsBlobStore } from '../attachment/blob-store'; -import { UPLOAD_LEASE_TTL_MS } from '../attachment/gc'; +import { AttachmentGc, UPLOAD_LEASE_TTL_MS } from '../attachment/gc'; +import { AttachmentIoMutex } from '../attachment/io-mutex'; import { AttachmentUploadService } from '../attachment/upload-service'; const temporaryDirectories: string[] = []; @@ -373,6 +374,79 @@ describe('AttachmentUploadService', () => { code: 'invalid_request', }); }); + + it('refuses a dedupe commit whose lease expired and whose blob the reaper unlinked', async () => { + let now = 1000; + const root = await mkdtemp(join(tmpdir(), 'linkcode-upload-dangling-')); + temporaryDirectories.push(root); + const blobs = new FsBlobStore(join(root, 'blobs')); + const attachments = new InMemoryAttachmentStore(); + const io = new AttachmentIoMutex(); + const uploads = new AttachmentUploadService(blobs, attachments, io, () => now); + const gc = new AttachmentGc(attachments, blobs, () => now, io); + const bytes = Buffer.from('plain text payload'); + const input = { + declaredSha256: sha256(bytes), + declaredSize: bytes.byteLength, + name: 'a.txt', + mimeType: 'text/plain', + attachmentKind: 'file', + }; + + const first = await run(uploads.begin(input)); + await run(uploads.chunk(first.uploadId, 0, bytes.toString('base64'))); + const committed = await run(uploads.commit(first.uploadId)); + const second = await run(uploads.begin(input)); + expect(second.state).toBe('exists'); + + now += UPLOAD_LEASE_TTL_MS + 1; + expect((await gc.sweep()).removedBlobs).toEqual([committed.blobId]); + expect(await blobs.stat(committed.blobId)).toBeUndefined(); + + await expect(run(uploads.commit(second.uploadId))).rejects.toMatchObject({ + _tag: 'RequestError', + code: 'conflict', + }); + expect(await attachments.getBlob(committed.blobId)).toBeUndefined(); + }); + + it('refuses a commit whose lease the store already dropped, writing no row', async () => { + const { attachments, uploads } = await makeService(); + const bytes = Buffer.from('lease dropped underneath'); + const begun = await run( + uploads.begin({ + declaredSha256: sha256(bytes), + declaredSize: bytes.byteLength, + name: 'late.bin', + attachmentKind: 'file', + }), + ); + await run(uploads.chunk(begun.uploadId, 0, bytes.toString('base64'))); + await attachments.deleteLease(begun.uploadId); + await expect(run(uploads.commit(begun.uploadId))).rejects.toMatchObject({ + _tag: 'RequestError', + code: 'conflict', + }); + expect(await attachments.getBlob(blobIdFromSha256(sha256(bytes)))).toBeUndefined(); + }); + + it('keeps only the sniff head of the first chunk, not the whole decoded buffer', async () => { + const { uploads } = await makeService(); + const bytes = Buffer.alloc(ATTACHMENT_UPLOAD_CHUNK_BYTES + 5, 1); + const begun = await run( + uploads.begin({ + declaredSha256: sha256(bytes), + declaredSize: bytes.byteLength, + name: 'big.bin', + attachmentKind: 'file', + }), + ); + await run(uploads.chunk(begun.uploadId, 0, chunksOf(bytes)[0].data)); + const live = (uploads as unknown as { live: Map }).live.get( + begun.uploadId, + ); + expect(live?.head.buffer.byteLength).toBeLessThanOrEqual(16); + }); }); describe('in-memory attachment reachability', () => { diff --git a/packages/host/engine/src/attachment/admit.ts b/packages/host/engine/src/attachment/admit.ts index 0f715d09b..572cd41b3 100644 --- a/packages/host/engine/src/attachment/admit.ts +++ b/packages/host/engine/src/attachment/admit.ts @@ -107,11 +107,7 @@ export function admitPromptAttachments( } if (attachment.kind === 'image') imageCount += 1; else fileCount += 1; - const maxCount = limits.maxCount; - if ( - (attachment.kind === 'image' && imageCount > maxCount) || - (attachment.kind === 'file' && fileCount > maxCount) - ) { + if ((attachment.kind === 'image' ? imageCount : fileCount) > limits.maxCount) { throw new RequestError({ code: 'limit_exceeded', message: 'Too many attachments', diff --git a/packages/host/engine/src/attachment/attachment-store.ts b/packages/host/engine/src/attachment/attachment-store.ts index cf0822a1d..1848df3c9 100644 --- a/packages/host/engine/src/attachment/attachment-store.ts +++ b/packages/host/engine/src/attachment/attachment-store.ts @@ -23,6 +23,14 @@ export interface AttachmentCommit { readonly uploadId?: UploadId; } +/** `commitAttachment` named a lease that no longer exists: its pin is gone, so the bytes may be too. */ +export class UploadLeaseGoneError extends Error { + constructor(uploadId: UploadId, options?: ErrorOptions) { + super(`Upload lease is gone: ${uploadId}`, options); + this.name = 'UploadLeaseGoneError'; + } +} + export interface AttachmentSweepWindow { readonly now: Timestamp; /** Rows created at or after this instant are never collected: their root may be one @@ -46,7 +54,8 @@ export interface AttachmentStore { beginUpload(lease: UploadLease): Promise; deleteLease(uploadId: UploadId): Promise; /** Atomic: the blob row (if new), the attachment with its `original` variant, and the lease - * pointed at the attachment. */ + * pointed at the attachment. Rejects with `UploadLeaseGoneError` when `uploadId` names no live + * lease — nothing is written, since the reaper may already have unlinked the pinned bytes. */ commitAttachment(commit: AttachmentCommit): Promise; /** Whether a prompt of a turn in `sessionId`, or a session resource of that session, names the * attachment. Integrity, not confidentiality — every peer of this store is one account. */ @@ -115,12 +124,15 @@ export class InMemoryAttachmentStore implements AttachmentStore { } commitAttachment({ attachment, blob, uploadId }: AttachmentCommit): Promise { + const lease = uploadId === undefined ? undefined : this.leases.get(uploadId); + if (uploadId !== undefined && lease === undefined) { + return Promise.reject(new UploadLeaseGoneError(uploadId)); + } if (!this.blobs.has(blob.blobId)) this.blobs.set(blob.blobId, structuredClone(blob)); this.attachments.set(attachment.attachmentId, { ...structuredClone(attachment), blobId: blob.blobId, }); - const lease = uploadId === undefined ? undefined : this.leases.get(uploadId); if (lease) { this.leases.set(lease.uploadId, { ...lease, diff --git a/packages/host/engine/src/attachment/blob-store.ts b/packages/host/engine/src/attachment/blob-store.ts index a11e41116..7f33d35f4 100644 --- a/packages/host/engine/src/attachment/blob-store.ts +++ b/packages/host/engine/src/attachment/blob-store.ts @@ -63,6 +63,8 @@ class FsBlobStage implements BlobStage { } async commit(expected: { sha256: string; sizeBytes: number }): Promise { + // Flush before publishing: the row commits with fsync, so the bytes must not lag it. + await this.handle?.sync(); await this.close(); const sizeBytes = (await stat(this.path)).size; const sha256 = await sha256OfFile(this.path); diff --git a/packages/host/engine/src/attachment/upload-service.ts b/packages/host/engine/src/attachment/upload-service.ts index 53ea5b1ed..3453a7cf5 100644 --- a/packages/host/engine/src/attachment/upload-service.ts +++ b/packages/host/engine/src/attachment/upload-service.ts @@ -12,6 +12,7 @@ import { Effect } from 'effect'; import { noop } from 'foxts/noop'; import { OperationError, RequestError } from '../failure'; import type { AttachmentStore } from './attachment-store'; +import { UploadLeaseGoneError } from './attachment-store'; import type { BlobStage, BlobStore } from './blob-store'; import { BlobIntegrityError } from './blob-store'; import { UPLOAD_LEASE_TTL_MS } from './gc'; @@ -177,7 +178,10 @@ export class AttachmentUploadService { }); } await stage.write(offset, bytes); - if (offset === 0) live.head = bytes.subarray(0, Math.min(HEAD_BYTES, bytes.byteLength)); + // Copy: a view would pin the whole decoded chunk in heap for the life of the upload. + if (offset === 0) { + live.head = new Uint8Array(bytes.subarray(0, Math.min(HEAD_BYTES, bytes.byteLength))); + } live.receivedBytes += bytes.byteLength; return { uploadId, receivedBytes: live.receivedBytes }; }), @@ -207,13 +211,19 @@ export class AttachmentUploadService { pinnedBlobId === undefined ? undefined : yield* files('stat', () => this.blobs.stat(pinnedBlobId)); - // A dedupe hit only counts when the stored bytes are the size the client declared; otherwise - // the declared size is a lie and the exists path would skip every coverage check. - const exists = live?.state === 'exists' || existsFile?.sizeBytes === lease.declaredSize; + // A dedupe hit only counts when the pinned bytes are on disk now at the declared size: a + // wrong size would skip every coverage check, and `begin`'s answer is stale once the lease + // outlived its blob (expired, then swept). + const exists = existsFile?.sizeBytes === lease.declaredSize; + if (!exists && live?.state === 'exists') { + return yield* invalid('conflict', 'Blob bytes are missing; retry from begin'); + } if (exists) { const blobId = pinnedBlobId ?? blobIdFromSha256(lease.declaredSha256); - const head = - (yield* files('read', () => this.blobs.read(blobId, 0, HEAD_BYTES))) ?? new Uint8Array(0); + const head = yield* files('read', () => this.blobs.read(blobId, 0, HEAD_BYTES)); + if (head === undefined) { + return yield* invalid('conflict', 'Blob bytes are missing; retry from begin'); + } yield* assertMime(lease.mimeType, head); const committed = yield* publishExists(lease, blobId); yield* files('discard', () => discard(uploadId)); @@ -452,6 +462,12 @@ function mapCause( if (cause instanceof BlobIntegrityError) { return new RequestError({ code: 'invalid_request', message: cause.message }); } + if (cause instanceof UploadLeaseGoneError) { + return new RequestError({ + code: 'conflict', + message: 'Upload lease expired; retry from begin', + }); + } return new OperationError({ subsystem, operation: `attachments.${operation}`, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index ed92743e7..16b704ab6 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -13,6 +13,7 @@ export { type AttachmentSweepWindow, InMemoryAttachmentStore, type StoredAttachment, + UploadLeaseGoneError, } from './attachment/attachment-store'; export { type BlobStage, type BlobStore, FsBlobStore } from './attachment/blob-store'; export type { LoopStore, ScheduleStore } from './automation'; From 18c057ac1bfbf3a436df15af27305ddd6cd575d8 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 6 Sep 2026 20:15:46 +0800 Subject: [PATCH 5/9] fix(client-core): fail attachment calls typed against an old peer and cap the put size --- packages/client/core/src/client.ts | 40 +++++++++++++--- .../core/src/client/attachment-channel.ts | 16 +++++-- .../integration/attachment-client.test.ts | 47 +++++++++++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index b3da6ed6b..590a835c4 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -110,6 +110,10 @@ import type { SequencedAgentEvent } from './client/event-buffer'; import { EventBuffer } from './client/event-buffer'; import { LoopLogBuffer } from './client/loop-log-buffer'; import type { + AttachmentChunkAck, + AttachmentCommitResult, + AttachmentReadResult, + AttachmentUploadBegun, ConversationGraphSnapshot, ConversationReadPage, PluginList, @@ -1391,28 +1395,51 @@ export class LinkCodeClient { return this.control.hostResource(resourceId); } - beginAttachmentUpload(input: AttachmentBeginInput) { + /** A peer below the store's wire version drops `attachment.*` frames unanswered — the request + * would hang forever — so every attachment method fails typed instead. */ + private attachmentStoreUnsupported(): Promise { + return Promise.reject( + new Error(`Peer wire ${this.peerWire?.version ?? 'unknown'} has no attachment store`), + ); + } + + beginAttachmentUpload(input: AttachmentBeginInput): Promise { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.beginUpload(input); } - sendAttachmentChunk(uploadId: UploadId, offset: number, data: string) { + sendAttachmentChunk( + uploadId: UploadId, + offset: number, + data: string, + ): Promise { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.sendChunk(uploadId, offset, data); } - commitAttachmentUpload(uploadId: UploadId) { + commitAttachmentUpload(uploadId: UploadId): Promise { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.commit(uploadId); } - abortAttachmentUpload(uploadId: UploadId) { + abortAttachmentUpload(uploadId: UploadId): Promise<{ ok: true }> { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.abort(uploadId); } - readAttachment(sessionId: SessionId, attachmentId: AttachmentId, offset: number, length: number) { + readAttachment( + sessionId: SessionId, + attachmentId: AttachmentId, + offset: number, + length: number, + ): Promise { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.read(sessionId, attachmentId, offset, length); } /** Hash + windowed chunked upload. Identical bytes commit with no transfer. */ - putAttachment(input: AttachmentPutInput) { + putAttachment(input: AttachmentPutInput): Promise { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.put(input); } @@ -1421,6 +1448,7 @@ export class LinkCodeClient { sessionId: SessionId, attachmentId: AttachmentId, ): Promise { + if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported(); return this.attachments.get(sessionId, attachmentId); } subscribeResources(cb: ResourceEventCb): Unsubscribe { diff --git a/packages/client/core/src/client/attachment-channel.ts b/packages/client/core/src/client/attachment-channel.ts index f0d2c2f2a..d8eadba40 100644 --- a/packages/client/core/src/client/attachment-channel.ts +++ b/packages/client/core/src/client/attachment-channel.ts @@ -1,5 +1,9 @@ import type { AttachmentId, OperationId, SessionId, UploadId } from '@linkcode/schema'; -import { ATTACHMENT_UPLOAD_CHUNK_BYTES, ATTACHMENT_UPLOAD_WINDOW_CHUNKS } from '@linkcode/schema'; +import { + ATTACHMENT_UPLOAD_CHUNK_BYTES, + ATTACHMENT_UPLOAD_WINDOW_CHUNKS, + MAX_ATTACHMENT_BYTES, +} from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { noop } from 'foxts/noop'; import type { Sha256Hex } from './blob-cache'; @@ -31,8 +35,8 @@ export interface AttachmentPutInput { } /** - * Chunked attachment upload/read. Credit-windowed puts retry from zero; completed re-sends are - * free via the daemon's SHA-256 short-circuit. The cache is keyed by `blobId`. + * Chunked attachment upload/read. A failed put aborts its lease and the caller retries from zero; + * a completed re-send is free via the daemon's SHA-256 short-circuit. The cache is keyed by `blobId`. */ export class AttachmentChannel { readonly cache = new AttachmentBlobCache(); @@ -100,6 +104,10 @@ export class AttachmentChannel { /** Hash, begin, windowed chunks, commit. Identical bytes short-circuit to `exists`. */ async put(input: AttachmentPutInput): Promise { + // The wire schema caps `declaredSize`; an oversize begin is dropped unanswered, never refused. + if (input.bytes.byteLength > MAX_ATTACHMENT_BYTES) { + throw new Error(`Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes`); + } const declaredSha256 = await this.digest(input.bytes); const begun = await this.beginUpload({ declaredSha256, @@ -120,7 +128,7 @@ export class AttachmentChannel { await this.abort(begun.uploadId).catch(noop); throw error; } - this.cache.set(committed.blobId, input.bytes); + this.cache.set(committed.blobId, input.bytes.slice()); return committed; } diff --git a/packages/client/core/tests/integration/attachment-client.test.ts b/packages/client/core/tests/integration/attachment-client.test.ts index dee65cb4b..621187697 100644 --- a/packages/client/core/tests/integration/attachment-client.test.ts +++ b/packages/client/core/tests/integration/attachment-client.test.ts @@ -3,6 +3,7 @@ import { ATTACHMENT_UPLOAD_CHUNK_BYTES, AttachmentIdSchema, BlobIdSchema, + MAX_ATTACHMENT_BYTES, SessionIdSchema, UploadIdSchema, } from '@linkcode/schema'; @@ -322,3 +323,49 @@ describe('AttachmentBlobCache', () => { expect(cache.has('a')).toBe(false); }); }); + +describe('LinkCodeClient attachment guards', () => { + it('rejects an oversize put before any frame leaves the client', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const seen: string[] = []; + serverTransport.onMessage((message) => { + seen.push(message.payload.kind); + }); + await expect( + client.putAttachment({ + bytes: new Uint8Array(MAX_ATTACHMENT_BYTES + 1), + name: 'huge.bin', + attachmentKind: 'file', + }), + ).rejects.toThrow('exceeds'); + expect(seen).not.toContain('attachment.upload.begin'); + client.dispose(); + serverTransport.close(); + }); + + it('fails typed against a peer without the attachment store instead of hanging', async () => { + const [clientTransport, serverTransport] = createLocalTransportPair(); + await serverTransport.connect(); + serverTransport.onMessage((message) => { + if (message.payload.kind === 'ping') { + serverTransport.send( + createWireMessage({ + kind: 'pong', + version: ATTACHMENT_STORE_WIRE_VERSION - 1, + minCompatible: ATTACHMENT_STORE_WIRE_VERSION - 4, + }), + ); + } + }); + const older = new LinkCodeClient(clientTransport); + await older.connect(); + await expect( + older.getAttachmentBytes( + SessionIdSchema.parse('session-1'), + AttachmentIdSchema.parse('att-1'), + ), + ).rejects.toThrow('has no attachment store'); + older.dispose(); + serverTransport.close(); + }); +}); From f905046c459babaef7af5ff938a3b01ae7aa282b Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 6 Sep 2026 20:17:30 +0800 Subject: [PATCH 6/9] fix(ui,workbench): resolve previews under the React Compiler and admit refs in the mock --- .../workbench/src/mock/dev-mock-host.ts | 28 ++++- .../__tests__/prompt-attachments.test.ts | 36 ++++++- .../src/surface/prompt-attachments.ts | 99 ++++++++++------- .../workbench/src/surface/workbench.tsx | 15 ++- .../integration/dev-mock-attachments.test.ts | 44 +++++++- .../__tests__/attachment-preview.test.tsx | 66 ++++++++++++ .../__tests__/content-block-view.test.tsx | 18 ++++ .../ui/src/chat/attachment-preview.tsx | 100 +++++++++++------- .../ui/src/chat/content-block-view.tsx | 2 +- vitest.config.ts | 7 ++ 10 files changed, 325 insertions(+), 90 deletions(-) create mode 100644 packages/presentation/ui/src/chat/__tests__/attachment-preview.test.tsx diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index b53a23fa3..3c655a69f 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -50,6 +50,7 @@ import { attachmentUri, blobIdFromSha256, declaredMimeTypeMatches, + effectiveAttachmentCapability, managedAgentAssetId, managedAssetIdEquals, managedAssetKey, @@ -1423,6 +1424,23 @@ export class DevMockHost { } if (p.input.type === 'prompt') { const blocks = p.input.blocks; + // Admission mirrors the daemon's typed refusals so a composer bug cannot hide behind the mock. + const capability = effectiveAttachmentCapability(session.kind); + for (let i = 0, len = blocks.length; i < len; i++) { + const block = blocks[i]; + if (block.type !== 'attachment_ref') continue; + const record = this.attachmentRecords.get(block.attachmentId); + if (record === undefined) { + this.sendFailure(p.clientReqId, 'Unknown attachment', { code: 'unsupported_attachment' }); + return; + } + if (capability?.kinds[record.kind === 'image' ? 'image' : 'file'] === undefined) { + this.sendFailure(p.clientReqId, 'This agent does not accept attachments of this kind', { + code: 'unsupported_attachment', + }); + return; + } + } for (let i = 0, len = blocks.length; i < len; i++) { const block = blocks[i]; if (block.type === 'attachment_ref') this.rootAttachment(p.sessionId, block.attachmentId); @@ -2000,7 +2018,15 @@ export class DevMockHost { ); return; } - const chunk = mockBase64ToBytes(payload.data); + let chunk: Uint8Array; + try { + chunk = mockBase64ToBytes(payload.data); + } catch { + this.sendFailure(payload.clientReqId, 'Chunk data is not valid base64', { + code: 'invalid_request', + }); + return; + } if (upload.received + chunk.byteLength > upload.declaredSize) { this.sendFailure(payload.clientReqId, 'Chunk exceeds the declared size', { code: 'invalid_request', diff --git a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts index 04ae45132..939b42686 100644 --- a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts +++ b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts @@ -8,6 +8,7 @@ import { noteInflightUserAttachments, notePendingUserAttachments, overlayPendingUserAttachments, + pendingUserAttachmentsSnapshot, promptBlocksFromComposer, } from '../prompt-attachments'; @@ -38,13 +39,27 @@ describe('promptBlocksFromComposer', () => { promptBlocksFromComposer([ { type: 'text', text: 'look' }, { type: 'resource_link', uri: `attachment:${attachmentId}`, name: 'shot.png' }, - { type: 'image', data: 'cG5n', mimeType: 'image/png' }, ]), ).toEqual([ { type: 'text', text: 'look' }, { type: 'attachment_ref', attachmentId }, ]); }); + + it('refuses content that turn.submit cannot carry instead of dropping the block', () => { + expect( + promptBlocksFromComposer([ + { type: 'text', text: 'look' }, + { type: 'image', data: 'cG5n', mimeType: 'image/png' }, + ]), + ).toBeUndefined(); + expect( + promptBlocksFromComposer([ + { type: 'text', text: 'look' }, + { type: 'resource_link', uri: 'file:///tmp/a.ts', name: 'a.ts' }, + ]), + ).toBeUndefined(); + }); }); describe('overlayPendingUserAttachments', () => { @@ -68,7 +83,9 @@ describe('overlayPendingUserAttachments', () => { }, ], }; - expect(overlayPendingUserAttachments(echo, sessionId).items[0]).toMatchObject({ + expect( + overlayPendingUserAttachments(echo, sessionId, pendingUserAttachmentsSnapshot()).items[0], + ).toMatchObject({ blocks: [{ type: 'text', text: 'look' }, link], }); @@ -85,7 +102,9 @@ describe('overlayPendingUserAttachments', () => { }, ], }; - expect(overlayPendingUserAttachments(durable, sessionId).items[0]).toMatchObject({ + expect( + overlayPendingUserAttachments(durable, sessionId, pendingUserAttachmentsSnapshot()).items[0], + ).toMatchObject({ blocks: [{ type: 'text', text: 'look' }, link], }); }); @@ -124,7 +143,11 @@ describe('overlayPendingUserAttachments', () => { }, ], }; - const overlaid = overlayPendingUserAttachments(conversation, inflightSession); + const overlaid = overlayPendingUserAttachments( + conversation, + inflightSession, + pendingUserAttachmentsSnapshot(), + ); expect(overlaid.items[0]).toMatchObject({ blocks: [{ type: 'text', text: 'previous' }], }); @@ -132,7 +155,10 @@ describe('overlayPendingUserAttachments', () => { blocks: [{ type: 'text', text: 'look' }, link], }); clearInflightUserAttachments(inflightSession); - expect(overlayPendingUserAttachments(conversation, inflightSession).items[1]).toMatchObject({ + expect( + overlayPendingUserAttachments(conversation, inflightSession, pendingUserAttachmentsSnapshot()) + .items[1], + ).toMatchObject({ blocks: [{ type: 'text', text: 'look' }], }); }); diff --git a/packages/client/workbench/src/surface/prompt-attachments.ts b/packages/client/workbench/src/surface/prompt-attachments.ts index b6ba1ba06..a3dfe7f69 100644 --- a/packages/client/workbench/src/surface/prompt-attachments.ts +++ b/packages/client/workbench/src/surface/prompt-attachments.ts @@ -40,7 +40,11 @@ export function isStoredAttachmentBlock(block: ContentBlock): boolean { return block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined; } -export function promptBlocksFromComposer(content: readonly ContentBlock[]): PromptBlock[] { +/** `undefined` when a block has no `turn.submit` form (inline image, resource): the caller must + * send that content on the legacy path rather than silently drop the block. */ +export function promptBlocksFromComposer( + content: readonly ContentBlock[], +): PromptBlock[] | undefined { const blocks: PromptBlock[] = []; for (let i = 0, len = content.length; i < len; i++) { const block = content[i]; @@ -48,9 +52,8 @@ export function promptBlocksFromComposer(content: readonly ContentBlock[]): Prom blocks.push({ type: 'text', text: block.text }); continue; } - if (block.type !== 'resource_link') continue; - const id = attachmentIdFromUri(block.uri); - if (id === undefined) continue; + const id = block.type === 'resource_link' ? attachmentIdFromUri(block.uri) : undefined; + if (id === undefined) return; blocks.push({ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse(id) }); } return blocks; @@ -123,22 +126,39 @@ export async function stageStoreAttachmentFromBase64( }; } -type PendingKey = `${string}:${string}`; +interface PendingUserRow { + readonly messageId: string; + readonly blocks: readonly ContentBlock[]; +} -const pendingByRow = new Map(); -const inflightBySession = new Map(); -let pendingVersion = 0; -const pendingListeners = new Set<() => void>(); +interface InflightUserAttachments { + readonly blocks: readonly ContentBlock[]; + readonly startedAt: number; +} -function pendingKey(sessionId: SessionId, messageId: string): PendingKey { - return `${sessionId}:${messageId}`; +/** Replaced wholesale on every change: the render reads this snapshot, never the module maps. */ +export interface PendingUserAttachments { + readonly pending: ReadonlyMap; + readonly inflight: ReadonlyMap; } -function bumpPending(): void { - pendingVersion += 1; +let snapshot: PendingUserAttachments = { pending: new Map(), inflight: new Map() }; +const pendingListeners = new Set<() => void>(); + +function updatePending( + mutate: ( + pending: Map, + inflight: Map, + ) => void, +): void { + const pending = new Map(snapshot.pending); + const inflight = new Map(snapshot.inflight); + mutate(pending, inflight); + snapshot = { pending, inflight }; for (const listener of pendingListeners) listener(); } +/** Only a session's newest prompt can still be echoing text-only, so one entry per session. */ export function notePendingUserAttachments( sessionId: SessionId, messageId: MessageId, @@ -146,8 +166,9 @@ export function notePendingUserAttachments( ): void { const refs = storedAttachmentBlocks(blocks); if (refs.length === 0) return; - pendingByRow.set(pendingKey(sessionId, messageId), refs); - bumpPending(); + updatePending((pending) => { + pending.set(sessionId, { messageId, blocks: refs }); + }); } /** Stash refs before `await submitTurn` — the echo arrives during send, `turn.submitted` after. */ @@ -157,13 +178,16 @@ export function noteInflightUserAttachments( ): void { const refs = storedAttachmentBlocks(blocks); if (refs.length === 0) return; - inflightBySession.set(sessionId, { blocks: refs, startedAt: Date.now() }); - bumpPending(); + updatePending((_pending, inflight) => { + inflight.set(sessionId, { blocks: refs, startedAt: Date.now() }); + }); } export function clearInflightUserAttachments(sessionId: SessionId): void { - if (!inflightBySession.delete(sessionId)) return; - bumpPending(); + if (!snapshot.inflight.has(sessionId)) return; + updatePending((_pending, inflight) => { + inflight.delete(sessionId); + }); } export function subscribePendingUserAttachments(onStoreChange: () => void): () => void { @@ -173,38 +197,39 @@ export function subscribePendingUserAttachments(onStoreChange: () => void): () = }; } -export function pendingUserAttachmentsVersion(): number { - return pendingVersion; +export function pendingUserAttachmentsSnapshot(): PendingUserAttachments { + return snapshot; } export function overlayPendingUserAttachments( conversation: Conversation, sessionId: SessionId | null, + { inflight, pending }: PendingUserAttachments, ): Conversation { if (!sessionId) return conversation; - const items = conversation.items; + const row = pending.get(sessionId); + const live = inflight.get(sessionId); + if (row === undefined && live === undefined) return conversation; let changed = false; - const next = items.slice(); - for (let i = 0, len = items.length; i < len; i++) { - const item = items[i]; - if (item.kind !== 'message' || item.role !== 'user') continue; - const extra = pendingByRow.get(pendingKey(sessionId, item.id)); - if (extra === undefined) continue; - if (item.blocks.some(isStoredAttachmentBlock)) { - pendingByRow.delete(pendingKey(sessionId, item.id)); - continue; + const next = conversation.items.slice(); + if (row !== undefined) { + for (let i = next.length - 1; i >= 0; i--) { + const item = next[i]; + if (item.kind !== 'message' || item.role !== 'user' || item.id !== row.messageId) continue; + if (!item.blocks.some(isStoredAttachmentBlock)) { + next[i] = { ...item, blocks: [...item.blocks, ...row.blocks] }; + changed = true; + } + break; } - changed = true; - next[i] = { ...item, blocks: [...item.blocks, ...extra] }; } - const inflight = inflightBySession.get(sessionId); - if (inflight !== undefined) { + if (live !== undefined) { for (let i = next.length - 1; i >= 0; i--) { const item = next[i]; if (item.kind !== 'message' || item.role !== 'user') continue; - if (item.receivedAt === undefined || item.receivedAt < inflight.startedAt) continue; + if (item.receivedAt === undefined || item.receivedAt < live.startedAt) continue; if (!item.blocks.some(isStoredAttachmentBlock)) { - next[i] = { ...item, blocks: [...item.blocks, ...inflight.blocks] }; + next[i] = { ...item, blocks: [...item.blocks, ...live.blocks] }; changed = true; } break; diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index b231e7db9..388748a34 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -87,7 +87,7 @@ import { noteInflightUserAttachments, notePendingUserAttachments, overlayPendingUserAttachments, - pendingUserAttachmentsVersion, + pendingUserAttachmentsSnapshot, promptBlocksFromComposer, revokeAttachmentObjectUrls, stageStoreAttachment, @@ -266,8 +266,15 @@ function WorkbenchSessionSurface({ const sdkClient = useWorkbenchSdkClient(); const client = sdkClient.raw; const activeSessionId = sessions.activeId; - useSyncExternalStore(subscribePendingUserAttachments, pendingUserAttachmentsVersion); - const displayedConversation = overlayPendingUserAttachments(conversation, activeSessionId); + const pendingAttachments = useSyncExternalStore( + subscribePendingUserAttachments, + pendingUserAttachmentsSnapshot, + ); + const displayedConversation = overlayPendingUserAttachments( + conversation, + activeSessionId, + pendingAttachments, + ); // Announce observation of the focused session so the daemon replays buffered per-session state // this client missed (e.g. the approval-policy advertisement after a reload). Fire-and-forget. useEffect(() => { @@ -360,7 +367,7 @@ function WorkbenchSessionSurface({ return; } const blocks = promptBlocksFromComposer(content); - if (blocks.length === 0) { + if (blocks === undefined || blocks.length === 0) { await submitActiveSessionInput({ type: 'prompt', content }, turnInputMutation.trigger); return; } diff --git a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts index 3f5db24a0..17f52b2c0 100644 --- a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts @@ -4,6 +4,9 @@ import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; +const PNG_1X1_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + async function connectedClient(): Promise { const client = new LinkCodeClient(createDevMockTransport()); await client.connect(); @@ -59,8 +62,14 @@ describe('dev mock attachment store', () => { const client = new LinkCodeClient(transport); await client.connect(); const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); - const bytes = new TextEncoder().encode('attached by prompt'); - const draft = await client.putAttachment({ bytes, name: 'note.txt', attachmentKind: 'file' }); + // codex declares images only, and the mock admits like the daemon: a `file` ref is refused. + const bytes = new Uint8Array(Buffer.from(PNG_1X1_BASE64, 'base64')); + const draft = await client.putAttachment({ + bytes, + name: 'shot.png', + mimeType: 'image/png', + attachmentKind: 'image', + }); await expect(client.getAttachmentBytes(sessionId, draft.attachmentId)).rejects.toThrow( 'Attachment not found', @@ -89,9 +98,10 @@ describe('dev mock attachment store', () => { { type: 'resource_link', uri: `attachment:${draft.attachmentId}`, - name: 'note.txt', + name: 'shot.png', + mimeType: 'image/png', size: bytes.byteLength, - description: 'file', + description: 'image', }, ]); client.dispose(); @@ -124,4 +134,30 @@ describe('dev mock attachment store', () => { ).rejects.toThrow('File contents are not image/png'); client.dispose(); }); + + it('refuses a prompt ref the daemon would refuse: unknown id, or a harness without images', async () => { + const client = await connectedClient(); + const codex = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + await expect( + client.submitTurn(codex, { + type: 'prompt', + blocks: [{ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse('att-nope') }], + }), + ).rejects.toMatchObject({ code: 'unsupported_attachment', message: 'Unknown attachment' }); + + const bytes = new TextEncoder().encode('‰PNG not really'); + const { attachmentId } = await client.putAttachment({ + bytes, + name: 'note.bin', + attachmentKind: 'image', + }); + const grok = await client.startSession({ kind: 'grok-build', cwd: '/mock/repo' }); + await expect( + client.submitTurn(grok, { + type: 'prompt', + blocks: [{ type: 'attachment_ref', attachmentId }], + }), + ).rejects.toMatchObject({ code: 'unsupported_attachment' }); + client.dispose(); + }); }); diff --git a/packages/presentation/ui/src/chat/__tests__/attachment-preview.test.tsx b/packages/presentation/ui/src/chat/__tests__/attachment-preview.test.tsx new file mode 100644 index 000000000..cb234df9c --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/attachment-preview.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from '@testing-library/react'; +import { asyncNoop } from 'foxts/noop'; +import { useEffect } from 'react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { + AttachmentPreviewProvider, + resetAttachmentPreviews, + useAttachmentPreview, +} from '../attachment-preview'; + +afterEach(() => { + cleanup(); + resetAttachmentPreviews(); + vi.useRealTimers(); +}); + +function Probe({ id, seen }: { id: string; seen: (url: string | undefined) => void }) { + const preview = useAttachmentPreview(id); + seen(preview?.url); + return null; +} + +/** The workbench resets the preview store on switch from an effect cleanup keyed to the session. */ +function Surface({ sessionId, seen }: { sessionId: string; seen: (url?: string) => void }) { + useEffect(() => () => resetAttachmentPreviews(), [sessionId]); + return ; +} + +it('resolves a session’s previews once across a switch that resets the store', async () => { + const resolve = vi.fn((id: string) => Promise.resolve({ url: `blob:${id}` })); + const seen = vi.fn(); + const view = render( + + + , + ); + await act(asyncNoop); + view.rerender( + + + , + ); + await act(asyncNoop); + + expect(resolve.mock.calls.filter(([id]) => id === 'att-b')).toHaveLength(1); + expect(seen).toHaveBeenLastCalledWith('blob:att-b'); +}); + +it('stops retrying a failing preview after bounded backoff and caches the miss', async () => { + vi.useFakeTimers(); + const resolve = vi.fn(() => Promise.reject(new Error('store I/O'))); + const seen = vi.fn(); + render( + + + , + ); + for (let i = 0; i < 12; i++) { + // eslint-disable-next-line no-await-in-loop -- each tick drains one retry timer + await act(() => vi.advanceTimersByTimeAsync(60 * 1000)); + } + expect(resolve).toHaveBeenCalledTimes(5); + expect(seen).toHaveBeenLastCalledWith(undefined); +}); diff --git a/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx b/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx index a5b7200b1..71e8d6a52 100644 --- a/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx @@ -113,6 +113,24 @@ it('renders a stored image from the preview resolver', async () => { expect(image.getAttribute('src')).toBe('blob:preview'); }); +it('marks a stored image unavailable when its bytes are gone but its record survives', async () => { + const { findByText } = render( + Promise.resolve(null)}> + + , + ); + expect(await findByText('attachmentUnavailable')).toBeDefined(); +}); + it('renders unknown-scheme resource links as inert chips titled by uri', () => { const { getByText, queryByRole } = render( , diff --git a/packages/presentation/ui/src/chat/attachment-preview.tsx b/packages/presentation/ui/src/chat/attachment-preview.tsx index 536fd4326..45cdd8465 100644 --- a/packages/presentation/ui/src/chat/attachment-preview.tsx +++ b/packages/presentation/ui/src/chat/attachment-preview.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useSyncExternalStore } from 'react'; +import { createContext, useContext, useEffect, useSyncExternalStore } from 'react'; export interface AttachmentPreview { url?: string; @@ -12,11 +12,12 @@ const AttachmentPreviewContext = createContext( const previews = new Map(); const inflight = new Set(); const failedUntil = new Map(); +const attempts = new Map(); const retryTimers = new Set>(); -let previewVersion = 0; let previewGeneration = 0; const previewListeners = new Set<() => void>(); -const PREVIEW_RETRY_MS = 2000; +const PREVIEW_RETRY_BASE_MS = 2000; +const PREVIEW_RETRY_MAX_ATTEMPTS = 5; function subscribePreviews(onStoreChange: () => void): () => void { previewListeners.add(onStoreChange); @@ -25,12 +26,7 @@ function subscribePreviews(onStoreChange: () => void): () => void { }; } -function previewStoreVersion(): number { - return previewVersion; -} - -function bumpPreviews(): void { - previewVersion += 1; +function notifyPreviews(): void { for (const listener of previewListeners) listener(); } @@ -39,28 +35,53 @@ function ensurePreview(attachmentId: string, resolve: AttachmentPreviewResolve): const retryAt = failedUntil.get(attachmentId); if (retryAt !== undefined && retryAt > Date.now()) return; inflight.add(attachmentId); - const generation = previewGeneration; - void resolve(attachmentId) - .then((result) => { - if (generation !== previewGeneration) return; - failedUntil.delete(attachmentId); - // `null` is a durable miss (GC / 404). Transient failures throw and are not cached. - previews.set(attachmentId, result ?? {}); - }) - .catch(() => { - if (generation !== previewGeneration) return; - failedUntil.set(attachmentId, Date.now() + PREVIEW_RETRY_MS); - const timer = setTimeout(() => { - retryTimers.delete(timer); - bumpPreviews(); - }, PREVIEW_RETRY_MS); - retryTimers.add(timer); - }) - .finally(() => { - if (generation !== previewGeneration) return; - inflight.delete(attachmentId); - bumpPreviews(); - }); + void fetchPreview(attachmentId, resolve, previewGeneration); +} + +async function fetchPreview( + attachmentId: string, + resolve: AttachmentPreviewResolve, + generation: number, +): Promise { + let result: AttachmentPreview | null; + try { + result = await resolve(attachmentId); + } catch { + if (generation !== previewGeneration) return; + inflight.delete(attachmentId); + scheduleRetry(attachmentId, resolve, generation); + return; + } + if (generation !== previewGeneration) return; + inflight.delete(attachmentId); + attempts.delete(attachmentId); + // `null` is a durable miss (GC / 404). Transient failures throw and retry with backoff. + previews.set(attachmentId, result ?? {}); + notifyPreviews(); +} + +function scheduleRetry( + attachmentId: string, + resolve: AttachmentPreviewResolve, + generation: number, +): void { + const attempt = (attempts.get(attachmentId) ?? 0) + 1; + if (attempt >= PREVIEW_RETRY_MAX_ATTEMPTS) { + attempts.delete(attachmentId); + previews.set(attachmentId, {}); + notifyPreviews(); + return; + } + attempts.set(attachmentId, attempt); + const delay = PREVIEW_RETRY_BASE_MS * 2 ** (attempt - 1); + failedUntil.set(attachmentId, Date.now() + delay); + const timer = setTimeout(() => { + retryTimers.delete(timer); + if (generation !== previewGeneration) return; + failedUntil.delete(attachmentId); + ensurePreview(attachmentId, resolve); + }, delay); + retryTimers.add(timer); } export function resetAttachmentPreviews(): void { @@ -70,7 +91,8 @@ export function resetAttachmentPreviews(): void { previews.clear(); inflight.clear(); failedUntil.clear(); - bumpPreviews(); + attempts.clear(); + notifyPreviews(); } export function AttachmentPreviewProvider({ @@ -87,13 +109,15 @@ export function AttachmentPreviewProvider({ ); } -/** `undefined` while the resolver is in flight, `null` when nothing is wired. */ +/** `undefined` while the resolver is in flight, `null` when nothing is wired. The snapshot is the + * cached entry itself: a version counter plus a render-time map read is memoized away by the React + * Compiler, and the fetch runs in an effect so it lands after the switch-time reset, not before. */ export function useAttachmentPreview(attachmentId: string): AttachmentPreview | null | undefined { const resolve = useContext(AttachmentPreviewContext); - useSyncExternalStore(subscribePreviews, previewStoreVersion); + const cached = useSyncExternalStore(subscribePreviews, () => previews.get(attachmentId)); + useEffect(() => { + if (resolve !== null && cached === undefined) ensurePreview(attachmentId, resolve); + }, [attachmentId, cached, resolve]); if (resolve === null) return null; - const cached = previews.get(attachmentId); - if (cached !== undefined) return cached; - ensurePreview(attachmentId, resolve); - return undefined; + return cached; } diff --git a/packages/presentation/ui/src/chat/content-block-view.tsx b/packages/presentation/ui/src/chat/content-block-view.tsx index 993363345..0eb6115ca 100644 --- a/packages/presentation/ui/src/chat/content-block-view.tsx +++ b/packages/presentation/ui/src/chat/content-block-view.tsx @@ -27,7 +27,7 @@ function StoredImageAttachment({ name={block.name} previewUrl={preview?.url} size={block.size} - unavailable={preview != null && preview.url === undefined && block.mimeType === undefined} + unavailable={preview != null && preview.url === undefined} /> ); } diff --git a/vitest.config.ts b/vitest.config.ts index 704c9c58e..909c55705 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,14 @@ import { fileURLToPath } from 'node:url'; +import babel from '@rolldown/plugin-babel'; +import { reactCompilerPreset } from '@vitejs/plugin-react'; import { ExternalPackageIconLoader } from 'unplugin-icons/loaders'; import Icons from 'unplugin-icons/vite'; import { configDefaults, defineConfig } from 'vitest/config'; +/** The renderers ship React-Compiler output; a hook that only works uncompiled is a production bug + * no uncompiled test can see, so the shared presentation/runtime packages are compiled here too. */ +const rReactCompiled = /\/packages\/(?:presentation\/ui|client\/workbench)\/src\/.*\.tsx$/; + export default defineConfig({ test: { // apps/mobile is its own project: it pins react to RN's bundled renderer, so its tests must @@ -12,6 +18,7 @@ export default defineConfig({ // Mirror the renderers' unplugin-icons setup so modules importing `~icons/*` // virtual modules (e.g. the shell's AgentIcon) load under the root vitest runner. plugins: [ + babel({ include: rReactCompiled, presets: [reactCompilerPreset()] }), Icons({ compiler: 'jsx', jsx: 'react', From e467ced0916f5f5ef5bcb10c15fa0ac573bedcf3 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 6 Sep 2026 20:34:58 +0800 Subject: [PATCH 7/9] fix(engine): store legacy inline images as attachment refs on the durable row --- .../engine-attachment-submit.test.ts | 91 ++++++++++++++++++- .../session-event-sequencing.test.ts | 7 +- packages/host/engine/src/attachment/ingest.ts | 85 +++++++++++++++++ .../engine/src/attachment/materializer.ts | 4 +- .../engine/src/conversation/turn-service.ts | 7 -- packages/host/engine/src/engine.ts | 7 +- packages/host/engine/src/resource/service.ts | 60 ++++-------- .../engine/src/session/lifecycle-service.ts | 22 ++++- .../host/engine/src/session/orchestrator.ts | 10 +- .../src/session/session-input-dispatcher.ts | 8 +- 10 files changed, 235 insertions(+), 66 deletions(-) create mode 100644 packages/host/engine/src/attachment/ingest.ts diff --git a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts index 356db7a2d..fb099d537 100644 --- a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts @@ -50,8 +50,10 @@ async function started(kind: 'claude-code' | 'grok-build' = 'claude-code') { const stateDir = await mkdtemp(join(tmpdir(), 'linkcode-attach-submit-')); temporaryDirectories.push(stateDir); const conversationStore = new InMemoryConversationStore(); - const attachmentStore = new InMemoryAttachmentStore(() => - conversationStore.referencedAttachmentIds(), + const attachmentStore = new InMemoryAttachmentStore( + () => conversationStore.referencedAttachmentIds(), + (sessionId, attachmentId) => + conversationStore.referencedAttachmentIdsForSession(sessionId).includes(attachmentId), ); const blobStore = new FsBlobStore(join(stateDir, 'blobs')); const h = harness( @@ -290,3 +292,88 @@ describe('turn.submit attachment admit and materialize', () => { expect(h.adapter.sentInputs).toHaveLength(1); }); }); + +describe('legacy agent.input inline images', () => { + it('stores the image as a ref on the durable row while the adapter and echo keep it inline', async () => { + const h = await started(); + const image = { + type: 'image' as const, + data: PNG_1X1.toString('base64'), + mimeType: 'image/png', + name: 'shot.png', + }; + await h.inject({ + kind: 'agent.input', + clientReqId: 'legacy', + sessionId: h.sessionId, + input: { type: 'prompt', content: [{ type: 'text', text: 'look' }, image] }, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'request.succeeded', replyTo: 'legacy' }), + ); + }); + expect(h.adapter.sentInputs).toEqual([ + { type: 'prompt', content: [{ type: 'text', text: 'look' }, image] }, + ]); + const echo = h.sent.find( + (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message', + ); + if (echo?.kind !== 'agent.event' || echo.event.type !== 'user-message') { + throw new Error('no live prompt echo'); + } + expect(echo.event.content).toEqual([{ type: 'text', text: 'look' }, image]); + + const [turn] = await h.conversationStore.listTurns(h.sessionId); + const turnInput = nullthrow(turn, 'expected a persisted turn').input; + const promptId = nullthrow( + turnInput.type === 'prompt' ? turnInput.promptId : null, + 'a prompt turn must persist a promptId', + ); + const prompt = nullthrow(await h.conversationStore.getPrompt(promptId)); + const ref = prompt.blocks[1]; + if (ref?.type !== 'attachment_ref') throw new Error('expected an attachment_ref'); + expect(prompt.blocks[0]).toEqual({ type: 'text', text: 'look' }); + expect(JSON.stringify(prompt.blocks)).not.toContain(image.data); + + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + const read = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === 'rr', + ); + if (read?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + const row = read.events.find((item) => 'event' in item && item.event.type === 'user-message'); + if (row === undefined || !('event' in row) || row.event.type !== 'user-message') { + throw new Error('no user row'); + } + expect(row.event.content).toEqual([ + { type: 'text', text: 'look' }, + { + type: 'resource_link', + uri: attachmentUri(ref.attachmentId), + name: 'shot.png', + mimeType: 'image/png', + size: PNG_1X1.byteLength, + description: 'image', + }, + ]); + + await h.inject({ + kind: 'attachment.read', + clientReqId: 'read', + sessionId: h.sessionId, + attachmentId: ref.attachmentId, + offset: 0, + length: PNG_1X1.byteLength, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'attachment.read.result', replyTo: 'read' }), + ); + }); + const page = h.sent.find( + (payload) => payload.kind === 'attachment.read.result' && payload.replyTo === 'read', + ); + if (page?.kind !== 'attachment.read.result') throw new Error('no attachment.read.result'); + expect(page.data).toBe(image.data); + }); +}); diff --git a/packages/host/engine/src/__tests__/session-event-sequencing.test.ts b/packages/host/engine/src/__tests__/session-event-sequencing.test.ts index a7341f334..af33b39df 100644 --- a/packages/host/engine/src/__tests__/session-event-sequencing.test.ts +++ b/packages/host/engine/src/__tests__/session-event-sequencing.test.ts @@ -19,6 +19,8 @@ import { describe, expect, it } from 'vitest'; import { AgentRuntimeService } from '../agent/runtime-service'; import { InMemoryAttachmentStore } from '../attachment/attachment-store'; import { FsBlobStore } from '../attachment/blob-store'; +import { AttachmentIngest } from '../attachment/ingest'; +import { AttachmentIoMutex } from '../attachment/io-mutex'; import { InMemoryConversationStore } from '../conversation/conversation-store'; import { ConversationLiveJournals } from '../conversation/live-journal'; import { ConversationTurnService } from '../conversation/turn-service'; @@ -262,6 +264,7 @@ describe('stale-run events at saga cutover', () => { void Effect.runPromise(effect); }, ); + const blobs = new FsBlobStore(join(tmpdir(), 'linkcode-sequencing-blobs')); const processor = new SessionEventProcessor( transport, registry, @@ -273,8 +276,8 @@ describe('stale-run events at saga cutover', () => { registry, undefined, new FileHostService(new PreviewRouteRegistry()), - new FsBlobStore(join(tmpdir(), 'linkcode-sequencing-blobs')), - new InMemoryAttachmentStore(), + blobs, + new AttachmentIngest(blobs, new InMemoryAttachmentStore(), new AttachmentIoMutex()), ), turns, journals, diff --git a/packages/host/engine/src/attachment/ingest.ts b/packages/host/engine/src/attachment/ingest.ts new file mode 100644 index 000000000..d1f90d529 --- /dev/null +++ b/packages/host/engine/src/attachment/ingest.ts @@ -0,0 +1,85 @@ +import { Buffer } from 'node:buffer'; +import { createHash, randomUUID } from 'node:crypto'; +import type { AttachmentId, ContentBlock, PromptBlock } from '@linkcode/schema'; +import { AttachmentIdSchema, blobIdFromSha256, MAX_ATTACHMENT_NAME_LENGTH } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { noop } from 'foxts/noop'; +import { OperationError } from '../failure'; +import type { AttachmentStore } from './attachment-store'; +import type { BlobStore } from './blob-store'; +import type { AttachmentIoMutex } from './io-mutex'; + +export interface IngestRecord { + readonly kind: string; + readonly name: string; + readonly mimeType: string; +} + +/** Bytes the daemon already holds (a resource upload, a legacy inline image) become one stored + * attachment. Publish and row insert share the GC mutex so a doomed blob cannot regrow a row. */ +export class AttachmentIngest { + constructor( + private readonly blobs: BlobStore, + private readonly attachments: AttachmentStore, + private readonly io: AttachmentIoMutex, + ) {} + + store(bytes: Uint8Array, record: IngestRecord, now = Date.now()): Promise { + const sha256 = createHash('sha256').update(bytes).digest('hex'); + const blobId = blobIdFromSha256(sha256); + const attachmentId = AttachmentIdSchema.parse(`att-${randomUUID()}`); + const { attachments, blobs } = this; + return this.io.run(async () => { + const stage = await blobs.stage(attachmentId); + try { + await stage.write(0, bytes); + await stage.commit({ sha256, sizeBytes: bytes.byteLength }); + await attachments.commitAttachment({ + blob: { blobId, sizeBytes: bytes.byteLength, createdAt: now }, + attachment: { + attachmentId, + ...record, + sizeBytes: bytes.byteLength, + metadata: {}, + createdAt: now, + }, + }); + } catch (error) { + await stage.abort().catch(noop); + // Content addressing means another attachment may already own a row for this blob; + // unlinking then would strand its bytes. + if (!(await attachments.getBlob(blobId))) await blobs.delete(blobId); + throw error; + } + return attachmentId; + }); + } + + /** Durable blocks for legacy prompt content: inline images are stored and referenced, so the + * row keeps them after the live echo is gone. Other binary blocks were refused at admit. */ + promptBlocks(content: readonly ContentBlock[]): Effect.Effect { + return Effect.tryPromise({ + try: () => + Promise.all( + content.map(async (block): Promise => { + if (block.type === 'text') return { type: 'text', text: block.text }; + if (block.type !== 'image') return; + // The legacy block's name is unbounded; the record's is not. + const attachmentId = await this.store(Buffer.from(block.data, 'base64'), { + kind: 'image', + name: (block.name || 'image').slice(0, MAX_ATTACHMENT_NAME_LENGTH), + mimeType: block.mimeType, + }); + return { type: 'attachment_ref', attachmentId }; + }), + ).then((blocks) => blocks.filter((block) => block !== undefined)), + catch: (cause) => + new OperationError({ + subsystem: 'filesystem', + operation: 'attachments.ingest', + publicMessage: 'Failed to store a prompt attachment', + cause, + }), + }); + } +} diff --git a/packages/host/engine/src/attachment/materializer.ts b/packages/host/engine/src/attachment/materializer.ts index cfda3385d..46c5f6253 100644 --- a/packages/host/engine/src/attachment/materializer.ts +++ b/packages/host/engine/src/attachment/materializer.ts @@ -237,8 +237,8 @@ export class PromptMaterializer { try() { return io.run(async () => { await mkdir(destDir, { recursive: true }); - // The destination is content-addressed, so an existing link is already the same bytes — - // a prompt may reference one attachment more than once within a run. + // The destination is keyed by the immutable attachment id, so an existing link is already + // the same bytes — a prompt may reference one attachment more than once within a run. await link(source, dest).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error; }); diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 7a37cab62..73612b0b4 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -40,13 +40,6 @@ function mintPromptId(): PromptId { return `prompt-${randomUUID()}` as PromptId; } -/** Durable prompt blocks from legacy prompt content: text only. Inline images are not ingested. */ -export function promptBlocksFromContent(content: ContentBlock[]): PromptBlock[] { - return content.flatMap((block) => - block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], - ); -} - /** What a submit wants persisted, before ids and ordinals exist. */ export interface TurnIntentSpec { readonly sessionId: SessionId; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 081827cc8..9ba000101 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -20,6 +20,7 @@ import type { AttachmentReachability } from './attachment/attachment-store'; import { InMemoryAttachmentStore } from './attachment/attachment-store'; import { FsBlobStore } from './attachment/blob-store'; import { AttachmentGc } from './attachment/gc'; +import { AttachmentIngest } from './attachment/ingest'; import { AttachmentIoMutex } from './attachment/io-mutex'; import { PromptMaterializer } from './attachment/materializer'; import { AttachmentRequestHandler } from './attachment/request-handler'; @@ -139,6 +140,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const attachmentIo = new AttachmentIoMutex(); const attachmentGc = new AttachmentGc(attachmentStore, blobStore, Date.now, attachmentIo); const materializer = new PromptMaterializer(attachmentStore, blobStore, stateDir, attachmentIo); + const ingest = new AttachmentIngest(blobStore, attachmentStore, attachmentIo); const resources = new ResourceService( transport, resourceStore, @@ -146,8 +148,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( stateDir, fileHost, blobStore, - attachmentStore, - attachmentIo, + ingest, ); const uploads = new AttachmentUploadService(blobStore, attachmentStore, attachmentIo); const plugins = new PluginService(deps.pluginFactory ?? createPluginProviderAdapter); @@ -228,6 +229,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( resources, conversationTurns, conversationJournals, + ingest, deps.browserToolsEnabled ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, @@ -290,6 +292,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( conversationCheckpoints, attachmentStore, materializer, + ingest, ); const sessionRequests = new SessionRequestHandler( transport, diff --git a/packages/host/engine/src/resource/service.ts b/packages/host/engine/src/resource/service.ts index 1f8769edd..fac7814e7 100644 --- a/packages/host/engine/src/resource/service.ts +++ b/packages/host/engine/src/resource/service.ts @@ -5,7 +5,6 @@ import { basename, extname, join, resolve, sep, win32 } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { SessionId, SessionResource, SessionResourceId } from '@linkcode/schema'; import { - AttachmentIdSchema, blobIdFromSha256, declaredMimeTypeMatches, MAX_ATTACHMENT_BYTES, @@ -15,9 +14,8 @@ import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; import { noop } from 'foxts/noop'; -import type { AttachmentStore } from '../attachment/attachment-store'; import type { BlobStore } from '../attachment/blob-store'; -import { AttachmentIoMutex } from '../attachment/io-mutex'; +import type { AttachmentIngest } from '../attachment/ingest'; import { OperationError, RequestError } from '../failure'; import type { FileHostService } from '../preview/file-host-service'; import type { SessionRecordRegistry } from '../session/session-record-registry'; @@ -67,8 +65,7 @@ export class ResourceService { private readonly stateDir: string | undefined, private readonly fileHost: FileHostService, private readonly blobs: BlobStore, - private readonly attachments: AttachmentStore, - private readonly io: AttachmentIoMutex = new AttachmentIoMutex(), + private readonly ingest: AttachmentIngest, ) {} list(sessionId: SessionId): Effect.Effect { @@ -81,7 +78,7 @@ export class ResourceService { mimeType: string | undefined, data: string, ): Effect.Effect { - const { attachments, blobs, io, records, transport } = this; + const { blobs, ingest, records, transport } = this; return Effect.gen({ self: this }, function* () { if (!records.has(sessionId)) { return yield* new RequestError({ code: 'not_found', message: 'Session not found' }); @@ -100,46 +97,29 @@ export class ResourceService { }); } const resourceId = SessionResourceIdSchema.parse(`resource-${randomUUID()}`); - const attachmentId = AttachmentIdSchema.parse(`att-${randomUUID()}`); const sha256 = createHash('sha256').update(bytes).digest('hex'); - const blobId = blobIdFromSha256(sha256); const now = Date.now(); const kind = classify(name, mimeType); - const locator = { type: 'managed-file' as const, path: blobs.pathOf(blobId) }; - const written = yield* Effect.tryPromise({ - async try() { - await io.run(async () => { - const stage = await blobs.stage(resourceId); - try { - await stage.write(0, bytes); - await stage.commit({ sha256, sizeBytes: bytes.byteLength }); - await attachments.commitAttachment({ - blob: { blobId, sizeBytes: bytes.byteLength, createdAt: now }, - attachment: { - attachmentId, - kind, - name, - mimeType: mimeType ?? 'application/octet-stream', - sizeBytes: bytes.byteLength, - metadata: {}, - createdAt: now, - }, - }); - } catch (error) { - await stage.abort().catch(noop); - // Content addressing means another attachment may already own a row for this blob; - // unlinking then would strand its bytes. - if (!(await attachments.getBlob(blobId))) await blobs.delete(blobId); - throw error; - } - }); - }, + const locator = { + type: 'managed-file' as const, + path: blobs.pathOf(blobIdFromSha256(sha256)), + }; + const attachmentId = yield* Effect.tryPromise({ + try: () => + ingest.store( + bytes, + { kind, name, mimeType: mimeType ?? 'application/octet-stream' }, + now, + ), catch: (cause) => cause, }).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), + Effect.catch((error) => + Effect.logWarning('Failed to persist uploaded resource', error).pipe( + Effect.as(undefined), + ), + ), ); - if (!written) { + if (attachmentId === undefined) { return { resourceId, sessionId, diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 8b3af5d33..1f9027315 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -27,6 +27,7 @@ import { uniqueAttachmentIds, } from '../attachment/admit'; import type { AttachmentStore } from '../attachment/attachment-store'; +import type { AttachmentIngest } from '../attachment/ingest'; import type { PromptMaterializer } from '../attachment/materializer'; import type { SessionDriver } from '../automation'; import type { ConversationCheckpointService, ForkCut } from '../conversation/checkpoint-service'; @@ -36,7 +37,7 @@ import type { PersistedTurnIntent, TerminalOperation, } from '../conversation/turn-service'; -import { mintOperationId, promptBlocksFromContent } from '../conversation/turn-service'; +import { mintOperationId } from '../conversation/turn-service'; import type { EngineFailure } from '../failure'; import { causeToRequestFailure, @@ -130,6 +131,7 @@ export class SessionLifecycleService { private readonly checkpoints: ConversationCheckpointService, private readonly attachments: AttachmentStore, private readonly materializer: PromptMaterializer, + private readonly ingest: AttachmentIngest, ) { this.driver = { createSession: ({ signal, ...options }) => @@ -342,7 +344,7 @@ export class SessionLifecycleService { ); } - const { checkpoints, history, sessions, turns } = this; + const { checkpoints, history, ingest, sessions, turns } = this; const resolveForRecord = this.resolveForRecord.bind(this); const launchRun = this.launchRun.bind(this); return Effect.gen(function* () { @@ -415,7 +417,7 @@ export class SessionLifecycleService { operationId: mintOperationId(), runId, parentTurnId, - input: { type: 'prompt', blocks: promptBlocksFromContent(content) }, + input: { type: 'prompt', blocks: yield* ingest.promptBlocks(content) }, }); yield* Effect.gen(function* () { yield* sessions.stopForReplacement(sourceSessionId); @@ -752,9 +754,21 @@ export class SessionLifecycleService { kind: AgentKind, blocks: Extract['blocks'], ): Effect.Effect { - const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(blocks)); + const occurrences = attachmentIdsFromBlocks(blocks); + const ids = uniqueAttachmentIds(occurrences); if (ids.length === 0) return Effect.void; const capability = effectiveAttachmentCapability(kind); + // Bound the ref count before the store load: `admitPromptAttachments` charges per occurrence, + // and an unbounded id list would otherwise reach SQLite as one oversized `IN (...)`. + if (capability !== undefined) { + const maxCount = + (capability.kinds.image?.maxCount ?? 0) + (capability.kinds.file?.maxCount ?? 0); + if (occurrences.length > maxCount) { + return Effect.fail( + new RequestError({ code: 'limit_exceeded', message: 'Too many attachments' }), + ); + } + } return Effect.tryPromise({ try: () => this.attachments.listAttachments(ids), catch: (cause) => diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 09ab8980a..282a21f15 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -17,11 +17,12 @@ import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Cause, Deferred, Effect, Exit, Scope } from 'effect'; import type { AgentRuntimeService } from '../agent/runtime-service'; +import type { AttachmentIngest } from '../attachment/ingest'; import type { TurnResult } from '../automation/turn-watcher'; import { watchTurn } from '../automation/turn-watcher'; import type { ConversationLiveJournals } from '../conversation/live-journal'; import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service'; -import { mintOperationId, promptBlocksFromContent } from '../conversation/turn-service'; +import { mintOperationId } from '../conversation/turn-service'; import type { EngineFailure } from '../failure'; import { OperationError, RequestError, toOperationFailure } from '../failure'; import { observeOperation, recordLiveSessions } from '../observability'; @@ -47,6 +48,7 @@ export class SessionOrchestrator { private readonly resources: ResourceService, private readonly turns: ConversationTurnService, private readonly journals: ConversationLiveJournals, + private readonly ingest: AttachmentIngest, private readonly browserTools?: BrowserToolsetFactory, private readonly onRunEnded?: (sessionId: SessionId, runId: RunId) => void, ) { @@ -59,7 +61,7 @@ export class SessionOrchestrator { turns, journals, ); - this.inputs = new SessionInputDispatcher(records, this.events, resources, turns); + this.inputs = new SessionInputDispatcher(records, this.events, resources, turns, ingest); } private get(sessionId: SessionId): LiveSession | undefined { @@ -207,7 +209,7 @@ export class SessionOrchestrator { } session.turnInputActive = true; const content: ContentBlock[] = [{ type: 'text', text }]; - const { records, turns } = this; + const { ingest, records, turns } = this; return session.run( Effect.gen({ self: this }, function* () { if (yield* turns.hasOpenOperation(sessionId)) { @@ -220,7 +222,7 @@ export class SessionOrchestrator { operationId: mintOperationId(), runId: session.runId, parentTurnId: records.get(sessionId)?.activeLeafTurnId ?? null, - input: { type: 'prompt', blocks: promptBlocksFromContent(content) }, + input: { type: 'prompt', blocks: yield* ingest.promptBlocks(content) }, }); const result = yield* Effect.sync(() => { this.events.broadcast( diff --git a/packages/host/engine/src/session/session-input-dispatcher.ts b/packages/host/engine/src/session/session-input-dispatcher.ts index d8cbbc0ae..86fe6dab1 100644 --- a/packages/host/engine/src/session/session-input-dispatcher.ts +++ b/packages/host/engine/src/session/session-input-dispatcher.ts @@ -8,8 +8,9 @@ import { import { Cause, Effect, Exit } from 'effect'; import { nullthrow } from 'foxts/guard'; import { assertInlineAttachmentsSupported } from '../attachment/admit'; +import type { AttachmentIngest } from '../attachment/ingest'; import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service'; -import { mintOperationId, promptBlocksFromContent } from '../conversation/turn-service'; +import { mintOperationId } from '../conversation/turn-service'; import { causeToRequestFailure, OperationError, RequestError } from '../failure'; import type { ResourceService } from '../resource/service'; import { RESOURCE_CONTEXT_SENTINEL } from '../resource/service'; @@ -25,6 +26,7 @@ export class SessionInputDispatcher { private readonly events: SessionEventProcessor, private readonly resources: ResourceService, private readonly turns: ConversationTurnService, + private readonly ingest: AttachmentIngest, ) {} /** `prepared` is a submit-saga intent already persisted for this dispatch; without one, a @@ -69,7 +71,7 @@ export class SessionInputDispatcher { this.events.rejectInput(sessionId, session, error.message); return Effect.fail(error); } - const { events, records, resources, turns } = this; + const { events, ingest, records, resources, turns } = this; // Set synchronously, before the first await, so a same-tick second turn input cannot slip // past the gate above while this one is still validating; every failure exit releases it. if (startsTurn) session.turnInputActive = true; @@ -127,7 +129,7 @@ export class SessionInputDispatcher { parentTurnId: records.get(sessionId)?.activeLeafTurnId ?? null, input: input.type === 'prompt' - ? { type: 'prompt', blocks: promptBlocksFromContent(input.content) } + ? { type: 'prompt', blocks: yield* ingest.promptBlocks(input.content) } : input, }); } From d98bfffcca67011ba49d6104fb501a67c4b78bde Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 6 Sep 2026 20:36:26 +0800 Subject: [PATCH 8/9] fix(workbench): sniff before upload, match the echo text, and ingest inline images in the mock --- .../workbench/src/mock/dev-mock-host.ts | 84 +++++++++++++------ .../__tests__/prompt-attachments.test.ts | 56 ++++++++++++- .../src/surface/prompt-attachments.ts | 30 ++++++- .../workbench/src/surface/workbench.tsx | 4 +- .../integration/dev-mock-attachments.test.ts | 49 ++++++++++- 5 files changed, 186 insertions(+), 37 deletions(-) diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 3c655a69f..15bd19fb2 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -273,7 +273,7 @@ export class DevMockHost { private readonly attachmentBlobs = new Map(); private readonly attachmentRecords = new Map< string, - { blobId: BlobId; sizeBytes: number; name: string; mimeType?: string; kind: string } + { blobId: BlobId; sizeBytes: number; name: string; mimeType: string; kind: string } >(); private readonly attachmentBegins = new Map(); /** The daemon's `isReachable` roots: sessions whose prompt or resource names the attachment. */ @@ -1508,6 +1508,7 @@ export class DevMockHost { content: ContentBlock[], ): Promise { const turn = this.beginTurn(session, content); + turn.readContent = await this.ingestInlineImages(session.sessionId, content); const result = await this.streamMockReply(session, content); settleTurn(session, turn, result.ok ? 'completed' : 'failed'); if (result.ok) this.sendSuccess(replyTo); @@ -2096,7 +2097,7 @@ export class DevMockHost { blobId, sizeBytes: upload.declaredSize, name: upload.name, - mimeType: upload.mimeType, + mimeType: upload.mimeType ?? 'application/octet-stream', kind: upload.attachmentKind, }); this.send({ @@ -2122,10 +2123,21 @@ export class DevMockHost { this.sendSuccess(payload.clientReqId); } - private async publishResourceAttachment( + private publishResourceAttachment( payload: Extract, ): Promise { - const bytes = mockBase64ToBytes(payload.data); + return this.storeMockBytes(mockBase64ToBytes(payload.data), { + name: payload.name, + mimeType: payload.mimeType, + kind: payload.mimeType?.startsWith('image/') ? 'image' : 'file', + }); + } + + /** Bytes the mock already holds become one record, the way the daemon's ingest does. */ + private async storeMockBytes( + bytes: Uint8Array, + record: { name: string; mimeType?: string; kind: string }, + ): Promise { const digest = await mockSha256Hex(bytes); this.attachmentBlobs.set(digest, bytes); this.attachmentSeq += 1; @@ -2133,36 +2145,54 @@ export class DevMockHost { this.attachmentRecords.set(attachmentId, { blobId: blobIdFromSha256(digest), sizeBytes: bytes.byteLength, - name: payload.name, - mimeType: payload.mimeType, - kind: payload.mimeType?.startsWith('image/') ? 'image' : 'file', + name: record.name, + mimeType: record.mimeType ?? 'application/octet-stream', + kind: record.kind, }); return attachmentId; } + /** The daemon stores a legacy prompt's inline images and projects refs on read; the echo keeps + * the image for old clients. `undefined` when the prompt is text-only (the echo is the row). */ + private ingestInlineImages( + sessionId: SessionId, + content: ContentBlock[], + ): Promise { + if (!content.some((block) => block.type === 'image')) return Promise.resolve(undefined); + return Promise.all( + content.map(async (block) => { + if (block.type !== 'image') return block; + const attachmentId = await this.storeMockBytes(mockBase64ToBytes(block.data), { + name: block.name ?? 'image', + mimeType: block.mimeType, + kind: 'image', + }); + this.rootAttachment(sessionId, attachmentId); + return this.attachmentLink(attachmentId); + }), + ); + } + + private attachmentLink(attachmentId: AttachmentId): ContentBlock { + const record = this.attachmentRecords.get(attachmentId); + return { + type: 'resource_link', + uri: attachmentUri(attachmentId), + name: record?.name ?? attachmentId, + ...(record !== undefined && { + mimeType: record.mimeType, + size: record.sizeBytes, + description: record.kind, + }), + }; + } + /** Durable `conversation.read` row: refs become `attachment:` links, never bytes. */ private projectTurnSubmit(input: TurnSubmitInput): ContentBlock[] { if (input.type !== 'prompt') return turnSubmitContent(input); - const content: ContentBlock[] = []; - for (let i = 0, len = input.blocks.length; i < len; i++) { - const block = input.blocks[i]; - if (block.type === 'text') { - content.push(textBlock(block.text)); - continue; - } - const record = this.attachmentRecords.get(block.attachmentId); - content.push({ - type: 'resource_link', - uri: attachmentUri(block.attachmentId), - name: record?.name ?? block.attachmentId, - ...(record?.mimeType !== undefined && { mimeType: record.mimeType }), - ...(record !== undefined && { - size: record.sizeBytes, - description: record.kind, - }), - }); - } - return content; + return input.blocks.map((block) => + block.type === 'text' ? textBlock(block.text) : this.attachmentLink(block.attachmentId), + ); } /** Root an attachment in a session, the way persisting a prompt or a resource does on the daemon. */ diff --git a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts index 939b42686..a8654a5b3 100644 --- a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts +++ b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts @@ -1,7 +1,8 @@ -import type { Conversation } from '@linkcode/client-core'; +import type { Conversation, LinkCodeClient } from '@linkcode/client-core'; import type { ContentBlock, SessionId, TurnId } from '@linkcode/schema'; import { AttachmentIdSchema, userRowMessageId } from '@linkcode/schema'; -import { describe, expect, it } from 'vitest'; +import type { ComposerAttachment } from '@linkcode/ui'; +import { describe, expect, it, vi } from 'vitest'; import { clearInflightUserAttachments, isStoredAttachmentBlock, @@ -10,6 +11,7 @@ import { overlayPendingUserAttachments, pendingUserAttachmentsSnapshot, promptBlocksFromComposer, + stageStoreAttachment, } from '../prompt-attachments'; const sessionId = 'sess-1' as SessionId; @@ -62,6 +64,25 @@ describe('promptBlocksFromComposer', () => { }); }); +describe('stageStoreAttachment', () => { + it('refuses bytes that are not the declared image before any upload frame', async () => { + const putAttachment = vi.fn(); + const client = { putAttachment }; + const pending: ComposerAttachment = { + id: 'chip-1', + kind: 'image', + name: 'shot.png', + status: 'pending', + }; + const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); + const file = new File([jpegBytes], 'shot.png', { type: 'image/png' }); + await expect( + stageStoreAttachment(client, file, pending, { unsupportedType: 'not a png' }), + ).rejects.toThrow('not a png'); + expect(putAttachment).not.toHaveBeenCalled(); + }); +}); + describe('overlayPendingUserAttachments', () => { it('fills a text-only echo from pending store refs and yields to a durable row', () => { const link: ContentBlock = { @@ -118,7 +139,7 @@ describe('overlayPendingUserAttachments', () => { uri: 'attachment:att-2', name: 'later.png', }; - noteInflightUserAttachments(inflightSession, [link]); + noteInflightUserAttachments(inflightSession, [{ type: 'text', text: 'look' }, link]); const started = Date.now(); const conversation: Conversation = { ...EMPTY, @@ -163,6 +184,35 @@ describe('overlayPendingUserAttachments', () => { }); }); + it('leaves a same-window user row alone when its text is not the sent prompt', () => { + const session = 'sess-author' as SessionId; + const link: ContentBlock = { + type: 'resource_link', + uri: 'attachment:att-3', + name: 'mine.png', + }; + noteInflightUserAttachments(session, [{ type: 'text', text: 'mine' }, link]); + const conversation: Conversation = { + ...EMPTY, + items: [ + { + kind: 'message', + id: userRowMessageId('turn-9' as TurnId), + turnId: 'turn-9', + role: 'user', + blocks: [{ type: 'text', text: 'automation prompt' }], + isStreaming: false, + receivedAt: Date.now() + 1, + }, + ], + }; + expect( + overlayPendingUserAttachments(conversation, session, pendingUserAttachmentsSnapshot()) + .items[0], + ).toMatchObject({ blocks: [{ type: 'text', text: 'automation prompt' }] }); + clearInflightUserAttachments(session); + }); + it('detects stored attachment links', () => { expect( isStoredAttachmentBlock({ diff --git a/packages/client/workbench/src/surface/prompt-attachments.ts b/packages/client/workbench/src/surface/prompt-attachments.ts index a3dfe7f69..ec04033fa 100644 --- a/packages/client/workbench/src/surface/prompt-attachments.ts +++ b/packages/client/workbench/src/surface/prompt-attachments.ts @@ -7,7 +7,12 @@ import type { PromptBlock, SessionId, } from '@linkcode/schema'; -import { AttachmentIdSchema, attachmentIdFromUri, attachmentUri } from '@linkcode/schema'; +import { + AttachmentIdSchema, + attachmentIdFromUri, + attachmentUri, + declaredMimeTypeMatches, +} from '@linkcode/schema'; import type { ComposerAttachment } from '@linkcode/ui'; const objectUrls = new Map(); @@ -81,11 +86,16 @@ function storedAttachmentResourceLink( } export async function stageStoreAttachment( - client: LinkCodeClient, + client: Pick, file: File, pending: ComposerAttachment, + errors: { unsupportedType: string }, ): Promise { const bytes = new Uint8Array(await file.arrayBuffer()); + // The daemon sniffs at commit; refusing here saves the transfer of a mislabeled file. + if (!declaredMimeTypeMatches(file.type, bytes.subarray(0, 16))) { + throw new Error(errors.unsupportedType); + } const kind = pending.kind === 'image' ? 'image' : 'file'; const { attachmentId } = await client.putAttachment({ bytes, @@ -102,7 +112,7 @@ export async function stageStoreAttachment( } export async function stageStoreAttachmentFromBase64( - client: LinkCodeClient, + client: Pick, pending: ComposerAttachment, content: string, mimeType: string | undefined, @@ -134,6 +144,17 @@ interface PendingUserRow { interface InflightUserAttachments { readonly blocks: readonly ContentBlock[]; readonly startedAt: number; + /** The echo is text-only; matching its text keeps the refs off another author's row. */ + readonly text: string; +} + +function promptTextOf(blocks: readonly ContentBlock[]): string { + let text = ''; + for (let i = 0, len = blocks.length; i < len; i++) { + const block = blocks[i]; + if (block.type === 'text') text += block.text; + } + return text; } /** Replaced wholesale on every change: the render reads this snapshot, never the module maps. */ @@ -179,7 +200,7 @@ export function noteInflightUserAttachments( const refs = storedAttachmentBlocks(blocks); if (refs.length === 0) return; updatePending((_pending, inflight) => { - inflight.set(sessionId, { blocks: refs, startedAt: Date.now() }); + inflight.set(sessionId, { blocks: refs, startedAt: Date.now(), text: promptTextOf(blocks) }); }); } @@ -228,6 +249,7 @@ export function overlayPendingUserAttachments( const item = next[i]; if (item.kind !== 'message' || item.role !== 'user') continue; if (item.receivedAt === undefined || item.receivedAt < live.startedAt) continue; + if (promptTextOf(item.blocks) !== live.text) continue; if (!item.blocks.some(isStoredAttachmentBlock)) { next[i] = { ...item, blocks: [...item.blocks, ...live.blocks] }; changed = true; diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 388748a34..b20b5c5c2 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -514,7 +514,9 @@ function WorkbenchSessionSurface({ file: File, pending: ComposerAttachment, ): Promise { - return stageStoreAttachment(client, file, pending); + return stageStoreAttachment(client, file, pending, { + unsupportedType: tComposer('attachmentUnsupportedType'), + }); } async function resolveAttachmentPreview(attachmentId: string): Promise { diff --git a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts index 17f52b2c0..419d2fe8a 100644 --- a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts @@ -1,7 +1,11 @@ import { LinkCodeClient } from '@linkcode/client-core'; -import { ATTACHMENT_UPLOAD_CHUNK_BYTES, AttachmentIdSchema } from '@linkcode/schema'; +import { + ATTACHMENT_UPLOAD_CHUNK_BYTES, + AttachmentIdSchema, + attachmentIdFromUri, +} from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; const PNG_1X1_BASE64 = @@ -160,4 +164,45 @@ describe('dev mock attachment store', () => { ).rejects.toMatchObject({ code: 'unsupported_attachment' }); client.dispose(); }); + + it('stores a legacy inline image as a ref on the read row and serves its bytes', async () => { + const client = await connectedClient(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + const bytes = new Uint8Array(Buffer.from(PNG_1X1_BASE64, 'base64')); + // The legacy ack lands after the whole mock reply streams; read the row as soon as it exists. + const sending = client.send(sessionId, { + type: 'prompt', + content: [ + { type: 'text', text: 'look' }, + { type: 'image', data: PNG_1X1_BASE64, mimeType: 'image/png', name: 'shot.png' }, + ], + }); + const content = await vi.waitFor(async () => { + const page = await client.readConversation(sessionId); + const userRow = page.events.find( + (item) => 'event' in item && item.event.type === 'user-message', + ); + const blocks = + userRow && 'event' in userRow && userRow.event.type === 'user-message' + ? userRow.event.content + : undefined; + if (blocks?.[1]?.type !== 'resource_link') throw new Error('row not projected yet'); + return blocks; + }); + const link = content[1]; + if (link?.type !== 'resource_link') throw new Error('expected a stored attachment link'); + expect(link).toMatchObject({ + name: 'shot.png', + mimeType: 'image/png', + size: bytes.byteLength, + description: 'image', + }); + expect(JSON.stringify(content)).not.toContain(PNG_1X1_BASE64); + const attachmentId = AttachmentIdSchema.parse(nullthrow(attachmentIdFromUri(link.uri))); + const read = await client.getAttachmentBytes(sessionId, attachmentId); + expect(read.bytes).toEqual(bytes); + await client.send(sessionId, { type: 'cancel' }); + await sending; + client.dispose(); + }); }); From f73786cb0140fe7803a17706f396a8e77aa7b259 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 6 Sep 2026 20:54:48 +0800 Subject: [PATCH 9/9] fix(engine,workbench): sniff legacy inline images before storing and refuse refs on capability-less harnesses early --- .../workbench/src/mock/dev-mock-host.ts | 11 +++ .../__tests__/prompt-attachments.test.ts | 31 ++++++- .../src/surface/prompt-attachments.ts | 4 +- .../workbench/src/surface/workbench.tsx | 2 +- .../integration/dev-mock-attachments.test.ts | 18 ++++ .../engine-attachment-submit.test.ts | 85 +++++++++++++++++-- packages/host/engine/src/attachment/ingest.ts | 34 ++++++-- .../engine/src/session/lifecycle-service.ts | 26 +++--- packages/presentation/i18n/src/locales/en.ts | 1 + .../presentation/i18n/src/locales/zh-cn.ts | 1 + 10 files changed, 186 insertions(+), 27 deletions(-) diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 15bd19fb2..cb5378f46 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -1507,6 +1507,17 @@ export class DevMockHost { session: MockSession, content: ContentBlock[], ): Promise { + // The daemon sniffs before it stores; a mislabeled inline image is refused before any echo. + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if (block.type !== 'image') continue; + if (!declaredMimeTypeMatches(block.mimeType, mockBase64ToBytes(block.data).subarray(0, 16))) { + this.sendFailure(replyTo, `File contents are not ${block.mimeType}`, { + code: 'invalid_request', + }); + return; + } + } const turn = this.beginTurn(session, content); turn.readContent = await this.ingestInlineImages(session.sessionId, content); const result = await this.streamMockReply(session, content); diff --git a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts index a8654a5b3..4ec3e635a 100644 --- a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts +++ b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts @@ -77,7 +77,7 @@ describe('stageStoreAttachment', () => { const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); const file = new File([jpegBytes], 'shot.png', { type: 'image/png' }); await expect( - stageStoreAttachment(client, file, pending, { unsupportedType: 'not a png' }), + stageStoreAttachment(client, file, pending, { contentMismatch: 'not a png' }), ).rejects.toThrow('not a png'); expect(putAttachment).not.toHaveBeenCalled(); }); @@ -184,6 +184,35 @@ describe('overlayPendingUserAttachments', () => { }); }); + it('paints an attachment-only prompt onto its empty echo', () => { + const session = 'sess-only-attachment' as SessionId; + const link: ContentBlock = { + type: 'resource_link', + uri: 'attachment:att-4', + name: 'only.png', + }; + noteInflightUserAttachments(session, [link]); + const conversation: Conversation = { + ...EMPTY, + items: [ + { + kind: 'message', + id: userRowMessageId('turn-4' as TurnId), + turnId: 'turn-4', + role: 'user', + blocks: [], + isStreaming: false, + receivedAt: Date.now() + 1, + }, + ], + }; + expect( + overlayPendingUserAttachments(conversation, session, pendingUserAttachmentsSnapshot()) + .items[0], + ).toMatchObject({ blocks: [link] }); + clearInflightUserAttachments(session); + }); + it('leaves a same-window user row alone when its text is not the sent prompt', () => { const session = 'sess-author' as SessionId; const link: ContentBlock = { diff --git a/packages/client/workbench/src/surface/prompt-attachments.ts b/packages/client/workbench/src/surface/prompt-attachments.ts index ec04033fa..b8dae9925 100644 --- a/packages/client/workbench/src/surface/prompt-attachments.ts +++ b/packages/client/workbench/src/surface/prompt-attachments.ts @@ -89,12 +89,12 @@ export async function stageStoreAttachment( client: Pick, file: File, pending: ComposerAttachment, - errors: { unsupportedType: string }, + errors: { contentMismatch: string }, ): Promise { const bytes = new Uint8Array(await file.arrayBuffer()); // The daemon sniffs at commit; refusing here saves the transfer of a mislabeled file. if (!declaredMimeTypeMatches(file.type, bytes.subarray(0, 16))) { - throw new Error(errors.unsupportedType); + throw new Error(errors.contentMismatch); } const kind = pending.kind === 'image' ? 'image' : 'file'; const { attachmentId } = await client.putAttachment({ diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index b20b5c5c2..f5eec0a65 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -515,7 +515,7 @@ function WorkbenchSessionSurface({ pending: ComposerAttachment, ): Promise { return stageStoreAttachment(client, file, pending, { - unsupportedType: tComposer('attachmentUnsupportedType'), + contentMismatch: tComposer('attachmentContentMismatch', { type: file.type }), }); } diff --git a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts index 419d2fe8a..4441019f9 100644 --- a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts @@ -205,4 +205,22 @@ describe('dev mock attachment store', () => { await sending; client.dispose(); }); + + it('refuses a legacy inline image whose bytes are not the declared type', async () => { + const client = await connectedClient(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + await expect( + client.send(sessionId, { + type: 'prompt', + content: [ + { + type: 'image', + mimeType: 'image/png', + data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]).toString('base64'), + }, + ], + }), + ).rejects.toThrow('File contents are not image/png'); + client.dispose(); + }); }); diff --git a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts index fb099d537..b4a186455 100644 --- a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { asHistoryId } from '@linkcode/agent-adapter'; @@ -81,6 +81,7 @@ async function started(kind: 'claude-code' | 'grok-build' = 'claude-code') { conversationStore, attachmentStore, blobStore, + stateDir, sessionId: startedId(h.sent, 'r1'), adapter: nullthrow(h.adapters[0]), }; @@ -125,9 +126,10 @@ describe('turn.submit attachment admit and materialize', () => { expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); }); - it('refuses an image on grok-build at admit', async () => { + it('refuses an image on grok-build at admit without touching the store', async () => { const h = await started('grok-build'); const attachmentId = await readyPng(h); + const list = vi.spyOn(h.attachmentStore, 'listAttachments'); await h.inject({ kind: 'turn.submit', clientReqId: 's-grok', @@ -146,6 +148,7 @@ describe('turn.submit attachment admit and materialize', () => { }); expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); expect(h.adapter.sentInputs).toEqual([]); + expect(list).not.toHaveBeenCalled(); }); it('materializes a declared image to the adapter without putting bytes on the echo or prompt row', async () => { @@ -294,14 +297,15 @@ describe('turn.submit attachment admit and materialize', () => { }); describe('legacy agent.input inline images', () => { + const image = { + type: 'image' as const, + data: PNG_1X1.toString('base64'), + mimeType: 'image/png', + name: 'shot.png', + }; + it('stores the image as a ref on the durable row while the adapter and echo keep it inline', async () => { const h = await started(); - const image = { - type: 'image' as const, - data: PNG_1X1.toString('base64'), - mimeType: 'image/png', - name: 'shot.png', - }; await h.inject({ kind: 'agent.input', clientReqId: 'legacy', @@ -376,4 +380,69 @@ describe('legacy agent.input inline images', () => { if (page?.kind !== 'attachment.read.result') throw new Error('no attachment.read.result'); expect(page.data).toBe(image.data); }); + + it('refuses an image whose bytes are not the declared type before any echo or row', async () => { + const h = await started(); + await h.inject({ + kind: 'agent.input', + clientReqId: 'lie', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [ + { + type: 'image', + mimeType: 'image/png', + data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]).toString('base64'), + }, + ], + }, + }); + expect(failure(h.sent, 'lie')).toMatchObject({ + code: 'invalid_request', + message: 'File contents are not image/png', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); + expect(h.adapter.sentInputs).toEqual([]); + expect(h.sent.some((p) => p.kind === 'agent.event' && p.event.type === 'user-message')).toBe( + false, + ); + }); + + it('fails typed when the store cannot take the bytes and leaves the session usable', async () => { + const h = await started(); + const blobsDir = join(h.stateDir, 'blobs'); + await mkdir(blobsDir, { recursive: true }); + await chmod(blobsDir, 0o500); + try { + await h.inject({ + kind: 'agent.input', + clientReqId: 'ro', + sessionId: h.sessionId, + input: { type: 'prompt', content: [{ type: 'text', text: 'look' }, image] }, + }); + await vi.waitFor(() => { + expect(failure(h.sent, 'ro')).toMatchObject({ + code: 'operation_failed', + message: 'Failed to store a prompt attachment', + }); + }); + } finally { + await chmod(blobsDir, 0o700); + } + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); + expect(h.adapter.sentInputs).toEqual([]); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'after', + sessionId: h.sessionId, + input: { type: 'prompt', content: [{ type: 'text', text: 'still here' }] }, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'request.succeeded', replyTo: 'after' }), + ); + }); + }); }); diff --git a/packages/host/engine/src/attachment/ingest.ts b/packages/host/engine/src/attachment/ingest.ts index d1f90d529..9cbb01140 100644 --- a/packages/host/engine/src/attachment/ingest.ts +++ b/packages/host/engine/src/attachment/ingest.ts @@ -1,10 +1,16 @@ import { Buffer } from 'node:buffer'; import { createHash, randomUUID } from 'node:crypto'; import type { AttachmentId, ContentBlock, PromptBlock } from '@linkcode/schema'; -import { AttachmentIdSchema, blobIdFromSha256, MAX_ATTACHMENT_NAME_LENGTH } from '@linkcode/schema'; +import { + AttachmentIdSchema, + blobIdFromSha256, + declaredMimeTypeMatches, + MAX_ATTACHMENT_NAME_LENGTH, +} from '@linkcode/schema'; import { Effect } from 'effect'; +import { nullthrow } from 'foxts/guard'; import { noop } from 'foxts/noop'; -import { OperationError } from '../failure'; +import { OperationError, RequestError } from '../failure'; import type { AttachmentStore } from './attachment-store'; import type { BlobStore } from './blob-store'; import type { AttachmentIoMutex } from './io-mutex'; @@ -57,15 +63,33 @@ export class AttachmentIngest { /** Durable blocks for legacy prompt content: inline images are stored and referenced, so the * row keeps them after the live echo is gone. Other binary blocks were refused at admit. */ - promptBlocks(content: readonly ContentBlock[]): Effect.Effect { + promptBlocks( + content: readonly ContentBlock[], + ): Effect.Effect { + const images = new Map(); + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if (block.type !== 'image') continue; + const bytes = Buffer.from(block.data, 'base64'); + // Every writer into the store sniffs: a mislabeled record is trusted on every later reference. + if (!declaredMimeTypeMatches(block.mimeType, bytes.subarray(0, 16))) { + return Effect.fail( + new RequestError({ + code: 'invalid_request', + message: `File contents are not ${block.mimeType}`, + }), + ); + } + images.set(i, bytes); + } return Effect.tryPromise({ try: () => Promise.all( - content.map(async (block): Promise => { + content.map(async (block, index): Promise => { if (block.type === 'text') return { type: 'text', text: block.text }; if (block.type !== 'image') return; // The legacy block's name is unbounded; the record's is not. - const attachmentId = await this.store(Buffer.from(block.data, 'base64'), { + const attachmentId = await this.store(nullthrow(images.get(index)), { kind: 'image', name: (block.name || 'image').slice(0, MAX_ATTACHMENT_NAME_LENGTH), mimeType: block.mimeType, diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 1f9027315..ce27da666 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -758,16 +758,22 @@ export class SessionLifecycleService { const ids = uniqueAttachmentIds(occurrences); if (ids.length === 0) return Effect.void; const capability = effectiveAttachmentCapability(kind); - // Bound the ref count before the store load: `admitPromptAttachments` charges per occurrence, - // and an unbounded id list would otherwise reach SQLite as one oversized `IN (...)`. - if (capability !== undefined) { - const maxCount = - (capability.kinds.image?.maxCount ?? 0) + (capability.kinds.file?.maxCount ?? 0); - if (occurrences.length > maxCount) { - return Effect.fail( - new RequestError({ code: 'limit_exceeded', message: 'Too many attachments' }), - ); - } + // Refuse before the store load: the same answers `admitPromptAttachments` gives, without an + // unbounded id list reaching SQLite as one oversized `IN (...)`. + if (capability === undefined) { + return Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }), + ); + } + const maxCount = + (capability.kinds.image?.maxCount ?? 0) + (capability.kinds.file?.maxCount ?? 0); + if (occurrences.length > maxCount) { + return Effect.fail( + new RequestError({ code: 'limit_exceeded', message: 'Too many attachments' }), + ); } return Effect.tryPromise({ try: () => this.attachments.listAttachments(ids), diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index e399fdf4b..1dc26e70c 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -370,6 +370,7 @@ export const en = { attachmentsTotalTooLarge: 'Attachments exceed the 12MB total limit', attachmentLimit: 'You can attach at most {count} images', attachmentUnsupportedType: 'Only JPEG / PNG / GIF / WEBP images are supported', + attachmentContentMismatch: 'File contents are not {type}', attachmentUnsupportedAgent: "This agent doesn't support image attachments yet", attachmentReadFailed: 'Failed to read the file', approvalTitle: 'How should {agent} actions be approved?', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 372033e47..0c742d68a 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -360,6 +360,7 @@ export const zhCN = { attachmentsTotalTooLarge: '附件总大小超过 12MB 上限', attachmentLimit: '最多添加 {count} 个图片附件', attachmentUnsupportedType: '仅支持 JPEG / PNG / GIF / WEBP 图片', + attachmentContentMismatch: '文件内容与 {type} 不符', attachmentUnsupportedAgent: '当前 agent 暂不支持图片附件', attachmentReadFailed: '读取文件失败', approvalTitle: '如何审批 {agent} 的操作?',