From 5bcd90324da63043601fd6f6fbb4a349b17ca14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Wed, 19 Aug 2026 19:45:01 +0800 Subject: [PATCH 1/5] feat(web-shell): unify file upload and reference flow --- docs/design/session-attachment-references.md | 50 + docs/design/session-media-references.md | 46 - docs/design/web-shell-drop-intent-choice.md | 60 ++ .../cli/qwen-serve-routes.test.ts | 2 +- packages/acp-bridge/package.json | 6 +- packages/acp-bridge/src/bridge.test.ts | 518 ++++------- packages/acp-bridge/src/bridge.ts | 275 ++---- packages/acp-bridge/src/bridgeClient.test.ts | 92 +- packages/acp-bridge/src/bridgeClient.ts | 55 +- packages/acp-bridge/src/bridgeOptions.ts | 6 + packages/acp-bridge/src/bridgeTypes.ts | 25 +- packages/acp-bridge/src/index.ts | 2 +- .../acp-bridge/src/sessionAttachments.test.ts | 868 ++++++++++++++++++ packages/acp-bridge/src/sessionAttachments.ts | 623 +++++++++++++ packages/acp-bridge/src/sessionMedia.test.ts | 396 -------- packages/acp-bridge/src/sessionMedia.ts | 353 ------- .../acp-bridge/src/transcript-replay.test.ts | 65 +- packages/acp-bridge/src/transcript-replay.ts | 22 +- .../acp-integration/session/Session.test.ts | 229 ++++- .../src/acp-integration/session/Session.ts | 142 +-- packages/cli/src/serve/capabilities.ts | 7 +- packages/cli/src/serve/routes/session.ts | 140 +-- packages/cli/src/serve/run-qwen-serve.ts | 20 + packages/cli/src/serve/server.test.ts | 328 ++++++- packages/cli/src/serve/server.ts | 4 + .../cli/src/serve/server/error-handlers.ts | 12 +- .../src/serve/server/error-response.test.ts | 4 +- .../cli/src/serve/server/error-response.ts | 6 +- .../src/serve/server/session-archive.test.ts | 48 +- .../cli/src/serve/server/session-archive.ts | 57 +- packages/cli/src/serve/server/telemetry.ts | 12 +- .../src/ui/utils/resumeHistoryUtils.test.ts | 12 +- .../cli/src/ui/utils/resumeHistoryUtils.ts | 20 +- .../src/services/chatRecordingService.test.ts | 16 +- .../core/src/services/chatRecordingService.ts | 16 +- packages/sdk-typescript/scripts/build.js | 6 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 46 +- .../src/daemon/DaemonSessionClient.ts | 121 ++- packages/sdk-typescript/src/daemon/index.ts | 4 +- packages/sdk-typescript/src/daemon/types.ts | 8 +- .../src/daemon/ui/normalizer.ts | 37 +- .../sdk-typescript/src/daemon/ui/store.ts | 8 +- .../sdk-typescript/src/daemon/ui/terminal.ts | 2 + .../src/daemon/ui/transcript.ts | 99 +- .../sdk-typescript/src/daemon/ui/types.ts | 34 +- .../test/unit/DaemonSessionClient.test.ts | 238 ++++- .../unit/daemon-transcript-projection.test.ts | 12 +- .../sdk-typescript/test/unit/daemonUi.test.ts | 95 +- packages/web-shell/client/App.test.tsx | 254 +++++ packages/web-shell/client/App.tsx | 138 ++- .../web-shell/client/adapters/messageTypes.ts | 17 +- .../web-shell/client/adapters/promptTypes.ts | 3 +- .../adapters/transcriptToMessages.test.ts | 38 +- .../client/adapters/transcriptToMessages.ts | 3 + .../client/components/ChatEditor.module.css | 163 +++- .../client/components/ChatEditor.test.tsx | 315 ++++++- .../client/components/ChatEditor.tsx | 360 +++++--- .../client/components/ChatPane.test.tsx | 39 + .../web-shell/client/components/ChatPane.tsx | 61 +- .../client/components/FileTypeIcon.tsx | 86 ++ .../client/components/MessageItem.tsx | 5 + .../client/components/MessageList.tsx | 5 + .../artifacts/ArtifactPanel.module.css | 76 ++ .../artifacts/ArtifactPanel.test.tsx | 227 +++++ .../components/artifacts/ArtifactPanel.tsx | 236 ++++- .../components/artifacts/TurnOutputs.tsx | 11 + .../messages/UserMessage.module.css | 32 +- .../components/messages/UserMessage.test.tsx | 178 ++++ .../components/messages/UserMessage.tsx | 164 +++- packages/web-shell/client/customization.tsx | 8 +- .../client/hooks/useAtMentionMenu.ts | 5 + .../client/hooks/useComposerCore.dom.test.tsx | 72 +- .../web-shell/client/hooks/useComposerCore.ts | 63 +- ...useQueuedPrompts.midTurnReconcile.test.tsx | 69 +- .../client/hooks/useQueuedPrompts.ts | 33 +- packages/web-shell/client/i18n.tsx | 32 +- packages/web-shell/client/utils/base64.ts | 8 + .../client/utils/composerTag.test.ts | 47 + .../web-shell/client/utils/composerTag.ts | 14 + .../client/utils/imageIngestion.test.ts | 94 +- .../web-shell/client/utils/imageIngestion.ts | 230 ++--- .../daemon/midTurnInjectedSidechannel.test.ts | 4 +- .../src/daemon/midTurnInjectedSidechannel.ts | 2 +- .../webui/src/daemon/session/actions.test.ts | 508 +++++++--- packages/webui/src/daemon/session/actions.ts | 372 +++++--- .../src/daemon/session/promptContent.test.ts | 19 + .../webui/src/daemon/session/promptContent.ts | 31 +- packages/webui/src/daemon/session/types.ts | 20 +- 88 files changed, 6681 insertions(+), 2608 deletions(-) create mode 100644 docs/design/session-attachment-references.md delete mode 100644 docs/design/session-media-references.md create mode 100644 docs/design/web-shell-drop-intent-choice.md create mode 100644 packages/acp-bridge/src/sessionAttachments.test.ts create mode 100644 packages/acp-bridge/src/sessionAttachments.ts delete mode 100644 packages/acp-bridge/src/sessionMedia.test.ts delete mode 100644 packages/acp-bridge/src/sessionMedia.ts create mode 100644 packages/web-shell/client/components/FileTypeIcon.tsx create mode 100644 packages/web-shell/client/utils/base64.ts diff --git a/docs/design/session-attachment-references.md b/docs/design/session-attachment-references.md new file mode 100644 index 00000000000..b5c5f9f1afb --- /dev/null +++ b/docs/design/session-attachment-references.md @@ -0,0 +1,50 @@ +# Session attachment references + +## Problem + +Embedding image base64 and file bytes in daemon requests, queues, events, and +replay data duplicates potentially large payloads. Attachments also need to +remain previewable after the daemon restarts. + +## Design + +The daemon writes image and arbitrary file bytes to the workspace runtime's attachment +directory and returns a filename-based reference: + +```ts +{ + type: 'image' | 'resource'; + attachmentId: string; + mimeType: string; + size: number; +} +``` + +The attachment ID is the stored filename. Duplicate names use the platform +convention `name (1).ext`, `name (2).ext`, and so on. There is no in-memory +attachment index or sidecar metadata; MIME type and size are derived from the +stored file when it is read. + +Prompt and mid-turn APIs carry references through queues, events, and +transcript metadata. The bridge resolves them only when dispatching to the ACP +child. The TypeScript session client hydrates the same references for previews +and replay rendering through the authenticated attachment route. +Text resources resolve as ACP text; other file formats resolve as ACP blobs, so +their original bytes are not decoded or altered in the browser. + +## Ownership and lifecycle + +- Storage lives at `~/.qwen/tmp//attachments/session-/` + (or the equivalent custom runtime directory). +- The resolved live-session owner and client authorization protect every + upload, read, and removal operation. +- Closing a daemon or detaching a client closes handles but keeps the files. +- Permanently deleting a session removes its attachment directory. +- No TTL, sweeper, retained-media cache, or restart reconstruction index is + used. +- Each attachment is limited to 8 MiB. Sessions have no cumulative attachment + size or count limit. + +The unified capability is `session_attachments`; the unified HTTP surface is +`/session/:id/attachments`. There is no `session_media`, `/media`, or `mediaId` +compatibility path. diff --git a/docs/design/session-media-references.md b/docs/design/session-media-references.md deleted file mode 100644 index 3f30aa405e2..00000000000 --- a/docs/design/session-media-references.md +++ /dev/null @@ -1,46 +0,0 @@ -# Session media references - -## Problem - -Image prompts currently repeat base64 data in request JSON, pending queues, -SSE events, and the replay ring. Mid-turn batches amplify this because several -images can be emitted in one event. - -## Design - -The daemon stores uploaded image bytes in a session-owned temporary -directory and returns a small media reference: - -```ts -{ - type: 'image'; - mediaId: string; - mimeType: string; - size: number; -} -``` - -Prompt and mid-turn APIs accept these references. Queues, reconciliation -snapshots, SSE events, and persisted user-message metadata retain only -references. Immediately before an ACP prompt or mid-turn drain crosses into the -child, the bridge resolves references to the protocol's inline base64 content -blocks. - -The TypeScript session client hydrates references in replay/live events and -queue snapshots through the authenticated media download route. Existing UI -reducers and renderers therefore continue receiving their current inline image -shape without carrying base64 through the daemon event bus. - -## Ownership and limits - -- Media is scoped to the resolved daemon session and protected by the - existing session client authorization. -- Each object is limited to 8 MiB, each live session to 100 MiB and 256 objects, - and the daemon retains at most 512 MiB across sessions. -- Objects remain available across client detach and reload for up to three - hours; explicit close, kill, and daemon shutdown remove them immediately. -- References from another or unavailable session fail instead of falling back to a - primary runtime. - -Legacy inline media remains accepted and echoed unchanged for older clients. -Media-reference-capable clients avoid placing those bytes in the replay ring. diff --git a/docs/design/web-shell-drop-intent-choice.md b/docs/design/web-shell-drop-intent-choice.md new file mode 100644 index 00000000000..1dfbee4ca1e --- /dev/null +++ b/docs/design/web-shell-drop-intent-choice.md @@ -0,0 +1,60 @@ +# Web Shell dropped-file intent choice + +## Problem + +The composer currently infers intent from file type: image-only drops become +prompt attachments, while ordinary or mixed drops upload to the workspace and +insert `@` references. File type does not express user intent. A user may want +an image persisted in the workspace, or a text file attached only to the next +prompt. + +## Design + +When workspace upload is available, a drop containing one or more files opens +one modal with their names, sizes, and three actions: + +- **Reference content** uses the existing prompt-attachment ingestion path. + The original browser files remain local to the draft. On submit they upload + unchanged to the daemon's session attachment store under the + workspace-scoped Qwen runtime temp directory. Prompt JSON carries + filename-based attachment IDs; the bridge resolves them only at dispatch. +- **Upload to workspace** uses the existing upload queue, configured upload + directory, progress UI, and server-confirmed `@` reference insertion. +- **Cancel** discards the drop. + +Every file type can be referenced. Multi-file drops use the same choice as +single files; the browser does not pre-disable referencing based on the +the files already present in the current draft. + +The browser `File` objects are copied synchronously during the drop event, so +the choice does not depend on a `DataTransfer` after the event returns. The +dialog closes if the composer target changes or upload becomes unavailable. + +When workspace upload is unavailable, drops keep the existing attachment +behavior instead of showing an upload action that cannot succeed. Host-level +`fileUploadEnabled={false}` retains its existing contract and disables all +file drag-in. Clipboard paste and the `@` panel upload item are unchanged. + +File attachment chips are interactive before and after optimistic submission. +Opening one shows the referenced file in the right-side preview panel. +Completed workspace uploads and their file tags open the same panel by reading +the uploaded workspace path. Image attachments retain their existing thumbnail +and image-panel behavior. + +## Storage and compatibility + +The default attachment root is +`~/.qwen/tmp//attachments/`, resolved through +`Storage.getProjectTempDir()` so custom runtime directories continue to work. +Each session owns `session-/`. Files use their stored names +as attachment IDs, with ` (1)` suffixes for duplicates. Daemon shutdown and +client detach keep the directory; permanent session deletion removes it. + +Images and files use the same `session_attachments` capability, +`/attachments` routes, and `attachmentId` references. There is no retained +media cache, TTL cleanup, in-memory filename index, or legacy `mediaId` path. + +## Scope + +Attachment admission limits are enforced by ingestion and the daemon rather +than by the choice dialog. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 1718224378e..24f512ce76b 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -315,7 +315,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_side_task', 'session_prompt', 'session_turn_status', - 'session_media', + 'session_attachments', 'session_mid_turn_message_mutation', 'session_mid_turn_message_query', 'session_cancel', diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 23e2306e9c4..54e8416b8b0 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -55,9 +55,9 @@ "types": "./dist/sessionArtifacts.d.ts", "import": "./dist/sessionArtifacts.js" }, - "./sessionMedia": { - "types": "./dist/sessionMedia.d.ts", - "import": "./dist/sessionMedia.js" + "./sessionAttachments": { + "types": "./dist/sessionAttachments.d.ts", + "import": "./dist/sessionAttachments.js" }, "./daemonEventTypes": { "types": "./dist/daemonEventTypes.d.ts", diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index fe05ae906a2..b8c7022f57b 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -112,10 +112,7 @@ import { SESS_A, } from './internal/testUtils.js'; import { SessionArtifactAuthorizationError } from './sessionArtifacts.js'; -import { - SESSION_MEDIA_MAX_TOTAL_BYTES, - SessionMediaStore, -} from './sessionMedia.js'; +import { SessionAttachmentStore } from './sessionAttachments.js'; import { REQUESTED_SESSION_ID_META_KEY, MID_TURN_QUEUE_DRAIN_METHOD, @@ -13280,7 +13277,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('keeps media references on the event bus and resolves bytes for ACP', async () => { + it('keeps attachment references on the event bus and resolves bytes for ACP', async () => { const prompts: PromptRequest[] = []; const factory: ChannelFactory = async () => makeChannel({ @@ -13291,7 +13288,7 @@ describe('createAcpSessionBridge', () => { }).channel; const bridge = makeBridge({ channelFactory: factory }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.from([1, 2, 3]), 'image/png', @@ -13323,7 +13320,7 @@ describe('createAcpSessionBridge', () => { expect(prompts[0]?.prompt).toEqual([ { type: 'image', data: 'AQID', mimeType: 'image/png' }, ]); - expect(prompts[0]?._meta?.['qwen.daemon.mediaReferences']).toEqual([ + expect(prompts[0]?._meta?.['qwen.daemon.attachmentReferences']).toEqual([ reference, ]); expect(await echoed).toEqual(reference); @@ -13331,6 +13328,68 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('resolves text and binary file attachment references for ACP', async () => { + const prompts: PromptRequest[] = []; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + promptImpl: (request) => { + prompts.push(request); + return { stopReason: 'end_turn' }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const textReference = await bridge.storeSessionAttachment( + session.sessionId, + new TextEncoder().encode('hello'), + 'text/plain', + { clientId: session.clientId }, + 'notes.txt', + ); + const binaryReference = await bridge.storeSessionAttachment( + session.sessionId, + Uint8Array.from([0, 255, 1]), + 'application/pdf', + { clientId: session.clientId }, + 'report.pdf', + ); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [textReference, binaryReference], + }, + undefined, + { clientId: session.clientId }, + ); + + expect(prompts[0]?.prompt).toEqual([ + { + type: 'resource', + resource: { + uri: 'attachment:///notes.txt', + mimeType: 'text/plain', + text: 'hello', + }, + }, + { + type: 'resource', + resource: { + uri: 'attachment:///report.pdf', + mimeType: 'application/pdf', + blob: 'AP8B', + }, + }, + ]); + expect(prompts[0]?._meta?.['qwen.daemon.attachmentReferences']).toEqual([ + textReference, + binaryReference, + ]); + await bridge.shutdown(); + }); + it('keeps inline media bytes in echoes for legacy clients', async () => { const bridge = makeBridge({ channelFactory: async () => @@ -13371,86 +13430,23 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('retains uploaded media across last-client detach and session load', async () => { + it('retains uploaded attachments across last-client detach and session load', async () => { + const root = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-bridge-attachments-'), + ); const bridge = makeBridge({ channelFactory: async () => makeChannel({ loadSessionImpl: () => ({}), }).channel, + sessionAttachmentsRoot: root, }); - const session = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', - }); - const reference = await bridge.storeSessionMedia( - session.sessionId, - Uint8Array.from([1, 2, 3]), - 'image/png', - { clientId: session.clientId }, - ); - - await bridge.detachClient(session.sessionId, session.clientId); - expect(bridge.sessionCount).toBe(0); - - const restored = await bridge.loadSession({ - sessionId: session.sessionId, - workspaceCwd: WS_A, - }); - expect( - await bridge.readSessionMedia(restored.sessionId, reference.mediaId, { - clientId: restored.clientId, - }), - ).toEqual({ data: Buffer.from([1, 2, 3]), mimeType: 'image/png' }); - - await bridge.shutdown(); - }); - - it('retains uploaded media across idle timeout and session load', async () => { - const bridge = makeBridge({ - channelFactory: async () => - makeChannel({ loadSessionImpl: () => ({}) }).channel, - }); - const session = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', - }); - const reference = await bridge.storeSessionMedia( - session.sessionId, - Uint8Array.from([1, 2, 3]), - 'image/png', - { clientId: session.clientId }, - ); - - await bridge.closeSession(session.sessionId, undefined, { - reason: 'idle_timeout', - }); - const restored = await bridge.loadSession({ - sessionId: session.sessionId, - workspaceCwd: WS_A, - }); - - expect( - await bridge.readSessionMedia(restored.sessionId, reference.mediaId, { - clientId: restored.clientId, - }), - ).toEqual({ data: Buffer.from([1, 2, 3]), mimeType: 'image/png' }); - await bridge.shutdown(); - }); - - it('reaps retained media after a detached session exceeds the media TTL', async () => { - vi.useFakeTimers(); try { - const bridge = makeBridge({ - channelFactory: async () => - makeChannel({ loadSessionImpl: () => ({}) }).channel, - sessionReapIntervalMs: 60 * 60_000, - sessionIdleTimeoutMs: 0, - }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A, sessionScope: 'thread', }); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.from([1, 2, 3]), 'image/png', @@ -13458,63 +13454,52 @@ describe('createAcpSessionBridge', () => { ); await bridge.detachClient(session.sessionId, session.clientId); - await vi.advanceTimersByTimeAsync(4 * 60 * 60_000); + expect(bridge.sessionCount).toBe(0); const restored = await bridge.loadSession({ sessionId: session.sessionId, workspaceCwd: WS_A, }); expect( - await bridge.readSessionMedia(restored.sessionId, reference.mediaId, { - clientId: restored.clientId, - }), - ).toBeUndefined(); - - await bridge.shutdown(); + await bridge.readSessionAttachment( + restored.sessionId, + reference.attachmentId, + { + clientId: restored.clientId, + }, + ), + ).toEqual({ data: Buffer.from([1, 2, 3]), mimeType: 'image/png' }); } finally { - vi.useRealTimers(); + await bridge.shutdown(); + await fsp.rm(root, { recursive: true, force: true }); } }); - it('still sweeps retained media when the session reaper is disabled', async () => { - // sessionReapIntervalMs: 0 turns off idle-session reaping, but the - // retained-media TTL sweep must keep running — it is the only runtime - // release path for detach-retained media. - vi.useFakeTimers(); + it('deletes persisted attachments only when session cleanup requests it', async () => { + const root = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-bridge-attachments-delete-'), + ); + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + sessionAttachmentsRoot: root, + }); try { - const bridge = makeBridge({ - channelFactory: async () => - makeChannel({ loadSessionImpl: () => ({}) }).channel, - sessionReapIntervalMs: 0, - sessionIdleTimeoutMs: 0, - }); - const session = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', - }); - const reference = await bridge.storeSessionMedia( + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const reference = await bridge.storeSessionAttachment( session.sessionId, - Uint8Array.from([1, 2, 3]), + Uint8Array.of(1, 2, 3), 'image/png', { clientId: session.clientId }, ); - await bridge.detachClient(session.sessionId, session.clientId); - await vi.advanceTimersByTimeAsync(4 * 60 * 60_000); - - const restored = await bridge.loadSession({ - sessionId: session.sessionId, - workspaceCwd: WS_A, - }); - expect( - await bridge.readSessionMedia(restored.sessionId, reference.mediaId, { - clientId: restored.clientId, - }), - ).toBeUndefined(); + await bridge.deleteSessionAttachments(session.sessionId); - await bridge.shutdown(); + const reopened = new SessionAttachmentStore(root, session.sessionId); + expect(await reopened.read(reference.attachmentId)).toBeUndefined(); + await reopened.close(); } finally { - vi.useRealTimers(); + await bridge.shutdown(); + await fsp.rm(root, { recursive: true, force: true }); } }); @@ -13525,7 +13510,7 @@ describe('createAcpSessionBridge', () => { // degrading to the crash-path detach retention. (A structured // RequestError answer is the separate definitive-refusal case, where // kill spares the channel and retries later.) - const close = vi.spyOn(SessionMediaStore.prototype, 'close'); + const close = vi.spyOn(SessionAttachmentStore.prototype, 'close'); const handle = makeChannel({ extMethodImpl: (method) => { if (method === SERVE_CONTROL_EXT_METHODS.sessionClose) { @@ -13540,7 +13525,7 @@ describe('createAcpSessionBridge', () => { }); try { const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - await bridge.storeSessionMedia( + await bridge.storeSessionAttachment( session.sessionId, Uint8Array.from([1, 2, 3]), 'image/png', @@ -14711,6 +14696,9 @@ describe('createAcpSessionBridge', () => { }); it('creates a persisted branch even when live session capacity is full', async () => { + const attachmentRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-branch-attachments-'), + ); const handle = makeChannel({ extMethodImpl: async (method) => { if (method !== SERVE_CONTROL_EXT_METHODS.sessionBranch) return {}; @@ -14721,8 +14709,16 @@ describe('createAcpSessionBridge', () => { const bridge = makeBridge({ channelFactory: async () => handle.channel, maxSessions: 1, + sessionAttachmentsRoot: attachmentRoot, }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const attachment = await bridge.storeSessionAttachment( + session.sessionId, + Uint8Array.of(1, 2, 3), + 'application/json', + { clientId: session.clientId }, + 'notes.json', + ); const branch = await bridge.branchSession(session.sessionId, { name: 'Branch 1', @@ -14739,7 +14735,19 @@ describe('createAcpSessionBridge', () => { }); expect(handle.agent.loadSessionCalls).toEqual([]); + const branchAttachments = new SessionAttachmentStore( + attachmentRoot, + branch.sessionId, + ); + await expect( + branchAttachments.read(attachment.attachmentId), + ).resolves.toEqual({ + data: Buffer.from([1, 2, 3]), + mimeType: 'application/json', + }); + await bridge.shutdown(); + await fsp.rm(attachmentRoot, { recursive: true, force: true }); }); it('restores a latest-state branch for v1 callers', async () => { @@ -15393,7 +15401,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('removes uploaded media owned by a deleted queued prompt', async () => { + it('keeps uploaded attachments after deleting a queued prompt', async () => { let release!: () => void; const gate = new Promise((resolve) => { release = resolve; @@ -15419,7 +15427,7 @@ describe('createAcpSessionBridge', () => { undefined, { promptId: 'media-delete-blocker' }, ); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1, 2, 3), 'image/png', @@ -15442,10 +15450,14 @@ describe('createAcpSessionBridge', () => { }), ).toEqual({ removed: true }); expect( - await bridge.readSessionMedia(session.sessionId, reference.mediaId, { - clientId: session.clientId, - }), - ).toBeUndefined(); + await bridge.readSessionAttachment( + session.sessionId, + reference.attachmentId, + { + clientId: session.clientId, + }, + ), + ).toEqual({ data: Buffer.from([1, 2, 3]), mimeType: 'image/png' }); release(); await running; @@ -19661,100 +19673,6 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('reaps retained media after an unexpectedly closed session expires', async () => { - vi.useFakeTimers(); - const close = vi.spyOn(SessionMediaStore.prototype, 'close'); - const handle = makeChannel(); - const bridge = makeBridge({ - channelFactory: async () => handle.channel, - sessionReapIntervalMs: 1_000, - sessionIdleTimeoutMs: 0, - }); - try { - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - await bridge.storeSessionMedia( - session.sessionId, - Uint8Array.of(1), - 'image/png', - { clientId: session.clientId }, - ); - - handle.crash(); - await vi.waitFor(() => expect(bridge.sessionCount).toBe(0)); - await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1_000); - expect(close).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(60 * 60 * 1_000 + 1_000); - - expect(close).toHaveBeenCalledOnce(); - } finally { - await bridge.shutdown(); - close.mockRestore(); - vi.useRealTimers(); - } - }); - - it('does not reap retained media while a restore of the session is in flight', async () => { - // A restore registers inFlightRestores synchronously but lands in byId - // only after the async channel-spawn/loadSession gap. A sweep tick - // inside that gap must not release the media being restored, or the - // restored session non-deterministically loses every attachment. - vi.useFakeTimers(); - const handles: ChannelHandle[] = []; - const load = deferred(); - const factory: ChannelFactory = async () => { - const h = makeChannel({ loadSessionImpl: () => load.promise }); - handles.push(h); - return h.channel; - }; - const bridge = makeBridge({ - channelFactory: factory, - sessionReapIntervalMs: 1_000, - sessionIdleTimeoutMs: 0, - sessionRestoreTimeoutMs: 4 * 60 * 60 * 1_000, - }); - try { - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const reference = await bridge.storeSessionMedia( - session.sessionId, - Uint8Array.of(1), - 'image/png', - { clientId: session.clientId }, - ); - - // Crash path: entry removed from byId, media retained with a - // detachedAt stamp. - handles[0]!.crash(); - await vi.waitFor(() => expect(bridge.sessionCount).toBe(0)); - - // Start the restore and hold it open inside the loadSession gap. - const restore = bridge.loadSession({ - sessionId: session.sessionId, - workspaceCwd: WS_A, - }); - await vi.waitFor(() => { - expect(handles).toHaveLength(2); - expect(handles[1]!.agent.loadSessionCalls).toHaveLength(1); - }); - - // Sweep ticks straddling the TTL boundary land inside the gap. - await vi.advanceTimersByTimeAsync(3 * 60 * 60 * 1_000 + 61_000); - - load.resolve({}); - await restore; - - // The retained media survived the restore. - const media = await bridge.readSessionMedia( - session.sessionId, - reference.mediaId, - ); - expect(media).toBeDefined(); - expect([...(media?.data ?? [])]).toEqual([1]); - } finally { - await bridge.shutdown(); - vi.useRealTimers(); - } - }); - it('exit fired on planned shutdown does NOT trigger the unexpected-cleanup path', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { @@ -25952,21 +25870,21 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('continues closing the session when media cleanup fails', async () => { + it('continues closing the session when attachment close fails', async () => { const handle = makeChannel(); const bridge = makeBridge({ channelFactory: async () => handle.channel, channelIdleTimeoutMs: 0, }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - await bridge.storeSessionMedia( + await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1), 'image/png', { clientId: session.clientId }, ); const closeMedia = vi - .spyOn(SessionMediaStore.prototype, 'close') + .spyOn(SessionAttachmentStore.prototype, 'close') .mockRejectedValueOnce(new Error('cleanup failed')); const stderr = vi .spyOn(process.stderr, 'write') @@ -25982,7 +25900,7 @@ describe('createAcpSessionBridge', () => { { sessionId: session.sessionId }, ]); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('failed to release media for closed session'), + expect.stringContaining('failed to close attachments for session'), ); } finally { closeMedia.mockRestore(); @@ -29774,7 +29692,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await bridge.shutdown(); }); - it('removes uploaded media owned by a deleted mid-turn message', async () => { + it('keeps uploaded attachments after deleting a mid-turn message', async () => { const { factory, release } = hangingPromptFactory(); const bridge = makeBridge({ channelFactory: factory }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); @@ -29787,7 +29705,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa undefined, { clientId: session.clientId }, ); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1, 2, 3), 'image/png', @@ -29809,10 +29727,14 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa }), ).toEqual({ removed: true }); expect( - await bridge.readSessionMedia(session.sessionId, reference.mediaId, { - clientId: session.clientId, - }), - ).toBeUndefined(); + await bridge.readSessionAttachment( + session.sessionId, + reference.attachmentId, + { + clientId: session.clientId, + }, + ), + ).toEqual({ data: Buffer.from([1, 2, 3]), mimeType: 'image/png' }); release(); await prompt; @@ -29835,7 +29757,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa ) .catch(() => {}); await new Promise((r) => setTimeout(r, 10)); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1, 2, 3), 'image/png', @@ -29857,7 +29779,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa ).toEqual({ removed: true }); // The removal settled the id; a same-id retry must hit the settled ring - // and ack, not throw session_media_gone (410). + // and ack, not throw session_attachments_gone (410). expect( bridge.enqueueMidTurnMessage( session.sessionId, @@ -30345,7 +30267,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa { clientId: session.clientId }, ); await vi.waitFor(() => expect(prompts).toHaveLength(1)); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1), 'image/png', @@ -30364,9 +30286,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa { clientId: session.clientId }, 'normal-message', ); - await bridge.removeSessionMedia(session.sessionId, reference.mediaId, { - clientId: session.clientId, - }); + await bridge.removeSessionAttachment( + session.sessionId, + reference.attachmentId, + { + clientId: session.clientId, + }, + ); releases[0]!(); await first; @@ -30374,7 +30300,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa expect(prompts[1]).toEqual([ { type: 'text', - text: 'expired attachment\n[Attached media is no longer available]', + text: 'expired attachment\n[Attachment is no longer available]', }, ]); releases[1]!(); @@ -30409,13 +30335,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa { clientId: session.clientId }, ); await vi.waitFor(() => expect(prompts).toHaveLength(1)); - const kept = await bridge.storeSessionMedia( + const kept = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1, 2), 'image/png', { clientId: session.clientId }, ); - const removed = await bridge.storeSessionMedia( + const removed = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(3, 4), 'image/png', @@ -30428,9 +30354,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa 'mixed-media', { content: [removed, kept] }, ); - await bridge.removeSessionMedia(session.sessionId, removed.mediaId, { - clientId: session.clientId, - }); + await bridge.removeSessionAttachment( + session.sessionId, + removed.attachmentId, + { + clientId: session.clientId, + }, + ); releases[0]!(); await first; @@ -30438,7 +30368,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa expect(prompts[1]).toEqual([ { type: 'text', - text: 'two images\n[Attached media is no longer available]', + text: 'two images\n[Attachment is no longer available]', }, { type: 'image', data: 'AQI=', mimeType: 'image/png' }, ]); @@ -30468,13 +30398,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa { clientId: session.clientId, promptId: 'prompt-p1' }, ); await vi.waitFor(() => expect(prompts).toHaveLength(1)); - const kept = await bridge.storeSessionMedia( + const kept = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1, 2), 'image/png', { clientId: session.clientId }, ); - const removed = await bridge.storeSessionMedia( + const removed = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(3, 4), 'image/png', @@ -30503,9 +30433,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa // m1 promoted to running; m2 admitted queued behind it. await vi.waitFor(() => expect(prompts).toHaveLength(2)); - await bridge.removeSessionMedia(session.sessionId, removed.mediaId, { - clientId: session.clientId, - }); + await bridge.removeSessionAttachment( + session.sessionId, + removed.attachmentId, + { + clientId: session.clientId, + }, + ); releases[1]!(); await vi.waitFor(() => expect(prompts).toHaveLength(3)); @@ -30514,7 +30448,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa expect(prompts[2]).toEqual([ { type: 'text', - text: 'm2\n[Attached media is no longer available]', + text: 'm2\n[Attachment is no longer available]', }, { type: 'image', data: 'AQI=', mimeType: 'image/png' }, ]); @@ -30525,75 +30459,6 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await bridge.shutdown(); }); - it('rejects queued inline media past the session byte budget', async () => { - // Inline base64 never passes through the media store, so admission must - // budget the aggregate queued inline bytes per session instead of - // letting a full queue pin hundreds of MiB. - const releases: Array<() => void> = []; - const handle = makeChannel({ - promptImpl: async () => { - await new Promise((resolve) => releases.push(resolve)); - return { stopReason: 'end_turn' }; - }, - }); - const bridge = makeBridge({ channelFactory: async () => handle.channel }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const busy = bridge - .sendPrompt( - session.sessionId, - { - sessionId: session.sessionId, - prompt: [{ type: 'text', text: 't1' }], - }, - undefined, - { clientId: session.clientId }, - ) - .catch(() => {}); - await new Promise((r) => setTimeout(r, 10)); - - const half = 'x'.repeat(SESSION_MEDIA_MAX_TOTAL_BYTES / 2 + 1024); - const inline = { - type: 'image', - data: half, - mimeType: 'image/png', - } as const; - expect( - bridge.enqueueMidTurnMessage( - session.sessionId, - 'first half', - { clientId: session.clientId }, - 'inline-1', - { content: [inline] }, - ), - ).toEqual({ accepted: true, messageId: 'inline-1' }); - // The second payload pushes the queued inline total past the budget. - expect( - bridge.enqueueMidTurnMessage( - session.sessionId, - 'second half', - { clientId: session.clientId }, - 'inline-2', - { content: [inline] }, - ), - ).toEqual({ accepted: false }); - // A small inline payload stays admissible under the budget. - expect( - bridge.enqueueMidTurnMessage( - session.sessionId, - 'small', - { clientId: session.clientId }, - 'inline-3', - { - content: [{ type: 'image', data: 'aW1n', mimeType: 'image/png' }], - }, - ), - ).toEqual({ accepted: true, messageId: 'inline-3' }); - - releases[0]!(); - await busy; - await bridge.shutdown(); - }); - it('degrades media removed between admission and dispatch in place, keeping FIFO order and one terminal', async () => { const prompts: unknown[] = []; const releases: Array<() => void> = []; @@ -30621,7 +30486,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa { clientId: session.clientId, promptId: 'prompt-p1' }, ); await vi.waitFor(() => expect(prompts).toHaveLength(1)); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1), 'image/png', @@ -30652,9 +30517,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await first; await vi.waitFor(() => expect(prompts).toHaveLength(2)); - await bridge.removeSessionMedia(session.sessionId, reference.mediaId, { - clientId: session.clientId, - }); + await bridge.removeSessionAttachment( + session.sessionId, + reference.attachmentId, + { + clientId: session.clientId, + }, + ); // An ordinary prompt admitted after the media message must stay behind it. const p3 = bridge.sendPrompt( session.sessionId, @@ -30670,7 +30539,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa expect(prompts[2]).toEqual([ { type: 'text', - text: 'm2\n[Attached media is no longer available]', + text: 'm2\n[Attachment is no longer available]', }, ]); releases[2]!(); @@ -30726,7 +30595,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa { clientId: session.clientId, promptId: 'prompt-p1' }, ); await vi.waitFor(() => expect(prompts).toHaveLength(1)); - const reference = await bridge.storeSessionMedia( + const reference = await bridge.storeSessionAttachment( session.sessionId, Uint8Array.of(1), 'image/png', @@ -30759,7 +30628,10 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await vi.waitFor(() => expect(prompts).toHaveLength(2)); // Media disappears between admission and dispatch (async arm). - await bridge.removeSessionMedia(session.sessionId, reference.mediaId); + await bridge.removeSessionAttachment( + session.sessionId, + reference.attachmentId, + ); releases[1]!(); // The degraded prompt must still reach the child — a re-admitted @@ -30769,7 +30641,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa expect(prompts[2]).toEqual([ { type: 'text', - text: 'm2\n[Attached media is no longer available]', + text: 'm2\n[Attachment is no longer available]', }, ]); releases[2]!(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 6e08e5cba5f..b4f2c8e3a7d 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -140,7 +140,7 @@ import { CHANNEL_STARTUP_PROFILE_META_KEY, CHANNEL_STARTUP_PROFILE_VERSION, DAEMON_CHANNEL_DELIVERY_META_KEY, - DAEMON_MEDIA_REFERENCES_META_KEY, + DAEMON_ATTACHMENT_REFERENCES_META_KEY, DAEMON_MODEL_PROMPT_META_KEY, DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, LOAD_REPLAY_BULK_MODE, @@ -195,12 +195,11 @@ import type { RuntimeMcpServerRemoveResult, } from './bridgeTypes.js'; import { - isSessionMediaReference, - SESSION_MEDIA_MAX_TOTAL_BYTES, - SessionMediaReferenceError, - SessionMediaStore, - withMediaDegradationMarker, -} from './sessionMedia.js'; + isSessionAttachmentReference, + SessionAttachmentReferenceError, + SessionAttachmentStore, + withAttachmentDegradationMarker, +} from './sessionAttachments.js'; import type { BridgeFreshSessionAdmissionContext, BridgeFreshSessionReservation, @@ -964,8 +963,8 @@ interface SessionEntry { events: EventBus; /** Per-session structured artifact registry. */ artifacts: SessionArtifactStore; - /** Session-owned temporary media referenced by prompts and SSE events. */ - media: SessionMediaStore; + /** Session-owned temporary attachment referenced by prompts and SSE events. */ + attachments: SessionAttachmentStore; /** Sticky in-memory health state for the session's transcript recorder. */ recordingDegraded: boolean; /** Set synchronously while agent-owned state and its writer lease close. */ @@ -2300,7 +2299,7 @@ function latestTerminalTurnStatus( /** * Extract inline media content blocks from a prompt for storage in the - * pending-prompt queue. Image references use the session media store; legacy + * pending-prompt queue. Image references use the session attachment store; legacy * audio blocks remain inline. This lets refreshed clients restore the payload. */ function extractMediaBlocks( @@ -2358,21 +2357,6 @@ const MAX_SHELL_OUTPUT_FOR_HISTORY = 10_000; // a `BridgeOptions` knob the same way `maxPendingPromptsPerSession` (the // analogous bound `/prompt` enforces, default 5) is wired. const MAX_MID_TURN_QUEUE_DEPTH = 20; -// Inline base64 blocks never pass through the media store, so the store's -// caps never see them: bound the aggregate queued inline bytes instead, or a -// full queue of body-limit-sized messages pins hundreds of MiB per session -// and re-embeds them into every reconciliation snapshot. -const inlineMediaBlockBytes = ( - blocks: readonly BridgePromptContentBlock[], -): number => { - let total = 0; - for (const block of blocks) { - if (block.type === 'image' && 'data' in block) { - total += block.data.length; - } - } - return total; -}; const DEFAULT_MAX_SESSIONS = 32; // Keep in sync with CLI serve/server.ts and SDK DaemonClient.ts. const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5; @@ -2405,8 +2389,6 @@ const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_MAX_PENDING_PER_SESSION = 64; const DEFAULT_SESSION_REAP_INTERVAL_MS = 60_000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000; -const MAX_RETAINED_SESSION_MEDIA_BYTES = 512 * 1024 * 1024; -const RETAINED_SESSION_MEDIA_TTL_MS = 3 * 60 * 60_000; export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { let liveScreenContextCaptureHandler: @@ -2711,7 +2693,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { DEFAULT_SESSION_IDLE_TIMEOUT_MS, ); let sessionReaper: ReturnType | undefined; - let mediaSweeper: ReturnType | undefined; // Tracks the most recent "activity" event for idle-detection by // external schedulers. Updated on prompt start/end and session @@ -3337,46 +3318,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } } - function sweepRetainedMedia(): void { - const now = Date.now(); - for (const [id] of retainedMedia) { - // A restore registers `inFlightRestores` synchronously but lands in - // `byId` only after its async channel-spawn/loadSession gap; sweeping - // inside that gap would delete the media mid-restore. - if (byId.has(id) || inFlightRestores.has(id)) continue; - const detachedAt = retainedMediaDetachedAt.get(id); - if ( - detachedAt === undefined || - now - detachedAt < RETAINED_SESSION_MEDIA_TTL_MS - ) { - continue; - } - void releaseSessionMedia(id).catch((error) => { - writeStderrLine( - `qwen serve: failed to release retained media for ${JSON.stringify(id)}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); - } - } - function startSessionReaper(): void { - if (sessionReapIntervalMs <= 0) { - // Idle-session reaping is off, but the retained-media TTL sweep must - // still run: it is the only runtime release path for detach-retained - // media, and detach-driven auto-close keeps producing it. - writeStderrLine( - 'qwen serve: session reaper disabled; retained media sweep runs ' + - `every ${DEFAULT_SESSION_REAP_INTERVAL_MS}ms`, - ); - mediaSweeper = setInterval(() => { - if (shuttingDown) return; - sweepRetainedMedia(); - }, DEFAULT_SESSION_REAP_INTERVAL_MS); - mediaSweeper.unref(); - return; - } + if (sessionReapIntervalMs <= 0) return; writeStderrLine( `qwen serve: session reaper started ` + `(interval ${sessionReapIntervalMs}ms, ` + @@ -3414,7 +3357,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { closeReason: 'idle_timeout', }); } - sweepRetainedMedia(); }, sessionReapIntervalMs); sessionReaper.unref(); } @@ -3424,10 +3366,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { clearInterval(sessionReaper); sessionReaper = undefined; } - if (mediaSweeper !== undefined) { - clearInterval(mediaSweeper); - mediaSweeper = undefined; - } } // BkUyD: superset of `channelInfo` covering channels @@ -3450,23 +3388,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); - const retainedMedia = new Map(); - const retainedMediaDetachedAt = new Map(); - let mediaPutQueue: Promise = Promise.resolve(); - const retainSessionMedia = ( - sessionId: string, - store = retainedMedia.get(sessionId) ?? new SessionMediaStore(), - ): SessionMediaStore => { - retainedMedia.set(sessionId, store); - return store; - }; - const releaseSessionMedia = async (sessionId: string): Promise => { - const store = retainedMedia.get(sessionId); - if (!store) return; - retainedMedia.delete(sessionId); - retainedMediaDetachedAt.delete(sessionId); - await store.close(); - }; const forwardRunningPromptCancel = async ( entry: SessionEntry, pending: PendingPromptEntry, @@ -4209,9 +4130,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { touchActivity(); } byId.delete(sid); - if (retainedMedia.has(sid)) { - retainedMediaDetachedAt.set(sid, Date.now()); - } + void sessEntry.attachments.close().catch((error) => { + writeStderrLine( + `qwen serve: failed to close attachments for closed channel session ${JSON.stringify(sid)}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); telemetry.metrics?.sessionLifecycle('die'); emitSessionLifecycle({ type: 'removed', @@ -5915,7 +5838,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { workspaceCwd, persistence: createSessionArtifactPersistence(ci.connection, sessionId), }), - media: retainSessionMedia(sessionId), + attachments: new SessionAttachmentStore( + opts.sessionAttachmentsRoot, + sessionId, + ), recordingDegraded: false, closing: false, cwdChangeQueue: Promise.resolve(), @@ -7301,9 +7227,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const restoreEntry = byId.get(req.sessionId); if (restoreEntry?.events === restoreEvents) { byId.delete(req.sessionId); - if (retainedMedia.has(req.sessionId)) { - retainedMediaDetachedAt.set(req.sessionId, Date.now()); - } + await restoreEntry.attachments.close().catch((error) => { + writeStderrLine( + `qwen serve: failed to close attachments after restoring session ${JSON.stringify(req.sessionId)}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); ci?.sessionIds.delete(req.sessionId); emitSessionLifecycle({ type: 'removed', @@ -7518,15 +7446,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // `session_closed` is terminal. Close the bus before ACP cancel so any // late cancellation frames from the agent are intentionally dropped. entry.events.close(); - if (reason === 'last_client_detached' || reason === 'idle_timeout') { - retainedMediaDetachedAt.set(sessionId, Date.now()); - } else { - await releaseSessionMedia(sessionId).catch((error) => { - writeStderrLine( - `qwen serve: failed to release media for closed session ${JSON.stringify(sessionId)}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } + await entry.attachments.close().catch((error) => { + writeStderrLine( + `qwen serve: failed to close attachments for session ${JSON.stringify(sessionId)}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); if (!agentSessionClosed) { try { await telemetry.withSpan( @@ -7559,20 +7483,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }; - const removeQueuedMedia = ( - entry: SessionEntry, - content: readonly BridgePromptContentBlock[] | undefined, - ) => { - for (const block of content ?? []) { - if (!isSessionMediaReference(block)) continue; - void entry.media.remove(block.mediaId).catch((error) => { - writeStderrLine( - `[session-media] session=${JSON.stringify(entry.sessionId)} failed to remove queued media ${JSON.stringify(block.mediaId)}: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`, - ); - }); - } - }; - const promoteMidTurnMessage = ( entry: SessionEntry, messageId: string, @@ -7588,10 +7498,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { let degraded = 0; for (const block of content ?? []) { try { - entry.media.assertReference(block); + entry.attachments.assertReference(block); resolvableBlocks.push(block); } catch (error) { - if (!(error instanceof SessionMediaReferenceError)) throw error; + if (!(error instanceof SessionAttachmentReferenceError)) throw error; degraded += 1; } } @@ -7600,7 +7510,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...resolvableBlocks, ]; if (degraded > 0) { - prompt = withMediaDegradationMarker(prompt); + prompt = withAttachmentDegradationMarker(prompt); } const context = { promptId: messageId, @@ -7614,7 +7524,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry.sessionId, { sessionId: entry.sessionId, - prompt: withMediaDegradationMarker( + prompt: withAttachmentDegradationMarker( text ? [{ type: 'text', text } as ContentBlock] : [], ), }, @@ -7634,7 +7544,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } catch (error) { try { - if (!(error instanceof SessionMediaReferenceError)) throw error; + if (!(error instanceof SessionAttachmentReferenceError)) throw error; result = sendFallback(); } catch (fallbackError) { writeStderrLine( @@ -7643,7 +7553,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return; } } - // SessionMediaReferenceError can no longer reject this result + // SessionAttachmentReferenceError can no longer reject this result // asynchronously: admission-time reference checks throw synchronously // (handled above) and dispatch degrades in place. void result.catch((error: unknown) => { @@ -8196,7 +8106,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const originatorClientId = promotedMidTurn ? promotedMidTurn.originatorClientId : resolveTrustedClientId(entry, context?.clientId); - entry.media.assertReferences(req.prompt); + entry.attachments.assertReferences(req.prompt); const modelPrompt = context?.modelPrompt; if ( modelPrompt !== undefined && @@ -8455,11 +8365,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { let resolvedPrompt: ContentBlock[]; try { resolvedPrompt = - await entry.media.resolveContent(dispatchBlocks); + await entry.attachments.resolveContent(dispatchBlocks); } catch (error) { if ( !isPromotedMidTurn || - !(error instanceof SessionMediaReferenceError) + !(error instanceof SessionAttachmentReferenceError) ) { throw error; } @@ -8467,11 +8377,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // keeps its resolvable siblings instead of replacing the // whole prompt with the marker. const perBlock = - await entry.media.resolveContentDegrading(dispatchBlocks); + await entry.attachments.resolveContentDegrading( + dispatchBlocks, + ); dispatchBlocks = perBlock.retainedBlocks; // The batch resolve threw on a dead reference, so the // marker always applies here. - resolvedPrompt = withMediaDegradationMarker( + resolvedPrompt = withAttachmentDegradationMarker( perBlock.resolvedBlocks, ); } @@ -8515,7 +8427,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY]; delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; delete meta[DAEMON_MODEL_PROMPT_META_KEY]; - delete meta[DAEMON_MEDIA_REFERENCES_META_KEY]; + delete meta[DAEMON_ATTACHMENT_REFERENCES_META_KEY]; // Channel classification is authenticated channel-worker // metadata; the daemon prompt route validates the worker // authorization and re-arms it through the trusted @@ -8538,11 +8450,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (modelPrompt !== undefined) { meta[DAEMON_MODEL_PROMPT_META_KEY] = modelPrompt; } - const mediaReferences = dispatchBlocks.filter( - isSessionMediaReference, + const attachmentReferences = dispatchBlocks.filter( + isSessionAttachmentReference, ); - if (mediaReferences.length > 0) { - meta[DAEMON_MEDIA_REFERENCES_META_KEY] = mediaReferences; + if (attachmentReferences.length > 0) { + meta[DAEMON_ATTACHMENT_REFERENCES_META_KEY] = + attachmentReferences; } if (context?.channelPrompt === true) { meta[CHANNEL_PROMPT_META_KEY] = true; @@ -9267,6 +9180,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // before any restore attempt so a committed branch is visible to // catalog-version watchers even when the restore later fails. markSessionCatalogChanged(); + if (opts.sessionAttachmentsRoot) { + const branchAttachments = new SessionAttachmentStore( + opts.sessionAttachmentsRoot, + result.newSessionId, + ); + await branchAttachments.copyFrom(entry.attachments); + await branchAttachments.close(); + } const rawBranchName = result.displayName ?? result.title; const branchDisplayName = typeof rawBranchName === 'string' @@ -9342,6 +9263,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } const newEntry = byId.get(result.newSessionId); + if (newEntry && !opts.sessionAttachmentsRoot) { + await newEntry.attachments.copyFrom(entry.attachments); + } if (newEntry) newEntry.displayName = branchDisplayName; let sourcePersisted: boolean | undefined; if (newEntry?.sourceType) { @@ -10792,47 +10716,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { sessionId, state: 'idle' as const }; }, - async storeSessionMedia(sessionId, data, mimeType, context) { + async storeSessionAttachment(sessionId, data, mimeType, context, name) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); resolveTrustedClientId(entry, context?.clientId); - const operation = mediaPutQueue.then(async () => { - if (byId.get(sessionId) !== entry) { - throw new SessionNotFoundError(sessionId); - } - const retainedBytes = [...new Set(retainedMedia.values())].reduce( - (total, store) => total + store.sizeBytes, - 0, - ); - if ( - retainedBytes + data.byteLength > - MAX_RETAINED_SESSION_MEDIA_BYTES - ) { - throw new RangeError( - `Session media exceeds the ${MAX_RETAINED_SESSION_MEDIA_BYTES}-byte daemon limit`, - ); - } - return await entry.media.put(data, mimeType); - }); - mediaPutQueue = operation.then( - () => undefined, - () => undefined, - ); - return await operation; + return await entry.attachments.putAttachment(data, mimeType, name); }, - async readSessionMedia(sessionId, mediaId, context) { + async readSessionAttachment(sessionId, attachmentId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); resolveTrustedClientId(entry, context?.clientId); - return await entry.media.read(mediaId); + return await entry.attachments.read(attachmentId); }, - async removeSessionMedia(sessionId, mediaId, context) { + async removeSessionAttachment(sessionId, attachmentId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); resolveTrustedClientId(entry, context?.clientId); - return await entry.media.remove(mediaId); + return await entry.attachments.remove(attachmentId); + }, + + async deleteSessionAttachments(sessionId) { + const store = + byId.get(sessionId)?.attachments ?? + new SessionAttachmentStore(opts.sessionAttachmentsRoot, sessionId); + await store.delete(); }, removePendingPrompt(sessionId, promptId, context) { @@ -10861,7 +10770,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // A queued prompt never dispatches once aborted — safe to drop // from the list immediately. entry.pendingPromptList.splice(idx, 1); - removeQueuedMedia(entry, target.content); } else { // A RUNNING prompt must stay on the list (hidden from // `getPendingPrompts` via the `removed` flag) until it settles @@ -10990,22 +10898,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // Validate only genuinely new admissions, AFTER the retry-ack rings: // a same-id retry whose media was already removed (delete racing an // in-flight POST, or a refresh re-enqueueing from the snapshot) must - // settle idempotently instead of failing with session_media_gone. - entry.media.assertReferences(mediaBlocks); - const inlineBytes = inlineMediaBlockBytes(mediaBlocks); - if (inlineBytes > 0) { - const queuedInlineBytes = entry.midTurnMessageQueue.reduce( - (total, queued) => - total + inlineMediaBlockBytes(queued.content ?? []), - 0, - ); - if (queuedInlineBytes + inlineBytes > SESSION_MEDIA_MAX_TOTAL_BYTES) { - writeStderrLine( - `[mid-turn] session=${entry.sessionId} rejected: queued inline media exceeds the ${SESSION_MEDIA_MAX_TOTAL_BYTES}-byte session budget`, - ); - return { accepted: false }; - } - } + // settle idempotently instead of failing with session_attachment_gone. + entry.attachments.assertReferences(mediaBlocks); const messageId = requestedMessageId ?? randomUUID(); // If the turn settled while the POST was in flight, start it through the // normal prompt path. A client-supplied id keeps retries idempotent. @@ -11106,7 +11000,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const [removed] = entry.midTurnMessageQueue.splice(index, 1); rememberMidTurnId(entry.settledMidTurnMessageIds, messageId); if (removed) { - removeQueuedMedia(entry, removed.content); try { entry.events.publish({ type: 'pending_prompt_completed', @@ -12034,9 +11927,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { closingChannel, `force kill closing session ${JSON.stringify(sessionId)}`, ); - await releaseSessionMedia(sessionId).catch((releaseError) => { + await entry.attachments.close().catch((releaseError) => { writeStderrLine( - `qwen serve: failed to release media for killed session ${JSON.stringify(sessionId)}: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, + `qwen serve: failed to close attachments for killed session ${JSON.stringify(sessionId)}: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, ); }); return true; @@ -12075,15 +11968,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci, `force kill session ${JSON.stringify(sessionId)}`, ); - // Kill removes media immediately (design: "explicit close, kill, and - // daemon shutdown remove them immediately"); without this the - // channel-exit handler's crash-path retention would keep it for the - // detach TTL. - await releaseSessionMedia(sessionId).catch((releaseError) => { - writeStderrLine( - `qwen serve: failed to release media for killed session ${JSON.stringify(sessionId)}: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, - ); - }); return true; } entry.closing = false; @@ -12146,9 +12030,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { /* bus already closed */ } entry.events.close(); - await releaseSessionMedia(sessionId).catch((error) => { + await entry.attachments.close().catch((error) => { writeStderrLine( - `qwen serve: failed to release media for killed session ${JSON.stringify(sessionId)}: ${error instanceof Error ? error.message : String(error)}`, + `qwen serve: failed to close attachments for killed session ${JSON.stringify(sessionId)}: ${error instanceof Error ? error.message : String(error)}`, ); }); // Only kill the channel when no other sessions remain AND no @@ -12347,8 +12231,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : Promise.resolve(); const teardownResults = await Promise.allSettled([ ...channels.map((ci) => ci.channel.kill()), - ...[...retainedMedia.values()].map((store) => store.close()), - mediaPutQueue, + ...[...byId.values()].map((entry) => entry.attachments.close()), ...inFlightSessionAwaits, ...inFlightRestoreAwaits, inFlightChannelAwait, diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index 453eb6a59b3..dc4f252a972 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -71,9 +71,9 @@ import { CancelSentinelCollisionError } from './bridgeErrors.js'; import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js'; import { SessionArtifactStore } from './sessionArtifacts.js'; import { - SESSION_MEDIA_MAX_ITEM_BYTES, - SessionMediaStore, -} from './sessionMedia.js'; + SESSION_ATTACHMENT_MAX_ITEM_BYTES, + SessionAttachmentStore, +} from './sessionAttachments.js'; /** * Minimal-stub constructor for a `BridgeClient` whose only purpose is @@ -3119,7 +3119,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = events: { publish: ReturnType }; activePromptId?: string; promptActive?: boolean; - media?: SessionMediaStore; + attachments?: SessionAttachmentStore; } | undefined, ownsSession?: (sessionId: string) => boolean, @@ -3127,7 +3127,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = const resolvedEntry = entry ? { ...entry, - media: entry.media ?? new SessionMediaStore(), + attachments: entry.attachments ?? new SessionAttachmentStore(), pendingPromptList: entry.pendingPromptList ?? [], settledMidTurnMessageIds: entry.settledMidTurnMessageIds ?? [], } @@ -3283,11 +3283,14 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = }); }); - it('resolves media references for the child and preserves their replay metadata', async () => { + it('resolves attachment references for the child and preserves their replay metadata', async () => { const publish = vi.fn().mockReturnValue(true); - const media = new SessionMediaStore(); + const media = new SessionAttachmentStore(); try { - const reference = await media.put(Uint8Array.of(1, 2, 3), 'image/png'); + const reference = await media.putAttachment( + Uint8Array.of(1, 2, 3), + 'image/png', + ); const entry = { sessionId: 'sess:media-reference', midTurnMessageQueue: [ @@ -3299,7 +3302,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = ], settledMidTurnMessageIds: [] as string[], events: { publish }, - media, + attachments: media, }; const client = makeClientWithEntry('sess:media-reference', entry); @@ -3314,7 +3317,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = { type: 'text', text: 'look' }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], - mediaReferences: [reference], + attachmentReferences: [reference], }, ], }); @@ -3323,11 +3326,14 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = } }); - it('degrades a mediaId reused across drained messages after its first use', async () => { + it('degrades a attachmentId reused across drained messages after its first use', async () => { const publish = vi.fn().mockReturnValue(true); - const media = new SessionMediaStore(); + const media = new SessionAttachmentStore(); try { - const reference = await media.put(Uint8Array.of(1, 2, 3), 'image/png'); + const reference = await media.putAttachment( + Uint8Array.of(1, 2, 3), + 'image/png', + ); const read = vi.spyOn(media, 'read'); const entry = { sessionId: 'sess:shared-media', @@ -3339,7 +3345,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = ], settledMidTurnMessageIds: [] as string[], events: { publish }, - media, + attachments: media, }; const client = makeClientWithEntry('sess:shared-media', entry); @@ -3359,7 +3365,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = content: [ { type: 'text', - text: 'b\n[Attached media is no longer available]', + text: 'b\n[Attachment is no longer available]', }, ], }, @@ -3367,7 +3373,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = content: [ { type: 'text', - text: 'c\n[Attached media is no longer available]', + text: 'c\n[Attachment is no longer available]', }, ], }, @@ -3375,7 +3381,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = content: [ { type: 'text', - text: 'd\n[Attached media is no longer available]', + text: 'd\n[Attachment is no longer available]', }, ], }, @@ -3398,7 +3404,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = await gate; return content ?? []; }), - } as unknown as SessionMediaStore; + } as unknown as SessionAttachmentStore; const entry = { sessionId: 'sess:slow-media', midTurnMessageQueue: [ @@ -3412,7 +3418,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = ], settledMidTurnMessageIds: [] as string[], events: { publish: vi.fn().mockReturnValue(true) }, - media, + attachments: media, }; const client = makeClientWithEntry('sess:slow-media', entry); @@ -3439,7 +3445,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = content: [ { type: 'image' as const, - mediaId: 'expired', + attachmentId: 'expired', mimeType: 'image/png', size: 3, }, @@ -3468,7 +3474,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = content: [ { type: 'text', - text: 'look at this\n[Attached media is no longer available]', + text: 'look at this\n[Attachment is no longer available]', }, ], }, @@ -3484,14 +3490,14 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = expect(publish).toHaveBeenCalledOnce(); }); - it('drains every valid media reference even when their total exceeds 16 MiB', async () => { - const media = new SessionMediaStore(); + it('drains every valid attachment reference even when their total exceeds 16 MiB', async () => { + const media = new SessionAttachmentStore(); try { - const large = new Uint8Array(SESSION_MEDIA_MAX_ITEM_BYTES); + const large = new Uint8Array(SESSION_ATTACHMENT_MAX_ITEM_BYTES); const refs = [ - await media.put(large, 'image/png'), - await media.put(large, 'image/png'), - await media.put(Uint8Array.of(1), 'image/png'), + await media.putAttachment(large, 'image/png'), + await media.putAttachment(large, 'image/png'), + await media.putAttachment(Uint8Array.of(1), 'image/png'), ]; const entry = { sessionId: 'sess:large-drain', @@ -3500,7 +3506,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = ], settledMidTurnMessageIds: [] as string[], events: { publish: vi.fn().mockReturnValue(true) }, - media, + attachments: media, }; const client = makeClientWithEntry('sess:large-drain', entry); @@ -3509,14 +3515,14 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = })) as { items: Array<{ content: Array>; - mediaReferences?: unknown[]; + attachmentReferences?: unknown[]; }>; }; expect( result.items[0]?.content.filter((block) => block['type'] === 'image'), ).toHaveLength(3); - expect(result.items[0]?.mediaReferences).toEqual(refs); + expect(result.items[0]?.attachmentReferences).toEqual(refs); } finally { await media.close(); } @@ -3527,14 +3533,17 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = // media of every queued message: the store still holds the bytes, so the // drain surfaces the error and hands the messages back for the next one. const publish = vi.fn().mockReturnValue(true); - const media = new SessionMediaStore(); + const media = new SessionAttachmentStore(); const readFile = vi .spyOn(fsp, 'readFile') .mockRejectedValueOnce( Object.assign(new Error('too many open files'), { code: 'EMFILE' }), ); try { - const reference = await media.put(Uint8Array.of(1, 2, 3), 'image/png'); + const reference = await media.putAttachment( + Uint8Array.of(1, 2, 3), + 'image/png', + ); const entry = { sessionId: 'sess:emfile', midTurnMessageQueue: [ @@ -3543,7 +3552,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = ], settledMidTurnMessageIds: [] as string[], events: { publish }, - media, + attachments: media, }; const client = makeClientWithEntry('sess:emfile', entry); @@ -3588,11 +3597,14 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = // One dead reference must drop only itself, not the whole message's // media: the sibling the store still holds reaches the child. const publish = vi.fn().mockReturnValue(true); - const media = new SessionMediaStore(); + const media = new SessionAttachmentStore(); try { - const live = await media.put(Uint8Array.of(1, 2, 3), 'image/png'); - const gone = await media.put(Uint8Array.of(4, 5), 'image/png'); - await media.remove(gone.mediaId); + const live = await media.putAttachment( + Uint8Array.of(1, 2, 3), + 'image/png', + ); + const gone = await media.putAttachment(Uint8Array.of(4, 5), 'image/png'); + await media.remove(gone.attachmentId); const entry = { sessionId: 'sess:mixed', midTurnMessageQueue: [ @@ -3600,7 +3612,7 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = ], settledMidTurnMessageIds: [] as string[], events: { publish }, - media, + attachments: media, }; const client = makeClientWithEntry('sess:mixed', entry); @@ -3615,11 +3627,11 @@ describe('BridgeClient — mid-turn queue drain (craft/drainMidTurnQueue)', () = content: [ { type: 'text', - text: 'mixed\n[Attached media is no longer available]', + text: 'mixed\n[Attachment is no longer available]', }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], - mediaReferences: [live], + attachmentReferences: [live], }, ], }); diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 9b7fd9aa3df..8e4a29996a3 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -85,12 +85,12 @@ import type { SessionArtifactStore, } from './sessionArtifacts.js'; import { - isSessionMediaReference, - SessionMediaReferenceError, - withMediaDegradationMarker, - type SessionMediaReference, - type SessionMediaStore, -} from './sessionMedia.js'; + isSessionAttachmentReference, + SessionAttachmentReferenceError, + withAttachmentDegradationMarker, + type SessionAttachmentReference, + type SessionAttachmentStore, +} from './sessionAttachments.js'; /** * Validate a channel-wide active-work snapshot off the wire. @@ -615,7 +615,7 @@ export interface BridgeClientSessionEntry { effectiveCwd: string; events: EventBus; artifacts: SessionArtifactStore; - media: SessionMediaStore; + attachments: SessionAttachmentStore; recordingDegraded: boolean; pendingPermissionIds: Set; /** Pollable pending human interactions, keyed by permission request id. */ @@ -1269,50 +1269,53 @@ export class BridgeClient implements Client { ); } } - // Shared across every message in this drain: one stored mediaId that + // Shared across every message in this drain: one stored attachment that // several queued messages reference is read and base64-encoded once // instead of once per message. - const mediaMemo = new Map>(); - const serializedMediaIds = new Set(); + const attachmentMemo = new Map>(); + const serializedAttachmentIds = new Set(); const items: Array<{ messageId: string; displayText: string; content: ContentBlock[]; - mediaReferences?: SessionMediaReference[]; + attachmentReferences?: SessionAttachmentReference[]; }> = []; try { for (const item of drained) { let degraded = 0; const planned = (item.content ?? []).filter((block) => { - if (!isSessionMediaReference(block)) return true; - if (serializedMediaIds.has(block.mediaId)) { + if (!isSessionAttachmentReference(block)) return true; + if (serializedAttachmentIds.has(block.attachmentId)) { degraded += 1; return false; } - serializedMediaIds.add(block.mediaId); + serializedAttachmentIds.add(block.attachmentId); return true; }); let resolvedBlocks: ContentBlock[]; - let mediaReferences: SessionMediaReference[]; + let attachmentReferences: SessionAttachmentReference[]; try { - resolvedBlocks = await entry.media.resolveContent(planned, mediaMemo); - mediaReferences = planned.filter(isSessionMediaReference); + resolvedBlocks = await entry.attachments.resolveContent( + planned, + attachmentMemo, + ); + attachmentReferences = planned.filter(isSessionAttachmentReference); } catch (error) { // Only a gone/invalid reference degrades — per block, so one dead // reference drops itself and keeps its siblings. Any other error // (fd exhaustion, I/O failure) propagates instead of silently - // destroying the media of every message sharing the mediaId. - if (!(error instanceof SessionMediaReferenceError)) throw error; + // destroying every message sharing the attachment. + if (!(error instanceof SessionAttachmentReferenceError)) throw error; writeStderrLine( - `[mid-turn] session=${JSON.stringify(entry.sessionId)} degraded media for message ${JSON.stringify(item.messageId)}: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`, + `[mid-turn] session=${JSON.stringify(entry.sessionId)} degraded attachment for message ${JSON.stringify(item.messageId)}: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`, ); - const perBlock = await entry.media.resolveContentDegrading( + const perBlock = await entry.attachments.resolveContentDegrading( planned, - mediaMemo, + attachmentMemo, ); resolvedBlocks = perBlock.resolvedBlocks; - mediaReferences = perBlock.retainedBlocks.filter( - isSessionMediaReference, + attachmentReferences = perBlock.retainedBlocks.filter( + isSessionAttachmentReference, ); degraded += perBlock.degraded; } @@ -1320,12 +1323,12 @@ export class BridgeClient implements Client { ...(item.text ? [{ type: 'text' as const, text: item.text }] : []), ...resolvedBlocks, ]; - if (degraded > 0) content = withMediaDegradationMarker(content); + if (degraded > 0) content = withAttachmentDegradationMarker(content); items.push({ messageId: item.messageId, displayText: item.text, content, - ...(mediaReferences.length > 0 ? { mediaReferences } : {}), + ...(attachmentReferences.length > 0 ? { attachmentReferences } : {}), }); } } catch (error) { diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index b141afd8b69..2c96e6ee6e4 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -192,6 +192,12 @@ export interface BridgeTelemetry { * strictly-required field. See per-field JSDoc for caller contract. */ export interface BridgeOptions { + /** + * Runtime-owned directory for persistent session attachment bytes. Daemon + * callers provide a workspace-scoped directory under the Qwen runtime temp + * root. Direct embedded callers may omit it for process-local storage. + */ + sessionAttachmentsRoot?: string; /** * `single` shares one session per workspace across HTTP * clients (live-collaboration default); `thread` gives each `spawnOrAttach` diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 48b014d4385..3332371c2d8 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -37,7 +37,7 @@ import type { SessionArtifactMutationResult, SessionArtifactsEnvelope, } from './sessionArtifacts.js'; -import type { SessionMediaReference } from './sessionMedia.js'; +import type { SessionAttachmentReference } from './sessionAttachments.js'; import type { ServeSessionContextStatus, ServeSessionHooksStatus, @@ -99,7 +99,9 @@ export interface ChildHeapReport { unclassifiedSpaceNames: string[]; } -export type BridgePromptContentBlock = ContentBlock | SessionMediaReference; +export type BridgePromptContentBlock = + | ContentBlock + | SessionAttachmentReference; export type BridgePromptRequest = Omit & { prompt: BridgePromptContentBlock[]; @@ -816,7 +818,8 @@ export interface BridgeClientRequestContext { } export const DAEMON_MODEL_PROMPT_META_KEY = 'qwen.daemon.modelPrompt'; -export const DAEMON_MEDIA_REFERENCES_META_KEY = 'qwen.daemon.mediaReferences'; +export const DAEMON_ATTACHMENT_REFERENCES_META_KEY = + 'qwen.daemon.attachmentReferences'; export const MAX_TRUSTED_MODEL_PROMPT_CHARS = 64 * 1024; export function isValidTrustedModelPrompt(value: unknown): value is string { @@ -1864,25 +1867,29 @@ export interface AcpSessionBridge { }, ): { accepted: boolean; messageId?: string }; - storeSessionMedia( + storeSessionAttachment( sessionId: string, data: Uint8Array, mimeType: string, context?: BridgeClientRequestContext, - ): Promise; + name?: string, + ): Promise; - readSessionMedia( + readSessionAttachment( sessionId: string, - mediaId: string, + attachmentId: string, context?: BridgeClientRequestContext, ): Promise<{ data: Buffer; mimeType: string } | undefined>; - removeSessionMedia( + removeSessionAttachment( sessionId: string, - mediaId: string, + attachmentId: string, context?: BridgeClientRequestContext, ): Promise; + /** Delete all persisted attachments after the session itself is deleted. */ + deleteSessionAttachments(sessionId: string): Promise; + /** Remove a queued or promoted mid-turn message. */ removeMidTurnMessage( sessionId: string, diff --git a/packages/acp-bridge/src/index.ts b/packages/acp-bridge/src/index.ts index 4145dddbf3f..13a93fafeed 100644 --- a/packages/acp-bridge/src/index.ts +++ b/packages/acp-bridge/src/index.ts @@ -13,7 +13,7 @@ export * from './workspacePaths.js'; export * from './status.js'; export * from './bridgeErrors.js'; export * from './sessionArtifacts.js'; -export * from './sessionMedia.js'; +export * from './sessionAttachments.js'; export * from './bridgeTypes.js'; export * from './session-source.js'; export * from './bridgeOptions.js'; diff --git a/packages/acp-bridge/src/sessionAttachments.test.ts b/packages/acp-bridge/src/sessionAttachments.test.ts new file mode 100644 index 00000000000..90a8936a3e1 --- /dev/null +++ b/packages/acp-bridge/src/sessionAttachments.test.ts @@ -0,0 +1,868 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { + SESSION_ATTACHMENT_UNAVAILABLE_TEXT, + SESSION_ATTACHMENT_MAX_ITEM_BYTES, + SessionAttachmentStore, + withAttachmentDegradationMarker, +} from './sessionAttachments.js'; + +describe('SessionAttachmentStore', () => { + it('does not append the attachment degradation marker twice', () => { + const once = withAttachmentDegradationMarker([ + { type: 'text', text: 'look at this' }, + ]); + + expect(withAttachmentDegradationMarker(once)).toEqual([ + { + type: 'text', + text: `look at this\n${SESSION_ATTACHMENT_UNAVAILABLE_TEXT}`, + }, + ]); + }); + + it('stores bytes by reference and resolves them only at dispatch', async () => { + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + Uint8Array.from([1, 2, 3]), + 'image/png', + ); + + expect(reference).toMatchObject({ + type: 'image', + mimeType: 'image/png', + size: 3, + }); + expect(await store.resolveContent([reference])).toEqual([ + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ]); + expect(await store.read(reference.attachmentId)).toEqual({ + data: Buffer.from([1, 2, 3]), + mimeType: 'image/png', + }); + } finally { + await store.close(); + } + }); + + it('stores text attachments under the configured runtime root', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachments-test-'), + ); + const store = new SessionAttachmentStore(root); + try { + const reference = await store.putAttachment( + new TextEncoder().encode('hello'), + 'text/plain', + '../notes.txt', + ); + + expect(reference).toMatchObject({ + type: 'resource', + mimeType: 'text/plain', + size: 5, + }); + expect(await store.resolveContent([reference])).toEqual([ + { + type: 'resource', + resource: { + uri: 'attachment:///notes.txt', + mimeType: 'text/plain', + text: 'hello', + }, + }, + ]); + expect(await fs.readdir(root)).toHaveLength(1); + } finally { + await store.close(); + expect(await fs.readdir(root)).toEqual([]); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('resolves arbitrary binary files without decoding their bytes', async () => { + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + Uint8Array.from([0, 255, 1]), + 'application/pdf', + 'report.pdf', + ); + + expect(reference).toMatchObject({ + type: 'resource', + mimeType: 'application/pdf', + }); + expect(await store.resolveContent([reference])).toEqual([ + { + type: 'resource', + resource: { + uri: 'attachment:///report.pdf', + mimeType: 'application/pdf', + blob: 'AP8B', + }, + }, + ]); + } finally { + await store.close(); + } + }); + + it.each(['app.py', 'deploy.sh'])( + 'resolves UTF-8 source %s as text', + async (name) => { + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + new TextEncoder().encode('echo hello\n'), + 'application/octet-stream', + name, + ); + + expect(await store.resolveContent([reference])).toEqual([ + { + type: 'resource', + resource: { + uri: `attachment:///${name}`, + mimeType: 'application/octet-stream', + text: 'echo hello\n', + }, + }, + ]); + } finally { + await store.close(); + } + }, + ); + + it('keeps unknown binary files as blobs', async () => { + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + Uint8Array.from([0, 255, 1]), + 'application/octet-stream', + 'payload.unknown', + ); + + expect(await store.resolveContent([reference])).toEqual([ + { + type: 'resource', + resource: { + uri: 'attachment:///payload.unknown', + mimeType: 'application/octet-stream', + blob: 'AP8B', + }, + }, + ]); + } finally { + await store.close(); + } + }); + + it('stores unsupported image formats as ordinary file resources', async () => { + const store = new SessionAttachmentStore(); + try { + const data = new TextEncoder().encode(''); + const reference = await store.putAttachment( + data, + 'image/svg+xml', + 'diagram.svg', + ); + + expect(reference).toMatchObject({ + type: 'resource', + mimeType: 'image/svg+xml', + }); + expect(await store.resolveContent([reference])).toEqual([ + { + type: 'resource', + resource: { + uri: 'attachment:///diagram.svg', + mimeType: 'image/svg+xml', + text: '', + }, + }, + ]); + } finally { + await store.close(); + } + }); + + it('resolves duplicate references with a single read', async () => { + // Duplicate references to one stored item must not multiply the disk + // reads and base64 encodes at dispatch — that amplification let one + // small request pin gigabytes of heap. + const store = new SessionAttachmentStore(); + const readFile = vi.spyOn(fs, 'readFile'); + try { + const reference = await store.putAttachment( + Uint8Array.from([1, 2, 3]), + 'image/png', + ); + readFile.mockClear(); + + const resolved = await store.resolveContent([ + reference, + { ...reference }, + { ...reference }, + ]); + + expect(resolved).toEqual([ + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ]); + expect(readFile).toHaveBeenCalledTimes(1); + } finally { + readFile.mockRestore(); + await store.close(); + } + }); + + it('shares reads across resolveContent calls via a caller-supplied memo', async () => { + const store = new SessionAttachmentStore(); + const readFile = vi.spyOn(fs, 'readFile'); + try { + const reference = await store.putAttachment( + Uint8Array.from([1, 2, 3]), + 'image/png', + ); + readFile.mockClear(); + + const memo = new Map>(); + const block = { + type: 'image', + data: 'AQID', + mimeType: 'image/png', + }; + expect(await store.resolveContent([reference], memo)).toEqual([block]); + expect(await store.resolveContent([reference], memo)).toEqual([block]); + expect(readFile).toHaveBeenCalledTimes(1); + + // Omitting the memo keeps the per-call default: a fresh map, so the + // blob is read again. + expect(await store.resolveContent([reference])).toEqual([block]); + expect(readFile).toHaveBeenCalledTimes(2); + } finally { + readFile.mockRestore(); + await store.close(); + } + }); + it('keeps attachments for the lifetime of the store', async () => { + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + Uint8Array.of(1), + 'image/png', + ); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2100-01-01T00:00:00Z')); + + expect(await store.read(reference.attachmentId)).toBeDefined(); + } finally { + vi.useRealTimers(); + await store.close(); + } + }); + + it('requires a file name for non-image uploads', async () => { + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(Uint8Array.of(1), 'audio/wav'), + ).rejects.toThrow('Session attachment name is invalid'); + } finally { + await store.close(); + } + }); + + it('rejects unsafe attachment names', async () => { + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(Uint8Array.of(1), 'text/plain', 'bad\0name.txt'), + ).rejects.toThrow('attachment name is invalid'); + } finally { + await store.close(); + } + }); + + it('rejects image names whose extension disagrees with Content-Type', async () => { + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment( + new TextEncoder().encode('not an image'), + 'text/plain', + 'screenshot.png', + ), + ).rejects.toThrow('Attachment name and Content-Type do not match'); + await expect( + store.putAttachment(Uint8Array.of(1), 'image/png', 'notes.txt'), + ).rejects.toThrow('Attachment name and Content-Type do not match'); + await expect( + store.putAttachment(Uint8Array.of(1), 'image/jpeg', 'photo.png'), + ).rejects.toThrow('Attachment name and Content-Type do not match'); + } finally { + await store.close(); + } + }); + + it('allows empty files but rejects empty images and oversized uploads', async () => { + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(new Uint8Array(), 'text/plain', 'empty.txt'), + ).resolves.toMatchObject({ + type: 'resource', + attachmentId: 'empty.txt', + size: 0, + }); + await expect( + store.putAttachment(new Uint8Array(), 'image/png'), + ).rejects.toThrow(/images cannot be empty/); + await expect( + store.putAttachment( + new Uint8Array(SESSION_ATTACHMENT_MAX_ITEM_BYTES + 1), + 'image/png', + ), + ).rejects.toThrow(/at most/); + } finally { + await store.close(); + } + }); + + it('copies stored files without changing their attachment ids', async () => { + const source = new SessionAttachmentStore(); + const target = new SessionAttachmentStore(); + try { + const reference = await source.putAttachment( + Uint8Array.of(1, 2, 3), + 'application/json', + 'notes.json', + ); + await target.copyFrom(source); + await expect(target.read(reference.attachmentId)).resolves.toEqual({ + data: Buffer.from([1, 2, 3]), + mimeType: 'application/json', + }); + } finally { + await source.close(); + await target.close(); + } + }); + + it('retries directory creation after a transient failure', async () => { + const mkdir = vi + .spyOn(fs, 'mkdtemp') + .mockRejectedValueOnce( + Object.assign(new Error('full'), { code: 'ENOSPC' }), + ); + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(Uint8Array.of(1), 'image/png'), + ).rejects.toThrow('full'); + mkdir.mockRestore(); + await expect( + store.putAttachment(Uint8Array.of(1), 'image/png'), + ).resolves.toMatchObject({ size: 1 }); + } finally { + mkdir.mockRestore(); + await store.close(); + } + }); + + it('removes a partial file after writing fails', async () => { + const write = vi + .spyOn(fs, 'writeFile') + .mockRejectedValueOnce( + Object.assign(new Error('full'), { code: 'ENOSPC' }), + ); + const remove = vi.spyOn(fs, 'rm'); + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(Uint8Array.of(1), 'image/png'), + ).rejects.toThrow('full'); + expect(remove).toHaveBeenCalledWith(expect.any(String), { force: true }); + } finally { + write.mockRestore(); + remove.mockRestore(); + await store.close(); + } + }); + + it('closes cleanly after directory creation fails', async () => { + const mkdir = vi + .spyOn(fs, 'mkdtemp') + .mockRejectedValueOnce( + Object.assign(new Error('full'), { code: 'ENOSPC' }), + ); + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(Uint8Array.of(1), 'image/png'), + ).rejects.toThrow('full'); + await expect(store.close()).resolves.toBeUndefined(); + } finally { + mkdir.mockRestore(); + await store.close(); + } + }); + + it('removes stored attachments', async () => { + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + Uint8Array.of(1, 2), + 'image/png', + ); + await expect(store.remove(reference.attachmentId)).resolves.toBe(true); + await expect(store.read(reference.attachmentId)).resolves.toBeUndefined(); + } finally { + await store.close(); + } + }); + + it('allows removal while another attachment is uploading', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'qwen-attachment-race-')); + const originalWriteFile = fs.writeFile.bind(fs); + let finishWrite: (() => void) | undefined; + const write = vi + .spyOn(fs, 'writeFile') + .mockImplementation(async (...args) => { + if (String(args[0]).endsWith('slow.bin')) { + await new Promise((resolve) => { + finishWrite = resolve; + }); + } + return await originalWriteFile(...args); + }); + const store = new SessionAttachmentStore(root, 'session-a'); + try { + const existing = await store.putAttachment( + Uint8Array.of(1, 2), + 'application/octet-stream', + 'existing.bin', + ); + const pending = store.putAttachment( + new Uint8Array(8), + 'application/octet-stream', + 'slow.bin', + ); + await vi.waitFor(() => expect(finishWrite).toBeTypeOf('function')); + + await expect(store.remove(existing.attachmentId)).resolves.toBe(true); + + finishWrite?.(); + await expect(pending).resolves.toMatchObject({ size: 8 }); + } finally { + finishWrite?.(); + write.mockRestore(); + await store.close(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('keeps a same-name upload protected while its duplicate retries', async () => { + const originalWriteFile = fs.writeFile.bind(fs); + let firstCreated: (() => void) | undefined; + let finishFirst: (() => void) | undefined; + const created = new Promise((resolve) => { + firstCreated = resolve; + }); + const waitForFinish = new Promise((resolve) => { + finishFirst = resolve; + }); + let first = true; + const write = vi + .spyOn(fs, 'writeFile') + .mockImplementation(async (...args) => { + if (first && String(args[0]).endsWith('notes.txt')) { + first = false; + await originalWriteFile(...args); + firstCreated?.(); + await waitForFinish; + return; + } + return await originalWriteFile(...args); + }); + const store = new SessionAttachmentStore(); + try { + const pending = store.putAttachment( + new TextEncoder().encode('first'), + 'text/plain', + 'notes.txt', + ); + await created; + const duplicate = await store.putAttachment( + new TextEncoder().encode('second'), + 'text/plain', + 'notes.txt', + ); + + expect(duplicate.attachmentId).toBe('notes (1).txt'); + await expect(store.remove('notes.txt')).resolves.toBe(false); + finishFirst?.(); + const original = await pending; + await expect(store.read(original.attachmentId)).resolves.toMatchObject({ + data: Buffer.from('first'), + }); + } finally { + finishFirst?.(); + write.mockRestore(); + await store.close(); + } + }); + + it('waits for target uploads before copying files', async () => { + const source = new SessionAttachmentStore(); + const target = new SessionAttachmentStore(); + const sourceReference = await source.putAttachment( + Uint8Array.of(1, 2, 3), + 'application/octet-stream', + 'source.bin', + ); + const originalWriteFile = fs.writeFile.bind(fs); + let finishWrite: (() => void) | undefined; + const write = vi + .spyOn(fs, 'writeFile') + .mockImplementation(async (...args) => { + if (String(args[0]).endsWith('target.bin')) { + await new Promise((resolve) => { + finishWrite = resolve; + }); + } + return await originalWriteFile(...args); + }); + try { + const pending = target.putAttachment( + new Uint8Array(8), + 'application/octet-stream', + 'target.bin', + ); + await vi.waitFor(() => expect(finishWrite).toBeTypeOf('function')); + const copying = target.copyFrom(source); + finishWrite?.(); + await Promise.all([pending, copying]); + + await expect(target.read(sourceReference.attachmentId)).resolves.toEqual({ + data: Buffer.from([1, 2, 3]), + mimeType: 'application/octet-stream', + }); + } finally { + finishWrite?.(); + write.mockRestore(); + await source.close(); + await target.close(); + } + }); + + it('skips source files removed while copying', async () => { + const source = new SessionAttachmentStore(); + const target = new SessionAttachmentStore(); + await source.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + 'gone.bin', + ); + const originalCopyFile = fs.copyFile.bind(fs); + const copy = vi + .spyOn(fs, 'copyFile') + .mockImplementationOnce(async (...args) => { + await fs.rm(args[0], { force: true }); + return await originalCopyFile(...args); + }); + try { + await expect(target.copyFrom(source)).resolves.toBeUndefined(); + } finally { + copy.mockRestore(); + await source.close(); + await target.close(); + } + }); + + it('forgets attachments whose backing file disappeared', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'qwen-attachment-gone-')); + const store = new SessionAttachmentStore(root, 'session-a'); + try { + const reference = await store.putAttachment( + Uint8Array.of(1, 2), + 'image/png', + ); + await fs.rm(path.join(root, 'session-session-a', reference.attachmentId)); + + await expect(store.read(reference.attachmentId)).resolves.toBeUndefined(); + expect(() => store.assertReferences([reference])).toThrow( + 'Unknown or unavailable session attachment', + ); + } finally { + await store.delete(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('uses deduplicated file names and restores them after close', async () => { + const root = await fs.mkdtemp( + path.join(tmpdir(), 'qwen-attachment-restart-'), + ); + const first = new SessionAttachmentStore(root, 'session-a'); + try { + const original = await first.putAttachment( + new TextEncoder().encode('first'), + 'application/json', + 'notes.json', + ); + const duplicate = await first.putAttachment( + new TextEncoder().encode('second'), + 'application/json', + 'notes.json', + ); + const typescript = await first.putAttachment( + new TextEncoder().encode('const value = 1;'), + 'text/plain', + 'example.ts', + ); + const image = await first.putAttachment(Uint8Array.of(1), 'image/png'); + const duplicateImage = await first.putAttachment( + Uint8Array.of(2), + 'image/png', + ); + expect(original).toMatchObject({ + attachmentId: 'notes.json', + }); + expect(duplicate).toMatchObject({ + attachmentId: 'notes (1).json', + }); + expect(image.attachmentId).toBe('image.png'); + expect(duplicateImage.attachmentId).toBe('image (1).png'); + + await first.close(); + const restored = new SessionAttachmentStore(root, 'session-a'); + try { + await expect(restored.read(original.attachmentId)).resolves.toEqual({ + data: Buffer.from('first'), + mimeType: 'application/json', + }); + await expect(restored.read(duplicate.attachmentId)).resolves.toEqual({ + data: Buffer.from('second'), + mimeType: 'application/json', + }); + await expect(restored.resolveContent([typescript])).resolves.toEqual([ + { + type: 'resource', + resource: { + uri: 'attachment:///example.ts', + mimeType: 'text/plain', + text: 'const value = 1;', + }, + }, + ]); + await expect( + restored.read(duplicateImage.attachmentId), + ).resolves.toEqual({ + data: Buffer.from([2]), + mimeType: 'image/png', + }); + } finally { + await restored.delete(); + } + await expect(fs.readdir(root)).resolves.toEqual([]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('keeps deduplicated names within the filesystem byte limit', async () => { + const store = new SessionAttachmentStore(); + const name = `${'a'.repeat(250)}.txt`; + try { + await store.putAttachment(Uint8Array.of(1), 'text/plain', name); + const duplicate = await store.putAttachment( + Uint8Array.of(2), + 'text/plain', + name, + ); + + expect(Buffer.byteLength(duplicate.attachmentId)).toBeLessThanOrEqual( + 255, + ); + expect(duplicate.attachmentId.endsWith(' (1).txt')).toBe(true); + await expect(store.read(duplicate.attachmentId)).resolves.toMatchObject({ + data: Buffer.from([2]), + }); + } finally { + await store.close(); + } + }); + + it.each(['CON', 'nul.txt', 'bad:name.txt'])( + 'rejects non-portable attachment name %s', + async (name) => { + const store = new SessionAttachmentStore(); + try { + await expect( + store.putAttachment(Uint8Array.of(1), 'text/plain', name), + ).rejects.toThrow('Session attachment name is invalid'); + } finally { + await store.close(); + } + }, + ); + + it('rejects duplicate references to one attachmentId in a single message', async () => { + // A block count cap alone does not bound the resolved payload: the same + // attachmentId repeated N times passes admission and expands per occurrence at + // dispatch, so one small upload can serialize into gigabytes. Reject the + // duplicate occurrences at admission. + const store = new SessionAttachmentStore(); + try { + const reference = await store.putAttachment( + Uint8Array.of(1, 2, 3), + 'image/png', + ); + expect(() => + store.assertReferences([reference, { ...reference }]), + ).toThrow(/more than once/); + // A single occurrence is still valid. + expect(() => store.assertReferences([reference])).not.toThrow(); + } finally { + await store.close(); + } + }); + + it('rejects references from another session store', async () => { + const first = new SessionAttachmentStore(); + const second = new SessionAttachmentStore(); + try { + const reference = await first.putAttachment( + Uint8Array.of(1), + 'image/png', + ); + expect(() => second.assertReferences([reference])).toThrow( + 'Unknown or unavailable session attachment', + ); + } finally { + await Promise.all([first.close(), second.close()]); + } + }); + + it('does not cap the number of stored objects per session', async () => { + const store = new SessionAttachmentStore(); + try { + const references = await Promise.all( + Array.from({ length: 257 }, async (_, index) => + store.putAttachment( + Uint8Array.of(1), + 'application/octet-stream', + `file-${index}.bin`, + ), + ), + ); + expect(references).toHaveLength(257); + } finally { + await store.close(); + } + }); + + it('does not cap the total bytes stored by one session', async () => { + const write = vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined); + const store = new SessionAttachmentStore(); + try { + const item = new Uint8Array(SESSION_ATTACHMENT_MAX_ITEM_BYTES); + for (let index = 0; index < 13; index += 1) { + await store.putAttachment(item, 'image/png'); + } + } finally { + write.mockRestore(); + await store.close(); + } + }); + + it('evicts a rejected memo entry so siblings and retries read again', async () => { + // A transient non-ENOENT read failure must not be cached in a shared + // memo: every message referencing the same attachmentId would otherwise await + // the cached rejection although the store still holds the bytes. + const store = new SessionAttachmentStore(); + const readFile = vi + .spyOn(fs, 'readFile') + .mockRejectedValueOnce( + Object.assign(new Error('too many open files'), { code: 'EMFILE' }), + ); + try { + const reference = await store.putAttachment( + Uint8Array.of(9, 9), + 'image/png', + ); + const memo = new Map>(); + + await expect(store.resolveContent([reference], memo)).rejects.toThrow( + 'too many open files', + ); + // The failed entry must not stay cached: the next resolution re-reads + // from disk and succeeds. + await expect(store.resolveContent([reference], memo)).resolves.toEqual([ + { type: 'image', data: 'CQk=', mimeType: 'image/png' }, + ]); + expect(readFile).toHaveBeenCalledTimes(2); + } finally { + readFile.mockRestore(); + await store.close(); + } + }); + + it('resolveContentDegrading drops only the unresolvable reference', async () => { + const store = new SessionAttachmentStore(); + try { + const live = await store.putAttachment(Uint8Array.of(1, 2), 'image/png'); + const gone = await store.putAttachment(Uint8Array.of(3, 4), 'image/png'); + await store.remove(gone.attachmentId); + const text = { type: 'text', text: 'both' } as ContentBlock; + + const result = await store.resolveContentDegrading([text, gone, live]); + + expect(result.degraded).toBe(1); + expect(result.retainedBlocks).toEqual([text, live]); + expect(result.resolvedBlocks).toEqual([ + text, + { type: 'image', data: 'AQI=', mimeType: 'image/png' }, + ]); + } finally { + await store.close(); + } + }); + + it('rejects an upload when close races its write', async () => { + let finishWrite: (() => void) | undefined; + const write = vi.spyOn(fs, 'writeFile').mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishWrite = resolve; + }), + ); + const store = new SessionAttachmentStore(); + try { + const pending = store.putAttachment(Uint8Array.of(1), 'image/png'); + await vi.waitFor(() => expect(write).toHaveBeenCalled()); + await store.close(); + finishWrite?.(); + await expect(pending).rejects.toThrow( + 'Session attachment store is closed', + ); + } finally { + write.mockRestore(); + await store.close(); + } + }); +}); diff --git a/packages/acp-bridge/src/sessionAttachments.ts b/packages/acp-bridge/src/sessionAttachments.ts new file mode 100644 index 00000000000..ceeae7d7ce7 --- /dev/null +++ b/packages/acp-bridge/src/sessionAttachments.ts @@ -0,0 +1,623 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { getSpecificMimeType } from '@qwen-code/qwen-code-core'; + +export const SESSION_ATTACHMENT_MAX_ITEM_BYTES = 8 * 1024 * 1024; +const SESSION_ATTACHMENT_MAX_NAME_BYTES = 255; +const SUPPORTED_IMAGE_MIME_TYPES = new Set([ + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); + +// Text the degrade paths substitute for an attachment the model will not receive. The +// SDK's DaemonSessionClient.hydrateBlock and the web shell's degradation +// detection carry their own copies; keep the wording in sync. +export const SESSION_ATTACHMENT_UNAVAILABLE_TEXT = + '[Attachment is no longer available]'; + +export class SessionAttachmentReferenceError extends Error { + constructor( + message: string, + readonly code: + | 'invalid_session_attachment_reference' + | 'session_attachment_gone', + ) { + super(message); + this.name = 'SessionAttachmentReferenceError'; + } +} + +export interface SessionAttachmentReference { + type: 'image' | 'resource'; + attachmentId: string; + mimeType: string; + size: number; +} + +export function isSessionAttachmentReference( + value: unknown, +): value is SessionAttachmentReference { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + (record['type'] === 'image' || record['type'] === 'resource') && + typeof record['attachmentId'] === 'string' && + record['attachmentId'].length > 0 && + typeof record['mimeType'] === 'string' && + record['mimeType'].length > 0 && + (record['type'] !== 'image' || record['mimeType'].startsWith('image/')) && + typeof record['size'] === 'number' && + Number.isSafeInteger(record['size']) && + record['size'] >= 0 && + (record['type'] !== 'image' || record['size'] > 0) + ); +} + +function safeAttachmentName(name: string): string | undefined { + const safeName = path.basename(name.replaceAll('\\', '/')).trim(); + const isWindowsReserved = + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safeName); + const hasInvalidCharacter = Array.from(safeName).some((character) => { + const codePoint = character.codePointAt(0); + return ( + codePoint !== undefined && + (codePoint <= 0x1f || + codePoint === 0x7f || + (codePoint >= 0xd800 && codePoint <= 0xdfff)) + ); + }); + return !safeName || + safeName === '.' || + safeName === '..' || + safeName.endsWith('.') || + isWindowsReserved || + /[<>:"|?*]/.test(safeName) || + hasInvalidCharacter || + Buffer.byteLength(safeName) > SESSION_ATTACHMENT_MAX_NAME_BYTES + ? undefined + : safeName; +} + +function truncateUtf8(value: string, maxBytes: number): string { + let bytes = 0; + let result = ''; + for (const character of value) { + const characterBytes = Buffer.byteLength(character); + if (bytes + characterBytes > maxBytes) break; + result += character; + bytes += characterBytes; + } + return result; +} + +function deduplicatedName(name: string, suffix: number): string { + if (suffix === 0) return name; + const extension = path.extname(name); + const suffixText = ` (${suffix})`; + const stem = name.slice(0, -extension.length || undefined); + const extensionBudget = + SESSION_ATTACHMENT_MAX_NAME_BYTES - Buffer.byteLength(suffixText) - 1; + const safeExtension = truncateUtf8(extension, extensionBudget); + const stemBudget = + SESSION_ATTACHMENT_MAX_NAME_BYTES - + Buffer.byteLength(suffixText) - + Buffer.byteLength(safeExtension); + return `${truncateUtf8(stem, stemBudget)}${suffixText}${safeExtension}`; +} + +function imageName(mimeType: string): string { + const extension = mimeType.slice('image/'.length).split(/[;+]/, 1)[0]; + return `image.${extension === 'jpg' ? 'jpeg' : extension || 'img'}`; +} + +function mimeTypeForName(name: string): string { + if ( + ['.ts', '.mts', '.cts', '.tsx'].includes(path.extname(name).toLowerCase()) + ) { + return 'text/plain'; + } + return getSpecificMimeType(name) ?? 'application/octet-stream'; +} + +function isSupportedImageMimeType(mimeType: string): boolean { + return SUPPORTED_IMAGE_MIME_TYPES.has(mimeType); +} + +function isTextMimeType(mimeType: string): boolean { + return ( + mimeType.startsWith('text/') || + mimeType === 'application/json' || + mimeType.endsWith('+json') || + mimeType === 'application/xml' || + mimeType.endsWith('+xml') || + mimeType === 'application/javascript' || + mimeType === 'application/typescript' || + mimeType === 'application/yaml' || + mimeType === 'application/x-yaml' || + mimeType === 'application/toml' + ); +} + +function isTextAttachment(data: Buffer, mimeType: string): boolean { + if (isTextMimeType(mimeType)) return true; + if (mimeType !== 'application/octet-stream' || data.includes(0)) return false; + return Buffer.from(data.toString('utf8'), 'utf8').equals(data); +} + +// Append the unavailable marker to the last text block (or as a new text +// block) so a partially degraded prompt keeps its surviving blocks instead of +// collapsing into one wholesale placeholder. +export function withAttachmentDegradationMarker< + T extends ContentBlock | SessionAttachmentReference, +>(blocks: readonly T[]): T[] { + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.type === 'text') { + if (block.text.endsWith(SESSION_ATTACHMENT_UNAVAILABLE_TEXT)) { + return [...blocks]; + } + const next = [...blocks]; + next[i] = { + type: 'text', + text: `${block.text}\n${SESSION_ATTACHMENT_UNAVAILABLE_TEXT}`, + } as T; + return next; + } + } + return [ + ...blocks, + { type: 'text', text: SESSION_ATTACHMENT_UNAVAILABLE_TEXT } as T, + ]; +} + +export class SessionAttachmentStore { + private directoryPromise?: Promise; + private readonly persistentDirectory?: string; + private activeDirectory?: string; + private pendingItems = 0; + private readonly pendingNames = new Map(); + private readonly pendingDrainWaiters: Array<() => void> = []; + private copying = false; + private closed = false; + + constructor( + private readonly directoryRoot?: string, + sessionId?: string, + ) { + if (!directoryRoot || !sessionId) return; + this.persistentDirectory = path.join( + directoryRoot, + `session-${encodeURIComponent(sessionId)}`, + ); + } + + async putAttachment( + data: Uint8Array, + mimeType: string, + name?: string, + ): Promise { + const isImage = isSupportedImageMimeType(mimeType); + const safeName = safeAttachmentName( + name ?? (isImage ? imageName(mimeType) : ''), + ); + if (!safeName) { + throw new TypeError('Session attachment name is invalid'); + } + const storedMimeType = mimeTypeForName(safeName); + if ( + (isImage && storedMimeType !== mimeType) || + (isSupportedImageMimeType(storedMimeType) && !isImage) + ) { + throw new TypeError('Attachment name and Content-Type do not match'); + } + if (this.closed) throw new Error('Session attachment store is closed'); + if (this.copying) throw new Error('Session attachments are being copied'); + if ( + (isImage && data.byteLength === 0) || + data.byteLength > SESSION_ATTACHMENT_MAX_ITEM_BYTES + ) { + throw new RangeError( + `Session attachment must be at most ${SESSION_ATTACHMENT_MAX_ITEM_BYTES} bytes and images cannot be empty`, + ); + } + let filePath: string | undefined; + this.pendingItems += 1; + try { + const directory = await this.directory(); + let suffix = 0; + for (;;) { + const candidateName = deduplicatedName(safeName, suffix); + filePath = path.join(directory, candidateName); + this.pendingNames.set( + candidateName, + (this.pendingNames.get(candidateName) ?? 0) + 1, + ); + try { + await fs.writeFile(filePath, data, { flag: 'wx' }); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + this.releasePendingName(candidateName); + suffix += 1; + } + } + if (this.closed) { + throw new Error('Session attachment store is closed'); + } + const name = path.basename(filePath); + const storedMimeType = mimeTypeForName(name); + const reference = { + type: isSupportedImageMimeType(storedMimeType) + ? ('image' as const) + : ('resource' as const), + attachmentId: name, + mimeType: storedMimeType, + size: data.byteLength, + } satisfies SessionAttachmentReference; + return reference; + } catch (error) { + if (filePath) await fs.rm(filePath, { force: true }).catch(() => {}); + throw error; + } finally { + if (filePath) this.releasePendingName(path.basename(filePath)); + if (!this.closed) { + this.pendingItems -= 1; + if (this.pendingItems === 0) { + this.resolvePendingDrainWaiters(); + } + } + } + } + + // Validate one block against the store. Ordinary ACP content passes through + // untouched, matching `assertReferences`. + assertReference(block: unknown): void { + if ( + !block || + typeof block !== 'object' || + Array.isArray(block) || + !('attachmentId' in block) + ) { + return; + } + if (!isSessionAttachmentReference(block)) { + throw new SessionAttachmentReferenceError( + 'Invalid session attachment reference', + 'invalid_session_attachment_reference', + ); + } + this.assertStored(block); + } + + assertReferences(content: readonly unknown[]): void { + // One occurrence per attachment: the serializer expands every reference at + // dispatch, so repeated occurrences of one stored blob amplify the + // outbound payload without bound even though only one read is needed. + const seenIds = new Set(); + for (const block of content) { + if ( + !block || + typeof block !== 'object' || + Array.isArray(block) || + !('attachmentId' in block) + ) { + continue; + } + if (!isSessionAttachmentReference(block)) { + throw new SessionAttachmentReferenceError( + 'Invalid session attachment reference', + 'invalid_session_attachment_reference', + ); + } + const id = block.attachmentId; + if (seenIds.has(id)) { + throw new SessionAttachmentReferenceError( + `Session attachment referenced more than once: ${id}`, + 'invalid_session_attachment_reference', + ); + } + seenIds.add(id); + this.assertStored(block); + } + } + + async resolveContent( + content: ReadonlyArray, + memo?: Map>, + ): Promise { + // Resolve each distinct attachment once: duplicate references share the read + // and base64 encode instead of amplifying heap per occurrence. Callers + // resolving several messages in one batch can pass a shared `memo` so a + // attachment referenced from different messages is also read only once. + const pendingById = memo ?? new Map>(); + return await Promise.all( + content.map(async (block) => { + if (!isSessionAttachmentReference(block)) return block; + const id = block.attachmentId; + let pending = pendingById.get(id); + if (!pending) { + const created = this.resolve(block); + pendingById.set(id, created); + // A transient read failure must not poison later resolutions of the + // same attachment: a cached rejection would hand every sibling message + // (and every later lookup) the failure although the store still + // holds the bytes. Evict it so the next lookup reads again. + void created.catch(() => { + if (pendingById.get(id) === created) { + pendingById.delete(id); + } + }); + pending = created; + } + return await pending; + }), + ); + } + + // Per-block variant of `resolveContent` for degrade paths: one unresolvable + // reference drops only itself, keeping the sibling blocks a wholesale + // fallback would discard. Other errors still propagate. + async resolveContentDegrading( + content: ReadonlyArray, + memo?: Map>, + ): Promise<{ + retainedBlocks: Array; + resolvedBlocks: ContentBlock[]; + degraded: number; + }> { + const retainedBlocks: Array = []; + const resolvedBlocks: ContentBlock[] = []; + let degraded = 0; + for (const block of content) { + if (!isSessionAttachmentReference(block)) { + retainedBlocks.push(block); + resolvedBlocks.push(block); + continue; + } + try { + const [resolved] = await this.resolveContent([block], memo); + if (resolved) resolvedBlocks.push(resolved); + retainedBlocks.push(block); + } catch (error) { + if (!(error instanceof SessionAttachmentReferenceError)) throw error; + degraded += 1; + } + } + return { retainedBlocks, resolvedBlocks, degraded }; + } + + async read( + attachmentId: string, + ): Promise<{ data: Buffer; mimeType: string } | undefined> { + const name = safeAttachmentName(attachmentId); + if (!name || name !== attachmentId) return undefined; + const filePath = path.join(await this.directory(), name); + try { + return { + data: await fs.readFile(filePath), + mimeType: mimeTypeForName(name), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + } + + async copyFrom(source: SessionAttachmentStore): Promise { + if (this.closed) throw new Error('Session attachment store is closed'); + if (this.copying) throw new Error('Session attachments are being copied'); + this.copying = true; + try { + if (this.pendingItems > 0) { + await new Promise((resolve) => + this.pendingDrainWaiters.push(resolve), + ); + } + if (this.closed) throw new Error('Session attachment store is closed'); + const sourceDirectory = + source.persistentDirectory ?? source.activeDirectory; + if (!sourceDirectory) return; + let entries; + try { + entries = await fs.readdir(sourceDirectory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + const targetDirectory = await this.directory(); + await Promise.all( + entries + .filter( + (entry) => entry.isFile() && !source.pendingNames.has(entry.name), + ) + .map(async (entry) => { + const sourcePath = path.join(sourceDirectory, entry.name); + try { + await fs.copyFile( + sourcePath, + path.join(targetDirectory, entry.name), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + try { + await fs.stat(sourcePath); + } catch (sourceError) { + if ( + (sourceError as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return; + } + } + } + throw error; + } + }), + ); + } finally { + this.copying = false; + } + } + + async remove(attachmentId: string): Promise { + const name = safeAttachmentName(attachmentId); + if ( + !name || + name !== attachmentId || + this.copying || + this.pendingNames.has(name) + ) { + return false; + } + const directory = await this.directory(); + const filePath = path.join(directory, name); + try { + await fs.unlink(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.pendingItems = 0; + this.pendingNames.clear(); + this.resolvePendingDrainWaiters(); + if (this.persistentDirectory || !this.directoryPromise) return; + const directory = await this.directoryPromise.catch(() => undefined); + if (!directory) return; + await fs.rm(directory, { recursive: true, force: true }); + } + + async delete(): Promise { + if (!this.closed) { + this.closed = true; + this.pendingItems = 0; + this.pendingNames.clear(); + this.resolvePendingDrainWaiters(); + } + const directory = + this.persistentDirectory ?? + (await this.directoryPromise?.catch(() => undefined)); + if (directory) await fs.rm(directory, { recursive: true, force: true }); + } + + private assertStored(reference: SessionAttachmentReference): void { + const id = reference.attachmentId; + const name = safeAttachmentName(id); + let size: number | undefined; + const directory = this.persistentDirectory ?? this.activeDirectory; + if (name && name === id && directory) { + try { + size = statSync(path.join(directory, name)).size; + } catch { + size = undefined; + } + } + const storedMimeType = name ? mimeTypeForName(name) : undefined; + const storedType = storedMimeType + ? isSupportedImageMimeType(storedMimeType) + ? 'image' + : 'resource' + : undefined; + if ( + size !== reference.size || + storedMimeType !== reference.mimeType || + storedType !== reference.type + ) { + throw new SessionAttachmentReferenceError( + `Unknown or unavailable session attachment: ${id}`, + 'session_attachment_gone', + ); + } + } + + private releasePendingName(name: string): void { + const count = this.pendingNames.get(name) ?? 0; + if (count <= 1) this.pendingNames.delete(name); + else this.pendingNames.set(name, count - 1); + } + + private resolvePendingDrainWaiters(): void { + for (const resolve of this.pendingDrainWaiters.splice(0)) resolve(); + } + + private async resolve( + reference: SessionAttachmentReference, + ): Promise { + const id = reference.attachmentId; + const attachment = await this.read(id); + if (!attachment) { + throw new SessionAttachmentReferenceError( + `Unknown or unavailable session attachment: ${id}`, + 'session_attachment_gone', + ); + } + if (reference.type === 'resource') { + const resource = { + uri: `attachment:///${encodeURIComponent(reference.attachmentId)}`, + mimeType: attachment.mimeType, + ...(isTextAttachment(attachment.data, attachment.mimeType) + ? { text: attachment.data.toString('utf8') } + : { blob: attachment.data.toString('base64') }), + }; + return { + type: 'resource', + resource, + } as ContentBlock; + } + return { + type: 'image', + data: attachment.data.toString('base64'), + mimeType: attachment.mimeType, + } as ContentBlock; + } + + private async directory(): Promise { + if (!this.directoryPromise) { + const pending = this.persistentDirectory + ? fs + .mkdir(this.persistentDirectory, { + recursive: true, + mode: 0o700, + }) + .then(() => this.persistentDirectory!) + : this.directoryRoot + ? fs + .mkdir(this.directoryRoot, { recursive: true, mode: 0o700 }) + .then(() => + fs.mkdtemp( + path.join(this.directoryRoot!, 'session-attachment-'), + ), + ) + : fs.mkdtemp(path.join(tmpdir(), 'qwen-session-attachment-')); + const directoryPromise = pending.then((directory) => { + this.activeDirectory = directory; + return directory; + }); + this.directoryPromise = directoryPromise; + void directoryPromise.catch(() => { + if (this.directoryPromise === directoryPromise) + this.directoryPromise = undefined; + }); + } + return await this.directoryPromise; + } +} diff --git a/packages/acp-bridge/src/sessionMedia.test.ts b/packages/acp-bridge/src/sessionMedia.test.ts deleted file mode 100644 index abb3904e186..00000000000 --- a/packages/acp-bridge/src/sessionMedia.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { promises as fs } from 'node:fs'; -import type { ContentBlock } from '@agentclientprotocol/sdk'; -import { describe, expect, it, vi } from 'vitest'; -import { - SESSION_MEDIA_UNAVAILABLE_TEXT, - SESSION_MEDIA_MAX_ITEM_BYTES, - SESSION_MEDIA_MAX_ITEMS, - SESSION_MEDIA_MAX_TOTAL_BYTES, - SessionMediaStore, - withMediaDegradationMarker, -} from './sessionMedia.js'; - -describe('SessionMediaStore', () => { - it('does not append the media degradation marker twice', () => { - const once = withMediaDegradationMarker([ - { type: 'text', text: 'look at this' }, - ]); - - expect(withMediaDegradationMarker(once)).toEqual([ - { - type: 'text', - text: `look at this\n${SESSION_MEDIA_UNAVAILABLE_TEXT}`, - }, - ]); - }); - - it('stores bytes by reference and resolves them only at dispatch', async () => { - const store = new SessionMediaStore(); - try { - const reference = await store.put( - Uint8Array.from([1, 2, 3]), - 'image/png', - ); - - expect(reference).toMatchObject({ - type: 'image', - mimeType: 'image/png', - size: 3, - }); - expect(await store.resolveContent([reference])).toEqual([ - { type: 'image', data: 'AQID', mimeType: 'image/png' }, - ]); - expect(await store.read(reference.mediaId)).toEqual({ - data: Buffer.from([1, 2, 3]), - mimeType: 'image/png', - }); - } finally { - await store.close(); - } - }); - - it('resolves duplicate references with a single read', async () => { - // Duplicate references to one stored item must not multiply the disk - // reads and base64 encodes at dispatch — that amplification let one - // small request pin gigabytes of heap. - const store = new SessionMediaStore(); - const readFile = vi.spyOn(fs, 'readFile'); - try { - const reference = await store.put( - Uint8Array.from([1, 2, 3]), - 'image/png', - ); - readFile.mockClear(); - - const resolved = await store.resolveContent([ - reference, - { ...reference }, - { ...reference }, - ]); - - expect(resolved).toEqual([ - { type: 'image', data: 'AQID', mimeType: 'image/png' }, - { type: 'image', data: 'AQID', mimeType: 'image/png' }, - { type: 'image', data: 'AQID', mimeType: 'image/png' }, - ]); - expect(readFile).toHaveBeenCalledTimes(1); - } finally { - readFile.mockRestore(); - await store.close(); - } - }); - - it('shares reads across resolveContent calls via a caller-supplied memo', async () => { - const store = new SessionMediaStore(); - const readFile = vi.spyOn(fs, 'readFile'); - try { - const reference = await store.put( - Uint8Array.from([1, 2, 3]), - 'image/png', - ); - readFile.mockClear(); - - const memo = new Map>(); - const block = { - type: 'image', - data: 'AQID', - mimeType: 'image/png', - }; - expect(await store.resolveContent([reference], memo)).toEqual([block]); - expect(await store.resolveContent([reference], memo)).toEqual([block]); - expect(readFile).toHaveBeenCalledTimes(1); - - // Omitting the memo keeps the per-call default: a fresh map, so the - // blob is read again. - expect(await store.resolveContent([reference])).toEqual([block]); - expect(readFile).toHaveBeenCalledTimes(2); - } finally { - readFile.mockRestore(); - await store.close(); - } - }); - it('keeps media for the lifetime of the store', async () => { - const store = new SessionMediaStore(); - try { - const reference = await store.put(Uint8Array.of(1), 'image/png'); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2100-01-01T00:00:00Z')); - - expect(await store.read(reference.mediaId)).toBeDefined(); - } finally { - vi.useRealTimers(); - await store.close(); - } - }); - - it('rejects non-image uploads', async () => { - const store = new SessionMediaStore(); - try { - await expect(store.put(Uint8Array.of(1), 'audio/wav')).rejects.toThrow( - 'Session media must be image/*', - ); - } finally { - await store.close(); - } - }); - - it('rejects empty and oversized uploads', async () => { - const store = new SessionMediaStore(); - try { - await expect(store.put(new Uint8Array(), 'image/png')).rejects.toThrow( - /between 1 and/, - ); - await expect( - store.put( - new Uint8Array(SESSION_MEDIA_MAX_ITEM_BYTES + 1), - 'image/png', - ), - ).rejects.toThrow(/between 1 and/); - } finally { - await store.close(); - } - }); - - it('retries directory creation after a transient failure', async () => { - const mkdir = vi - .spyOn(fs, 'mkdtemp') - .mockRejectedValueOnce( - Object.assign(new Error('full'), { code: 'ENOSPC' }), - ); - const store = new SessionMediaStore(); - try { - await expect(store.put(Uint8Array.of(1), 'image/png')).rejects.toThrow( - 'full', - ); - mkdir.mockRestore(); - await expect( - store.put(Uint8Array.of(1), 'image/png'), - ).resolves.toMatchObject({ size: 1 }); - } finally { - mkdir.mockRestore(); - await store.close(); - } - }); - - it('removes a partial file after writing fails', async () => { - const write = vi - .spyOn(fs, 'writeFile') - .mockRejectedValueOnce( - Object.assign(new Error('full'), { code: 'ENOSPC' }), - ); - const remove = vi.spyOn(fs, 'rm'); - const store = new SessionMediaStore(); - try { - await expect(store.put(Uint8Array.of(1), 'image/png')).rejects.toThrow( - 'full', - ); - expect(remove).toHaveBeenCalledWith(expect.any(String), { force: true }); - expect(store.sizeBytes).toBe(0); - } finally { - write.mockRestore(); - remove.mockRestore(); - await store.close(); - } - }); - - it('closes cleanly after directory creation fails', async () => { - const mkdir = vi - .spyOn(fs, 'mkdtemp') - .mockRejectedValueOnce( - Object.assign(new Error('full'), { code: 'ENOSPC' }), - ); - const store = new SessionMediaStore(); - try { - await expect(store.put(Uint8Array.of(1), 'image/png')).rejects.toThrow( - 'full', - ); - await expect(store.close()).resolves.toBeUndefined(); - } finally { - mkdir.mockRestore(); - await store.close(); - } - }); - - it('removes stored media and releases its byte accounting', async () => { - const store = new SessionMediaStore(); - try { - const reference = await store.put(Uint8Array.of(1, 2), 'image/png'); - expect(store.sizeBytes).toBe(2); - await expect(store.remove(reference.mediaId)).resolves.toBe(true); - expect(store.sizeBytes).toBe(0); - await expect(store.read(reference.mediaId)).resolves.toBeUndefined(); - } finally { - await store.close(); - } - }); - - it('forgets media whose backing file disappeared', async () => { - const store = new SessionMediaStore(); - try { - const reference = await store.put(Uint8Array.of(1, 2), 'image/png'); - const read = vi - .spyOn(fs, 'readFile') - .mockRejectedValueOnce( - Object.assign(new Error('gone'), { code: 'ENOENT' }), - ); - try { - await expect(store.read(reference.mediaId)).resolves.toBeUndefined(); - expect(store.sizeBytes).toBe(0); - expect(() => store.assertReferences([reference])).toThrow( - 'Unknown or unavailable session media', - ); - } finally { - read.mockRestore(); - } - } finally { - await store.close(); - } - }); - - it('rejects duplicate references to one mediaId in a single message', async () => { - // A block count cap alone does not bound the resolved payload: the same - // mediaId repeated N times passes admission and expands per occurrence at - // dispatch, so one small upload can serialize into gigabytes. Reject the - // duplicate occurrences at admission. - const store = new SessionMediaStore(); - try { - const reference = await store.put(Uint8Array.of(1, 2, 3), 'image/png'); - expect(() => - store.assertReferences([reference, { ...reference }]), - ).toThrow(/more than once/); - // A single occurrence is still valid. - expect(() => store.assertReferences([reference])).not.toThrow(); - } finally { - await store.close(); - } - }); - - it('rejects references from another session store', async () => { - const first = new SessionMediaStore(); - const second = new SessionMediaStore(); - try { - const reference = await first.put(Uint8Array.of(1), 'image/png'); - expect(() => second.assertReferences([reference])).toThrow( - 'Unknown or unavailable session media', - ); - } finally { - await Promise.all([first.close(), second.close()]); - } - }); - - it('bounds the number of stored objects', async () => { - const store = new SessionMediaStore(); - try { - await Promise.all( - Array.from({ length: SESSION_MEDIA_MAX_ITEMS }, async () => - store.put(Uint8Array.of(1), 'image/png'), - ), - ); - await expect(store.put(Uint8Array.of(1), 'image/png')).rejects.toThrow( - `${SESSION_MEDIA_MAX_ITEMS}-item session limit`, - ); - } finally { - await store.close(); - } - }); - - it('bounds the total bytes stored by one session', async () => { - const write = vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined); - const store = new SessionMediaStore(); - try { - const item = new Uint8Array(SESSION_MEDIA_MAX_ITEM_BYTES); - const accepted = Math.floor( - SESSION_MEDIA_MAX_TOTAL_BYTES / SESSION_MEDIA_MAX_ITEM_BYTES, - ); - for (let index = 0; index < accepted; index += 1) { - await store.put(item, 'image/png'); - } - - await expect(store.put(item, 'image/png')).rejects.toThrow( - /session limit/, - ); - expect(store.sizeBytes).toBe(accepted * item.byteLength); - } finally { - write.mockRestore(); - await store.close(); - } - }); - - it('evicts a rejected memo entry so siblings and retries read again', async () => { - // A transient non-ENOENT read failure must not be cached in a shared - // memo: every message referencing the same mediaId would otherwise await - // the cached rejection although the store still holds the bytes. - const store = new SessionMediaStore(); - const readFile = vi - .spyOn(fs, 'readFile') - .mockRejectedValueOnce( - Object.assign(new Error('too many open files'), { code: 'EMFILE' }), - ); - try { - const reference = await store.put(Uint8Array.of(9, 9), 'image/png'); - const memo = new Map>(); - - await expect(store.resolveContent([reference], memo)).rejects.toThrow( - 'too many open files', - ); - // The failed entry must not stay cached: the next resolution re-reads - // from disk and succeeds. - await expect(store.resolveContent([reference], memo)).resolves.toEqual([ - { type: 'image', data: 'CQk=', mimeType: 'image/png' }, - ]); - expect(readFile).toHaveBeenCalledTimes(2); - } finally { - readFile.mockRestore(); - await store.close(); - } - }); - - it('resolveContentDegrading drops only the unresolvable reference', async () => { - const store = new SessionMediaStore(); - try { - const live = await store.put(Uint8Array.of(1, 2), 'image/png'); - const gone = await store.put(Uint8Array.of(3, 4), 'image/png'); - await store.remove(gone.mediaId); - const text = { type: 'text', text: 'both' } as ContentBlock; - - const result = await store.resolveContentDegrading([text, gone, live]); - - expect(result.degraded).toBe(1); - expect(result.retainedBlocks).toEqual([text, live]); - expect(result.resolvedBlocks).toEqual([ - text, - { type: 'image', data: 'AQI=', mimeType: 'image/png' }, - ]); - } finally { - await store.close(); - } - }); - - it('does not make byte accounting negative when close races put', async () => { - let finishWrite: (() => void) | undefined; - const write = vi.spyOn(fs, 'writeFile').mockImplementationOnce( - async () => - await new Promise((resolve) => { - finishWrite = resolve; - }), - ); - const store = new SessionMediaStore(); - try { - const pending = store.put(Uint8Array.of(1), 'image/png'); - await vi.waitFor(() => expect(write).toHaveBeenCalled()); - await store.close(); - finishWrite?.(); - await expect(pending).rejects.toThrow('Session media store is closed'); - expect((store as unknown as { totalBytes: number }).totalBytes).toBe(0); - } finally { - write.mockRestore(); - await store.close(); - } - }); -}); diff --git a/packages/acp-bridge/src/sessionMedia.ts b/packages/acp-bridge/src/sessionMedia.ts deleted file mode 100644 index 8e746ed993f..00000000000 --- a/packages/acp-bridge/src/sessionMedia.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { randomUUID } from 'node:crypto'; -import { promises as fs } from 'node:fs'; -import { tmpdir } from 'node:os'; -import * as path from 'node:path'; -import type { ContentBlock } from '@agentclientprotocol/sdk'; - -export const SESSION_MEDIA_MAX_ITEM_BYTES = 8 * 1024 * 1024; -export const SESSION_MEDIA_MAX_TOTAL_BYTES = 100 * 1024 * 1024; -export const SESSION_MEDIA_MAX_ITEMS = 256; - -// Text the degrade paths substitute for media the model will not receive. The -// SDK's DaemonSessionClient.hydrateBlock and the web shell's degradation -// detection carry their own copies; keep the wording in sync. -export const SESSION_MEDIA_UNAVAILABLE_TEXT = - '[Attached media is no longer available]'; - -export class SessionMediaReferenceError extends Error { - constructor( - message: string, - readonly code: 'invalid_session_media_reference' | 'session_media_gone', - ) { - super(message); - this.name = 'SessionMediaReferenceError'; - } -} - -export interface SessionMediaReference { - type: 'image'; - mediaId: string; - mimeType: string; - size: number; -} - -interface StoredSessionMedia extends SessionMediaReference { - filePath: string; -} - -export function isSessionMediaReference( - value: unknown, -): value is SessionMediaReference { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const record = value as Record; - return ( - record['type'] === 'image' && - typeof record['mediaId'] === 'string' && - record['mediaId'].length > 0 && - typeof record['mimeType'] === 'string' && - record['mimeType'].startsWith(`${record['type']}/`) && - typeof record['size'] === 'number' && - Number.isSafeInteger(record['size']) && - record['size'] > 0 - ); -} - -// Append the unavailable marker to the last text block (or as a new text -// block) so a partially degraded prompt keeps its surviving blocks instead of -// collapsing into one wholesale placeholder. -export function withMediaDegradationMarker< - T extends ContentBlock | SessionMediaReference, ->(blocks: readonly T[]): T[] { - for (let i = blocks.length - 1; i >= 0; i--) { - const block = blocks[i]; - if (block.type === 'text') { - if (block.text.endsWith(SESSION_MEDIA_UNAVAILABLE_TEXT)) { - return [...blocks]; - } - const next = [...blocks]; - next[i] = { - type: 'text', - text: `${block.text}\n${SESSION_MEDIA_UNAVAILABLE_TEXT}`, - } as T; - return next; - } - } - return [ - ...blocks, - { type: 'text', text: SESSION_MEDIA_UNAVAILABLE_TEXT } as T, - ]; -} - -export class SessionMediaStore { - private readonly records = new Map(); - private directoryPromise?: Promise; - private totalBytes = 0; - private pendingItems = 0; - private closed = false; - - async put( - data: Uint8Array, - mimeType: string, - ): Promise { - if (this.closed) throw new Error('Session media store is closed'); - const type = 'image' as const; - if (!mimeType.startsWith('image/')) { - throw new TypeError('Session media must be image/*'); - } - if ( - data.byteLength === 0 || - data.byteLength > SESSION_MEDIA_MAX_ITEM_BYTES - ) { - throw new RangeError( - `Session media must be between 1 and ${SESSION_MEDIA_MAX_ITEM_BYTES} bytes`, - ); - } - if (this.totalBytes + data.byteLength > SESSION_MEDIA_MAX_TOTAL_BYTES) { - throw new RangeError( - `Session media exceeds the ${SESSION_MEDIA_MAX_TOTAL_BYTES}-byte session limit`, - ); - } - if (this.records.size + this.pendingItems >= SESSION_MEDIA_MAX_ITEMS) { - throw new RangeError( - `Session media exceeds the ${SESSION_MEDIA_MAX_ITEMS}-item session limit`, - ); - } - - const mediaId = randomUUID(); - let filePath: string | undefined; - this.totalBytes += data.byteLength; - this.pendingItems += 1; - try { - const directory = await this.directory(); - filePath = path.join(directory, mediaId); - await fs.writeFile(filePath, data, { flag: 'wx' }); - if (this.closed) { - throw new Error('Session media store is closed'); - } - const record: StoredSessionMedia = { - type, - mediaId, - mimeType, - size: data.byteLength, - filePath, - }; - this.records.set(mediaId, record); - return { type, mediaId, mimeType, size: data.byteLength }; - } catch (error) { - if (filePath) await fs.rm(filePath, { force: true }).catch(() => {}); - if (!this.closed) this.totalBytes -= data.byteLength; - throw error; - } finally { - if (!this.closed) this.pendingItems -= 1; - } - } - - // Validate one block against the store. Blocks without a `mediaId` (inline - // media, text) pass through untouched, matching `assertReferences`. - assertReference(block: unknown): void { - if ( - !block || - typeof block !== 'object' || - Array.isArray(block) || - !('mediaId' in block) - ) { - return; - } - if (!isSessionMediaReference(block)) { - throw new SessionMediaReferenceError( - 'Invalid session media reference', - 'invalid_session_media_reference', - ); - } - this.assertStored(block); - } - - assertReferences(content: readonly unknown[]): void { - // One occurrence per mediaId: the serializer expands every reference at - // dispatch, so repeated occurrences of one stored blob amplify the - // outbound payload without bound even though only one read is needed. - const seenMediaIds = new Set(); - for (const block of content) { - if ( - !block || - typeof block !== 'object' || - Array.isArray(block) || - !('mediaId' in block) - ) { - continue; - } - if (!isSessionMediaReference(block)) { - throw new SessionMediaReferenceError( - 'Invalid session media reference', - 'invalid_session_media_reference', - ); - } - if (seenMediaIds.has(block.mediaId)) { - throw new SessionMediaReferenceError( - `Session media referenced more than once: ${block.mediaId}`, - 'invalid_session_media_reference', - ); - } - seenMediaIds.add(block.mediaId); - this.assertStored(block); - } - } - - async resolveContent( - content: ReadonlyArray, - memo?: Map>, - ): Promise { - // Resolve each distinct mediaId once: duplicate references share the read - // and base64 encode instead of amplifying heap per occurrence. Callers - // resolving several messages in one batch can pass a shared `memo` so a - // mediaId referenced from different messages is also read only once. - const pendingByMediaId = memo ?? new Map>(); - return await Promise.all( - content.map(async (block) => { - if (!isSessionMediaReference(block)) return block; - let pending = pendingByMediaId.get(block.mediaId); - if (!pending) { - const created = this.resolve(block); - pendingByMediaId.set(block.mediaId, created); - // A transient read failure must not poison later resolutions of the - // same mediaId: a cached rejection would hand every sibling message - // (and every later lookup) the failure although the store still - // holds the bytes. Evict it so the next lookup reads again. - void created.catch(() => { - if (pendingByMediaId.get(block.mediaId) === created) { - pendingByMediaId.delete(block.mediaId); - } - }); - pending = created; - } - return await pending; - }), - ); - } - - // Per-block variant of `resolveContent` for degrade paths: one unresolvable - // reference drops only itself, keeping the sibling blocks a wholesale - // fallback would discard. Non-media errors still propagate. - async resolveContentDegrading( - content: ReadonlyArray, - memo?: Map>, - ): Promise<{ - retainedBlocks: Array; - resolvedBlocks: ContentBlock[]; - degraded: number; - }> { - const retainedBlocks: Array = []; - const resolvedBlocks: ContentBlock[] = []; - let degraded = 0; - for (const block of content) { - if (!isSessionMediaReference(block)) { - retainedBlocks.push(block); - resolvedBlocks.push(block); - continue; - } - try { - const [resolved] = await this.resolveContent([block], memo); - if (resolved) resolvedBlocks.push(resolved); - retainedBlocks.push(block); - } catch (error) { - if (!(error instanceof SessionMediaReferenceError)) throw error; - degraded += 1; - } - } - return { retainedBlocks, resolvedBlocks, degraded }; - } - - async read( - mediaId: string, - ): Promise<{ data: Buffer; mimeType: string } | undefined> { - const record = this.records.get(mediaId); - if (!record) return undefined; - try { - return { - data: await fs.readFile(record.filePath), - mimeType: record.mimeType, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - if (this.records.delete(mediaId)) this.totalBytes -= record.size; - return undefined; - } - throw error; - } - } - - async remove(mediaId: string): Promise { - const record = this.records.get(mediaId); - if (!record) return false; - this.records.delete(mediaId); - this.totalBytes -= record.size; - await fs.rm(record.filePath, { force: true }); - return true; - } - - get sizeBytes(): number { - return this.totalBytes; - } - - async close(): Promise { - if (this.closed) return; - this.closed = true; - this.records.clear(); - this.totalBytes = 0; - this.pendingItems = 0; - if (!this.directoryPromise) return; - const directory = await this.directoryPromise.catch(() => undefined); - if (!directory) return; - await fs.rm(directory, { recursive: true, force: true }); - } - - private assertStored(reference: SessionMediaReference): void { - const stored = this.records.get(reference.mediaId); - if ( - !stored || - stored.type !== reference.type || - stored.mimeType !== reference.mimeType || - stored.size !== reference.size - ) { - throw new SessionMediaReferenceError( - `Unknown or unavailable session media: ${reference.mediaId}`, - 'session_media_gone', - ); - } - } - - private async resolve( - reference: SessionMediaReference, - ): Promise { - const media = await this.read(reference.mediaId); - if (!media || media.mimeType !== reference.mimeType) { - throw new SessionMediaReferenceError( - `Unknown or unavailable session media: ${reference.mediaId}`, - 'session_media_gone', - ); - } - return { - type: reference.type, - data: media.data.toString('base64'), - mimeType: media.mimeType, - } as ContentBlock; - } - - private async directory(): Promise { - if (!this.directoryPromise) { - const pending = fs.mkdtemp(path.join(tmpdir(), 'qwen-session-media-')); - this.directoryPromise = pending; - void pending.catch(() => { - if (this.directoryPromise === pending) - this.directoryPromise = undefined; - }); - } - return await this.directoryPromise; - } -} diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index 8e40455ed2e..46dff1d325e 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -585,7 +585,7 @@ describe('createTranscriptReplayMachine', () => { const tagged = '\ninjected hook context\n'; - it('replays daemon media references without embedding base64', () => { + it('replays daemon attachment references without embedding base64', () => { const projected = updates( createTranscriptReplayMachine(), record('user-media-ref', 'user', { @@ -593,10 +593,10 @@ describe('createTranscriptReplayMachine', () => { systemPayload: { displayText: 'describe this', hookContext: '', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -614,7 +614,7 @@ describe('createTranscriptReplayMachine', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -622,6 +622,49 @@ describe('createTranscriptReplayMachine', () => { ]); }); + it('replays file attachment references for hydration and preview', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('user-file-ref', 'user', { + message: { + role: 'user', + parts: [{ text: 'check\n\n@attachment:///notes.json' }], + }, + systemPayload: { + displayText: 'check\n\n@attachment:///notes.json', + hookContext: '', + attachmentReferences: [ + { + type: 'resource', + attachmentId: 'notes.json', + mimeType: 'application/json', + size: 6, + }, + ], + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'text', + text: 'check\n\n@attachment:///notes.json', + }, + }, + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'resource', + attachmentId: 'notes.json', + mimeType: 'application/json', + size: 6, + }, + }, + ]); + }); + it('replaces text parts with displayText while preserving image parts', () => { // displayText must replace all model-facing text while the image part // survives (the previous early-return path dropped it). @@ -905,7 +948,7 @@ describe('createTranscriptReplayMachine', () => { }); }); - it('replays media references from a mid-turn user record', () => { + it('replays attachment references from a mid-turn user record', () => { const projected = updates( createTranscriptReplayMachine(), record('mid-turn-media', 'user', { @@ -913,10 +956,10 @@ describe('createTranscriptReplayMachine', () => { message: { role: 'user', parts: [{ text: 'inspect image' }] }, systemPayload: { displayText: 'inspect image', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -938,7 +981,7 @@ describe('createTranscriptReplayMachine', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -961,10 +1004,10 @@ describe('createTranscriptReplayMachine', () => { }, systemPayload: { displayText: '', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'media-only', + attachmentId: 'media-only', mimeType: 'image/png', size: 3, }, @@ -978,7 +1021,7 @@ describe('createTranscriptReplayMachine', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-only', + attachmentId: 'media-only', mimeType: 'image/png', size: 3, }, diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index 3c93043b45c..3a76945c7ef 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -259,13 +259,13 @@ export function createTranscriptImageUpdate( } as SessionUpdate; } -function createTranscriptMediaReferenceUpdate( +function createTranscriptAttachmentReferenceUpdate( reference: Record, options: UpdateMetaOptions, ): SessionUpdate | undefined { if ( - (reference['type'] !== 'image' && reference['type'] !== 'audio') || - typeof reference['mediaId'] !== 'string' || + (reference['type'] !== 'image' && reference['type'] !== 'resource') || + typeof reference['attachmentId'] !== 'string' || typeof reference['mimeType'] !== 'string' || typeof reference['size'] !== 'number' ) { @@ -276,7 +276,7 @@ function createTranscriptMediaReferenceUpdate( sessionUpdate: 'user_message_chunk', content: { type: reference['type'], - mediaId: reference['mediaId'], + attachmentId: reference['attachmentId'], mimeType: reference['mimeType'], size: reference['size'], }, @@ -598,7 +598,7 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { : undefined; if (record.subtype === 'mid_turn_user_message' && displayText === '') { const media = [ - ...this.projectUserMediaReferences(payload, emit, replayMeta), + ...this.projectUserAttachmentReferences(payload, emit, replayMeta), ]; if (media.length > 0) { yield* media; @@ -629,7 +629,7 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { : {}), }), ); - yield* this.projectUserMediaReferences(payload, emit, replayMeta); + yield* this.projectUserAttachmentReferences(payload, emit, replayMeta); return; } if (record.subtype !== 'mid_turn_user_message') return; @@ -648,7 +648,7 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { projection.displayText, ), ); - yield* this.projectUserMediaReferences(payload, emit, replayMeta); + yield* this.projectUserAttachmentReferences(payload, emit, replayMeta); return; } @@ -660,19 +660,19 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { undefined, projection.parts, ); - yield* this.projectUserMediaReferences(payload, emit, replayMeta); + yield* this.projectUserAttachmentReferences(payload, emit, replayMeta); } - private *projectUserMediaReferences( + private *projectUserAttachmentReferences( payload: Record | undefined, emit: (update: SessionUpdate) => TranscriptReplayEmission, meta: UpdateMetaOptions, ): Iterable { - const references = payload?.['mediaReferences']; + const references = payload?.['attachmentReferences']; if (!Array.isArray(references)) return; for (const reference of references) { if (!isObjectRecord(reference)) continue; - const update = createTranscriptMediaReferenceUpdate(reference, meta); + const update = createTranscriptAttachmentReferenceUpdate(reference, meta); if (update) yield emit(update); } } diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index c73eeb28627..f0380c6c424 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5518,13 +5518,19 @@ describe('Session', () => { ]); }); - it('records daemon media references for transcript replay', async () => { - const mediaReference = { + it('records daemon attachment references for transcript replay', async () => { + const imageReference = { type: 'image' as const, - mediaId: 'media-1', + attachmentId: 'image.png', mimeType: 'image/png', size: 3, }; + const fileReference = { + type: 'resource' as const, + attachmentId: 'notes.json', + mimeType: 'application/json', + size: 6, + }; mockChat.sendMessageStream = vi .fn() .mockResolvedValue(createEmptyStream()); @@ -5534,9 +5540,17 @@ describe('Session', () => { prompt: [ { type: 'text', text: 'describe this' }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { + type: 'resource', + resource: { + uri: 'attachment:///notes.json', + mimeType: 'application/json', + text: '你好', + }, + }, ], _meta: { - 'qwen.daemon.mediaReferences': [mediaReference], + 'qwen.daemon.attachmentReferences': [imageReference, fileReference], }, }); @@ -5546,15 +5560,15 @@ describe('Session', () => { { displayText: 'describe this', hookContext: '', - mediaReferences: [mediaReference], + attachmentReferences: [imageReference, fileReference], }, ); }); - it('records every media reference allowed by the session store', async () => { - const mediaReferences = Array.from({ length: 256 }, (_, index) => ({ + it('records 256 attachment references from one prompt', async () => { + const attachmentReferences = Array.from({ length: 256 }, (_, index) => ({ type: 'image' as const, - mediaId: `media-${index}`, + attachmentId: `media-${index}`, mimeType: 'image/png', size: 3, })); @@ -5566,14 +5580,42 @@ describe('Session', () => { sessionId: 'test-session-id', prompt: [{ type: 'text', text: 'describe these' }], _meta: { - 'qwen.daemon.mediaReferences': mediaReferences, + 'qwen.daemon.attachmentReferences': attachmentReferences, }, }); expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalledWith( 'describe these', undefined, - expect.objectContaining({ mediaReferences }), + expect.objectContaining({ attachmentReferences }), + ); + }); + + it('records empty file attachment references', async () => { + const attachmentReference = { + type: 'resource' as const, + attachmentId: 'empty.txt', + mimeType: 'text/plain', + size: 0, + }; + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'inspect this' }], + _meta: { + 'qwen.daemon.attachmentReferences': [attachmentReference], + }, + }); + + expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalledWith( + 'inspect this', + undefined, + expect.objectContaining({ + attachmentReferences: [attachmentReference], + }), ); }); @@ -11600,6 +11642,22 @@ describe('Session', () => { mimeType: 'image/png', data: 'iVBORw0KGgo=', }, + { + type: 'resource', + resource: { + uri: 'attachment:///mixed-notes.txt', + mimeType: 'text/plain', + text: 'mixed private contents', + }, + }, + { + type: 'resource', + resource: { + uri: 'attachment:///mixed.pdf', + mimeType: 'application/pdf', + blob: 'AP8B', + }, + }, { type: 'audio', mimeType: 'audio/wav', @@ -11622,13 +11680,25 @@ describe('Session', () => { }, ], displayText: 'please inspect this image', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 8, }, + { + type: 'resource', + attachmentId: 'mixed-notes.txt', + mimeType: 'text/plain', + size: 22, + }, + { + type: 'resource', + attachmentId: 'mixed.pdf', + mimeType: 'application/pdf', + size: 3, + }, ], }, { @@ -11640,15 +11710,50 @@ describe('Session', () => { }, ], displayText: '', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'image-2', + attachmentId: 'image-2', mimeType: 'image/png', size: 10, }, ], }, + { + content: [ + { type: 'text', text: 'inspect notes' }, + { + type: 'resource', + resource: { + uri: 'attachment:///notes.txt', + mimeType: 'text/plain', + text: 'private attachment contents', + }, + }, + ], + displayText: 'inspect notes', + attachmentReferences: [ + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 27, + }, + ], + }, + { + content: [ + { + type: 'resource', + resource: { + uri: 'attachment:///inline-only.txt', + mimeType: 'text/plain', + text: 'inline only contents', + }, + }, + ], + displayText: '', + }, ], }); mockChat.sendMessageStream = vi @@ -11682,7 +11787,7 @@ describe('Session', () => { }; const midTurnParts: Part[] = [ { - text: '\n[User message received during tool execution]: please inspect this image', + text: '\n[User message received during tool execution]: please inspect this image@attachment:///mixed-notes.txt@attachment:///mixed.pdf', }, { inlineData: { @@ -11690,6 +11795,12 @@ describe('Session', () => { data: 'iVBORw0KGgo=', }, }, + { + inlineData: { + mimeType: 'application/pdf', + data: 'AP8B', + }, + }, audioFallbackPart, ]; const secondCall = vi.mocked(mockChat.sendMessageStream).mock.calls[1]; @@ -11723,16 +11834,84 @@ describe('Session', () => { expect( mockChatRecordingService.recordMidTurnUserMessage, ).toHaveBeenCalledWith( - [midTurnParts[0], midTurnParts[2]], + [midTurnParts[0], midTurnParts[3]], 'please inspect this image', undefined, [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 8, }, + { + type: 'resource', + attachmentId: 'mixed-notes.txt', + mimeType: 'text/plain', + size: 22, + }, + { + type: 'resource', + attachmentId: 'mixed.pdf', + mimeType: 'application/pdf', + size: 3, + }, + ], + ); + expect( + mockChatRecordingService.recordMidTurnUserMessage, + ).toHaveBeenCalledWith( + [ + { + text: '\n[User message received during tool execution]: @attachment:///inline-only.txt', + }, + { + text: 'File: attachment:///inline-only.txt\ninline only contents', + }, + ], + '[User message with attachments]', + ); + expect( + mockChatRecordingService.recordMidTurnUserMessage.mock.calls.flatMap( + ([parts]) => parts, + ), + ).not.toContainEqual({ + text: 'File: attachment:///notes.txt\nprivate attachment contents', + }); + expect( + mockChatRecordingService.recordMidTurnUserMessage.mock.calls.flatMap( + ([parts]) => parts, + ), + ).not.toContainEqual({ + text: 'File: attachment:///mixed-notes.txt\nmixed private contents', + }); + expect( + mockChatRecordingService.recordMidTurnUserMessage.mock.calls.flatMap( + ([parts]) => parts, + ), + ).not.toContainEqual({ + inlineData: { + mimeType: 'application/pdf', + data: 'AP8B', + }, + }); + expect( + mockChatRecordingService.recordMidTurnUserMessage, + ).toHaveBeenCalledWith( + [ + { + text: '\n[User message received during tool execution]: inspect notes@attachment:///notes.txt', + }, + ], + 'inspect notes', + undefined, + [ + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 27, + }, ], ); expect( @@ -11744,7 +11923,7 @@ describe('Session', () => { [ { type: 'image', - mediaId: 'image-2', + attachmentId: 'image-2', mimeType: 'image/png', size: 10, }, @@ -11888,10 +12067,10 @@ describe('Session', () => { displayText: '', // One reference for two image blocks -> references will NOT be // persisted, so displayText must not be ''. - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'ref-1', + attachmentId: 'ref-1', mimeType: 'image/png', size: 4, }, @@ -11985,10 +12164,10 @@ describe('Session', () => { }, ], displayText: 'voice note', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 4, }, @@ -12091,10 +12270,10 @@ describe('Session', () => { }, ], displayText: 'compare with image', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 8, }, @@ -12142,7 +12321,7 @@ describe('Session', () => { [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 8, }, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index eb270962d20..02c86209633 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -205,13 +205,13 @@ import { ENV_ACP_REPEATED_TOOL_FAILURE_GUARD } from '../../config/shared-env-key import { type ActiveWorkHoldV1, DAEMON_CHANNEL_DELIVERY_META_KEY, - DAEMON_MEDIA_REFERENCES_META_KEY, + DAEMON_ATTACHMENT_REFERENCES_META_KEY, DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, MID_TURN_QUEUE_DRAIN_METHOD, isValidTrustedModelPrompt, TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD, } from '@qwen-code/acp-bridge/bridgeTypes'; -import type { SessionMediaReference } from '@qwen-code/acp-bridge/sessionMedia'; +import type { SessionAttachmentReference } from '@qwen-code/acp-bridge/sessionAttachments'; import { SERVE_CONTROL_EXT_METHODS } from '@qwen-code/acp-bridge/status'; import { getCommandSubcommandNames } from '../../services/commandMetadata.js'; import { cleanupReviewWorktreeLeases } from '../../services/review-worktree-lease.js'; @@ -361,41 +361,41 @@ const NEW_PROMPT_ABORT_REASON = 'qwen:new-prompt'; const SESSION_DISPOSE_ABORT_REASON = 'qwen:session-dispose'; const DAEMON_RETRY_META_KEY = 'qwen.daemon.retry'; const DAEMON_CONTINUE_META_KEY = 'qwen.daemon.continueLastTurn'; -const MAX_DAEMON_MEDIA_REFERENCES = 256; - -function readDaemonMediaReferences( +const MAX_DAEMON_ATTACHMENT_REFERENCES = 256; +function readDaemonAttachmentReferences( value: unknown, -): SessionMediaReference[] | undefined { +): SessionAttachmentReference[] | undefined { if ( !Array.isArray(value) || value.length === 0 || - value.length > MAX_DAEMON_MEDIA_REFERENCES + value.length > MAX_DAEMON_ATTACHMENT_REFERENCES ) { return undefined; } - const references: SessionMediaReference[] = []; + const references: SessionAttachmentReference[] = []; for (const item of value) { if (!item || typeof item !== 'object' || Array.isArray(item)) { return undefined; } const reference = item as Record; if ( - reference['type'] !== 'image' || - typeof reference['mediaId'] !== 'string' || - reference['mediaId'].length === 0 || - reference['mediaId'].length > 128 || + (reference['type'] !== 'image' && reference['type'] !== 'resource') || + typeof reference['attachmentId'] !== 'string' || + reference['attachmentId'].length === 0 || + reference['attachmentId'].length > 255 || typeof reference['mimeType'] !== 'string' || reference['mimeType'].length === 0 || reference['mimeType'].length > 128 || typeof reference['size'] !== 'number' || !Number.isSafeInteger(reference['size']) || - reference['size'] <= 0 + reference['size'] < 0 || + (reference['type'] === 'image' && reference['size'] === 0) ) { return undefined; } references.push({ type: reference['type'], - mediaId: reference['mediaId'], + attachmentId: reference['attachmentId'], mimeType: reference['mimeType'], size: reference['size'], }); @@ -953,7 +953,7 @@ type DrainedMidTurnMessage = kind: 'structured'; content: ContentBlock[]; displayText: string; - mediaReferences?: SessionMediaReference[]; + attachmentReferences?: SessionAttachmentReference[]; }; function isRecord(value: unknown): value is Record { @@ -1060,8 +1060,13 @@ function isEmbeddedResourceResource( return typeof value['blob'] === 'string'; } -function hasInlineMediaContentBlock(content: ContentBlock[]): boolean { - return content.some((part) => part.type === 'image' || part.type === 'audio'); +function hasInlineAttachmentContentBlock(content: ContentBlock[]): boolean { + return content.some( + (part) => + part.type === 'image' || + part.type === 'audio' || + part.type === 'resource', + ); } function extractTurnPromptText(content: ContentBlock[]): string { @@ -1100,27 +1105,45 @@ function truncateTurnText(text: string): { return { text: text.slice(0, TURN_RESULT_TEXT_MAX_CHARS), truncated: true }; } -function stripReferencedInlineDataParts( +function stripReferencedAttachmentDataParts( parts: Part[], content: ContentBlock[], ): Part[] { - const coveredByKey = new Map(); + const inlineDataCounts = new Map(); + const textCounts = new Map(); for (const block of content) { - if (block.type !== 'image') continue; - const key = `${block.mimeType}\u0000${block.data}`; - coveredByKey.set(key, (coveredByKey.get(key) ?? 0) + 1); + if (block.type === 'image') { + const key = `${block.mimeType}\u0000${block.data}`; + inlineDataCounts.set(key, (inlineDataCounts.get(key) ?? 0) + 1); + continue; + } + if (block.type !== 'resource') continue; + const resource = block.resource; + if ('blob' in resource) { + const key = `${resource.mimeType ?? 'application/octet-stream'}\u0000${resource.blob}`; + inlineDataCounts.set(key, (inlineDataCounts.get(key) ?? 0) + 1); + } else if (resource.text) { + const text = `File: ${resource.uri}\n${resource.text}`; + textCounts.set(text, (textCounts.get(text) ?? 0) + 1); + } } - if (coveredByKey.size === 0) return parts; return parts.filter((part) => { - const inlineData = part.inlineData; - if (inlineData === undefined || typeof inlineData.data !== 'string') { - return true; + if (part.inlineData && typeof part.inlineData.data === 'string') { + const key = `${part.inlineData.mimeType ?? ''}\u0000${part.inlineData.data}`; + const remaining = inlineDataCounts.get(key) ?? 0; + if (remaining > 0) { + inlineDataCounts.set(key, remaining - 1); + return false; + } } - const key = `${inlineData.mimeType ?? ''}\u0000${inlineData.data}`; - const remaining = coveredByKey.get(key) ?? 0; - if (remaining === 0) return true; - coveredByKey.set(key, remaining - 1); - return false; + if (typeof part.text === 'string') { + const remaining = textCounts.get(part.text) ?? 0; + if (remaining > 0) { + textCounts.set(part.text, remaining - 1); + return false; + } + } + return true; }); } @@ -1186,13 +1209,13 @@ function getStructuredMidTurnDisplayText( if (text) return text; - // Only records that WILL persist media references keep '' (replay then - // projects the media ids). The gate must match #buildMidTurnParts' + // Only records that WILL persist attachment references keep '' (replay then + // projects the attachment ids). The gate must match #buildMidTurnParts' // persistence condition exactly; a record that will not carry references // needs the visible placeholder, because resume and replay fall back to the // recorded parts — which start with the raw internal prefix — when // displayText is empty. - if (!willPersistReferences && hasInlineMediaContentBlock(content)) { + if (!willPersistReferences && hasInlineAttachmentContentBlock(content)) { return '[User message with attachments]'; } @@ -1213,17 +1236,19 @@ function parseMidTurnDrainResponse(response: unknown): DrainedMidTurnMessage[] { item['displayText'], ); if (content.length === 0) return []; - const mediaReferences = readDaemonMediaReferences( - item['mediaReferences'], + const attachmentReferences = readDaemonAttachmentReferences( + item['attachmentReferences'], ); // Same gate #buildMidTurnParts uses to decide whether references are // persisted; display text must agree or a mixed inline+reference // message records displayText:'' with NO references — a shape replay // and resume cannot project. const willPersistReferences = - mediaReferences !== undefined && - mediaReferences.length === - content.filter((block) => block.type === 'image').length; + attachmentReferences !== undefined && + attachmentReferences.length === + content.filter( + (block) => block.type === 'image' || block.type === 'resource', + ).length; return [ { kind: 'structured', @@ -1233,7 +1258,7 @@ function parseMidTurnDrainResponse(response: unknown): DrainedMidTurnMessage[] { item['displayText'], willPersistReferences, ), - ...(mediaReferences ? { mediaReferences } : {}), + ...(attachmentReferences ? { attachmentReferences } : {}), }, ]; }, @@ -1276,8 +1301,9 @@ function isValidMidTurnDrainResponse( Array.isArray(item['content']) && item['content'].length > 0 && item['content'].every(isContentBlock) && - (item['mediaReferences'] === undefined || - readDaemonMediaReferences(item['mediaReferences']) !== undefined), + (item['attachmentReferences'] === undefined || + readDaemonAttachmentReferences(item['attachmentReferences']) !== + undefined), ); } @@ -4475,15 +4501,15 @@ export class Session implements SessionContext { // (R18-6) — while every other slash command records here, // BEFORE its action runs: `/clear` swaps in a fresh recorder // inside its action, so its record must land first (R20-9). - const mediaReferences = readDaemonMediaReferences( - promptMetadata?.[DAEMON_MEDIA_REFERENCES_META_KEY], + const attachmentReferences = readDaemonAttachmentReferences( + promptMetadata?.[DAEMON_ATTACHMENT_REFERENCES_META_KEY], ); const recorder = this.config.getChatRecordingService(); - if (promptDisplayText !== undefined || mediaReferences) { + if (promptDisplayText !== undefined || attachmentReferences) { recorder?.recordUserMessage(promptText, goalTurn?.permit, { displayText: promptDisplayText ?? promptText, hookContext: '', - ...(mediaReferences ? { mediaReferences } : {}), + ...(attachmentReferences ? { attachmentReferences } : {}), }); } else if (goalTurn) { recorder?.recordUserMessage(promptText, goalTurn.permit); @@ -7043,23 +7069,25 @@ export class Session implements SessionContext { rawParts = [{ text: displayText }]; if ( message.kind === 'structured' && - hasInlineMediaContentBlock(message.content) + hasInlineAttachmentContentBlock(message.content) ) { rawParts.push({ text: MID_TURN_ATTACHMENT_PROCESSING_FAILURE_TEXT }); } } const built = prefixMidTurnUserMessageParts(rawParts, displayText); const recorder = this.config.getChatRecordingService(); - if (message.kind === 'structured' && message.mediaReferences) { - const everyMediaBlockHasAReference = - message.mediaReferences.length === - message.content.filter((block) => block.type === 'image').length; - if (everyMediaBlockHasAReference) { + if (message.kind === 'structured' && message.attachmentReferences) { + const everyAttachmentBlockHasAReference = + message.attachmentReferences.length === + message.content.filter( + (block) => block.type === 'image' || block.type === 'resource', + ).length; + if (everyAttachmentBlockHasAReference) { recorder?.recordMidTurnUserMessage( - stripReferencedInlineDataParts(built, message.content), + stripReferencedAttachmentDataParts(built, message.content), displayText, undefined, - message.mediaReferences, + message.attachmentReferences, ); } else { recorder?.recordMidTurnUserMessage(built, displayText); @@ -11800,7 +11828,11 @@ export class Session implements SessionContext { // with its content block by the "@path" token left in the prompt text and // the "--- Content from ... ---" delimiter labels, not by position, so // leading with the content is safe. - const referenceParts: Part[] = [...extensionParts, ...mcpServerParts]; + const referenceParts: Part[] = [ + ...partsToSend.filter((part) => 'inlineData' in part), + ...extensionParts, + ...mcpServerParts, + ]; // Read files using readManyFiles utility if (pathSpecsToRead.length > 0) { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 7ff94d2284c..46044d94f72 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -53,10 +53,9 @@ export const SERVE_CAPABILITY_REGISTRY = { session_side_task: { since: 'v1' }, session_prompt: { since: 'v1' }, session_turn_status: { since: 'v1' }, - // Prompts and mid-turn messages support session-scoped media uploaded once - // and referenced by `mediaId`. The bridge resolves bytes only when ACP input - // is dispatched, keeping base64 out of JSON and SSE payloads. - session_media: { since: 'v1' }, + // Prompts and mid-turn messages reference session-scoped image and file + // attachments by their stored filename. + session_attachments: { since: 'v1' }, session_mid_turn_message_mutation: { since: 'v1' }, // Daemon-owned reconciliation surface for mid-turn messages: // `GET /session/:id/mid-turn-messages` returns the messages still waiting diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 9f75ba40bc8..45f53afb53e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -216,23 +216,17 @@ const CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS = 60_000; // Media blocks are resolved into inline bytes at dispatch, so an unbounded // content array lets one small request fan out into gigabytes of heap (a // repeated reference resolves to the same 8 MiB image once per occurrence). -// 256 matches the session media store's item cap, so a message can still -// reference every stored item. +// Keep a single request from expanding an unbounded number of content blocks. const MEDIA_CONTENT_MAX_BLOCKS = 256; -// SVG can carry scripts; this origin also hosts the daemon API and Web -// Shell UI, and stored bytes are served back to browsers — raster formats -// only. Compare the normalized media type: standards-conformant spelling -// variants (`image/svg+xml;charset=utf-8`, `image/SVG+XML`) must not slip -// past an exact-string match. +// SVG is allowed as an ordinary file resource but never as an inline image. +// Compare the normalized media type so spelling variants cannot bypass the +// image-block check. function isSvgMimeType(mimeType: string | undefined): boolean { return mimeType?.split(';', 1)[0]?.trim().toLowerCase() === 'image/svg+xml'; } -// Shared per-block validation for the prompt and mid-turn routes. SVG can -// carry scripts; this origin also hosts the daemon API and Web Shell UI, and -// stored bytes are served back to browsers — raster formats only, matching -// the upload route's policy. +// Shared per-block validation for the prompt and mid-turn routes. type MediaBlockParseResult = | { valid: true; block: BridgePromptContentBlock } | { valid: false; code: 'not-object' | 'invalid-shape' | 'svg' }; @@ -244,32 +238,50 @@ function parseMediaContentBlock(block: unknown): MediaBlockParseResult { const record = block as Record; const type = record['type']; const data = record['data']; - const mediaId = record['mediaId']; + const attachmentId = record['attachmentId']; const mimeType = record['mimeType']; const size = record['size']; const inline = typeof data === 'string' && data.length > 0; const reference = - typeof mediaId === 'string' && - mediaId.length > 0 && + typeof attachmentId === 'string' && + attachmentId.length > 0 && typeof size === 'number' && Number.isSafeInteger(size) && - size > 0; + size >= 0 && + (type !== 'image' || size > 0); + if (type === 'resource' && !reference) { + const resource = record['resource']; + if (typeof resource !== 'object' || resource === null) { + return { valid: false, code: 'invalid-shape' }; + } + const value = resource as Record; + const hasText = typeof value['text'] === 'string'; + const hasBlob = typeof value['blob'] === 'string'; + if ( + typeof value['uri'] !== 'string' || + value['uri'].length === 0 || + hasText === hasBlob + ) { + return { valid: false, code: 'invalid-shape' }; + } + return { valid: true, block: block as BridgePromptContentBlock }; + } if ( - type !== 'image' || - inline === reference || + (type !== 'image' && type !== 'resource') || + (type === 'image' ? inline === reference : inline || !reference) || typeof mimeType !== 'string' || - !mimeType.startsWith(`${type}/`) + (type === 'image' && !mimeType.startsWith('image/')) ) { return { valid: false, code: 'invalid-shape' }; } - if (isSvgMimeType(mimeType)) { + if (type === 'image' && isSvgMimeType(mimeType)) { return { valid: false, code: 'svg' }; } return { valid: true, block: inline ? ({ type, data, mimeType } as BridgePromptContentBlock) - : ({ type, mediaId, mimeType, size } as BridgePromptContentBlock), + : ({ type, attachmentId, mimeType, size } as BridgePromptContentBlock), }; } @@ -283,7 +295,7 @@ function mediaBlockParseError( if (code === 'svg') { return 'SVG images are not supported'; } - return `each ${entryLabel} must be an image block with either \`data\`, or \`mediaId\` and \`size\`, plus a matching \`mimeType\``; + return `each ${entryLabel} must be an inline content block or carry \`attachmentId\`, \`size\`, and \`mimeType\``; } const PRIMARY_ONLY_LIVE_SESSION_ROUTES = ['POST /session/:id/cd'] as const; const PRIMARY_OR_INTERNAL_LIVE_SESSION_ROUTES = [ @@ -4281,9 +4293,9 @@ export function registerSessionRoutes( ); app.post( - '/session/:id/media', + '/session/:id/attachments', mutate(), - express.raw({ type: 'image/*', limit: '8mb' }), + express.raw({ type: '*/*', limit: '8mb' }), ((error, _req, res, next) => { if ( error && @@ -4297,39 +4309,53 @@ export function registerSessionRoutes( next(error); }) satisfies ErrorRequestHandler, withOwnerMutableSession( - 'POST /session/:id/media', + 'POST /session/:id/attachments', async (req, res, sessionId, runtime) => { + const encodedName = req.headers['x-qwen-attachment-name']; const contentType = req.headers['content-type'] ?.split(';', 1)[0] ?.trim() .toLowerCase(); - // SVG can carry scripts; this origin also hosts the daemon API and - // Web Shell UI, and the bytes are served back to browsers, so an - // inline SVG would run same-origin. Raster formats only. - if (isSvgMimeType(contentType)) { - res.status(415).json({ error: 'SVG uploads are not supported' }); - return; - } if ( + typeof encodedName !== 'string' || !contentType || - !contentType.startsWith('image/') || - !Buffer.isBuffer(req.body) || - req.body.byteLength === 0 + !Buffer.isBuffer(req.body) ) { res.status(400).json({ error: - 'request body must contain image/* bytes with a matching Content-Type', + 'request body, Content-Type, and X-Qwen-Attachment-Name are required', }); return; } + let name: string; + try { + name = decodeURIComponent(encodedName); + } catch { + res.status(400).json({ error: 'attachment name is invalid' }); + return; + } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if ( + req.body.length === 0 && + [ + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', + ].includes(contentType) + ) { + res.status(400).json({ error: 'Image attachments cannot be empty' }); + return; + } try { - const reference = await runtime.bridge.storeSessionMedia( + const reference = await runtime.bridge.storeSessionAttachment( sessionId, req.body, contentType, clientId !== undefined ? { clientId } : undefined, + name, ); res.status(201).json(reference); } catch (error) { @@ -4337,6 +4363,10 @@ export function registerSessionRoutes( res.status(413).json({ error: error.message }); return; } + if (error instanceof TypeError) { + res.status(400).json({ error: error.message }); + return; + } throw error; } }, @@ -4344,52 +4374,52 @@ export function registerSessionRoutes( ); app.get( - '/session/:id/media/:mediaId', + '/session/:id/attachments/:attachmentId', withOwnerReadSession( - 'GET /session/:id/media/:mediaId', + 'GET /session/:id/attachments/:attachmentId', async (req, res, sessionId, runtime) => { - const mediaId = req.params['mediaId']; - if (!mediaId) { - res.status(400).json({ error: '`mediaId` is required' }); + const attachmentId = req.params['attachmentId']; + if (!attachmentId) { + res.status(400).json({ error: '`attachmentId` is required' }); return; } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; - const media = await runtime.bridge.readSessionMedia( + const attachment = await runtime.bridge.readSessionAttachment( sessionId, - mediaId, + attachmentId, clientId !== undefined ? { clientId } : undefined, ); - if (!media) { - res.status(404).json({ error: 'session media not found' }); + if (!attachment) { + res.status(404).json({ error: 'session attachment not found' }); return; } - res.setHeader('Content-Type', media.mimeType); - res.setHeader('Content-Length', String(media.data.byteLength)); + res.setHeader('Content-Type', attachment.mimeType); + res.setHeader('Content-Length', String(attachment.data.byteLength)); res.setHeader('Cache-Control', 'private, max-age=300'); res.setHeader('Content-Disposition', 'attachment'); res.setHeader('X-Content-Type-Options', 'nosniff'); - res.status(200).send(media.data); + res.status(200).send(attachment.data); }, ), ); app.delete( - '/session/:id/media/:mediaId', + '/session/:id/attachments/:attachmentId', mutate(), withOwnerMutableSession( - 'DELETE /session/:id/media/:mediaId', + 'DELETE /session/:id/attachments/:attachmentId', async (req, res, sessionId, runtime) => { - const mediaId = req.params['mediaId']; - if (!mediaId) { - res.status(400).json({ error: '`mediaId` is required' }); + const attachmentId = req.params['attachmentId']; + if (!attachmentId) { + res.status(400).json({ error: '`attachmentId` is required' }); return; } const clientId = parseClientIdHeader(req, res); if (clientId === null) return; - const removed = await runtime.bridge.removeSessionMedia( + const removed = await runtime.bridge.removeSessionAttachment( sessionId, - mediaId, + attachmentId, clientId !== undefined ? { clientId } : undefined, ); res.status(200).json({ removed }); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 46754b9d0fa..6b4e37ea6b3 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -3692,6 +3692,14 @@ async function runQwenServeImpl( runtimeBootSettings, runtimeEnvSnapshot.effectiveEnv, ); + const sessionAttachmentsRoot = ( + workspace: string, + runtimeBaseDir: string, + ): string => + path.join( + new core.Storage(workspace, runtimeBaseDir).getProjectTempDir(), + 'attachments', + ); const runtimeEffectiveEnv: NodeJS.ProcessEnv = { ...runtimeEnvSnapshot.effectiveEnv, QWEN_RUNTIME_DIR: primarySessionRuntimeBaseDir, @@ -4290,6 +4298,10 @@ async function runQwenServeImpl( const bridge = deps.bridge ?? runtime.createAcpSessionBridge({ + sessionAttachmentsRoot: sessionAttachmentsRoot( + boundWorkspace, + primarySessionRuntimeBaseDir, + ), // Reverse tool channel: let `BridgeClient.extMethod` reach the WS // connection that hosts a named client MCP server (#5626). clientMcpSender: clientMcpSenderRegistry.lookup, @@ -4701,6 +4713,10 @@ async function runQwenServeImpl( ), }); const secondaryBridge = runtime.createAcpSessionBridge({ + sessionAttachmentsRoot: sessionAttachmentsRoot( + workspaceInput.cwd, + secondaryEnv.sessionRuntimeBaseDir, + ), clientMcpSender: secondaryClientMcpSenderRegistry.lookup, onCreateSubSession: secondarySubSessionLauncher.launch, onChannelDelivery: createBoundChannelDeliveryHandler( @@ -5262,6 +5278,10 @@ async function runQwenServeImpl( let wsBridge: ReturnType; try { wsBridge = runtime.createAcpSessionBridge({ + sessionAttachmentsRoot: sessionAttachmentsRoot( + cwd, + wsEnv.sessionRuntimeBaseDir, + ), clientMcpSender: wsClientMcpRegistry.lookup, onCreateSubSession: wsSubSessionLauncher.launch, onChannelDelivery: createBoundChannelDeliveryHandler( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 765f44943e6..201db046e7e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -537,7 +537,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_side_task', 'session_prompt', 'session_turn_status', - 'session_media', + 'session_attachments', 'session_mid_turn_message_mutation', 'session_mid_turn_message_query', 'session_cancel', @@ -1317,7 +1317,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { worktree: { slug: string; path: string; branch: string }; }> = []; const enqueueMidTurnCalls: FakeBridge['enqueueMidTurnCalls'] = []; - const sessionMedia = new Map(); + const sessionAttachments = new Map< + string, + { data: Buffer; mimeType: string } + >(); const enqueueMidTurnImpl = opts.enqueueMidTurnImpl ?? (() => ({ accepted: true, messageId: 'mid-default' })); @@ -2331,21 +2334,35 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { async isWorkspaceMemoryRememberAvailable() { return true; }, - async storeSessionMedia(_sessionId, data, mimeType) { - const mediaId = `media-${sessionMedia.size + 1}`; - sessionMedia.set(mediaId, { data: Buffer.from(data), mimeType }); + async storeSessionAttachment(_sessionId, data, mimeType, _context, name) { + const attachmentId = name ?? `image-${sessionAttachments.size + 1}.png`; + sessionAttachments.set(attachmentId, { + data: Buffer.from(data), + mimeType, + }); return { - type: 'image', - mediaId, + type: [ + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', + ].includes(mimeType) + ? 'image' + : 'resource', + attachmentId, mimeType, size: data.byteLength, }; }, - async readSessionMedia(_sessionId, mediaId) { - return sessionMedia.get(mediaId); + async readSessionAttachment(_sessionId, attachmentId) { + return sessionAttachments.get(attachmentId); }, - async removeSessionMedia(_sessionId, mediaId) { - return sessionMedia.delete(mediaId); + async removeSessionAttachment(_sessionId, attachmentId) { + return sessionAttachments.delete(attachmentId); + }, + async deleteSessionAttachments() { + sessionAttachments.clear(); }, enqueueMidTurnMessage(sessionId, message, context, messageId, options) { enqueueMidTurnCalls.push({ @@ -9548,7 +9565,119 @@ describe('createServeApp', () => { }); }); - describe('session media', () => { + describe('session attachments', () => { + it('uploads session-scoped text attachments', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + const uploaded = await request(app) + .post('/session/s-1/attachments') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'text/plain') + .set('X-Qwen-Attachment-Name', encodeURIComponent('notes 你好.txt')) + .send(Buffer.from('hello')); + + expect(uploaded.status).toBe(201); + expect(uploaded.body).toEqual({ + type: 'resource', + attachmentId: 'notes 你好.txt', + mimeType: 'text/plain', + size: 5, + }); + }); + + it('uploads empty files', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + const uploaded = await request(app) + .post('/session/s-1/attachments') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'text/plain') + .set('X-Qwen-Attachment-Name', 'empty.txt') + .set('Content-Length', '0') + .send(Buffer.alloc(0)); + + expect(uploaded.status).toBe(201); + expect(uploaded.body).toEqual({ + type: 'resource', + attachmentId: 'empty.txt', + mimeType: 'text/plain', + size: 0, + }); + }); + + it('rejects empty images as a bad request', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + const uploaded = await request(app) + .post('/session/s-1/attachments') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'image/png') + .set('X-Qwen-Attachment-Name', 'empty.png') + .set('Content-Length', '0') + .send(Buffer.alloc(0)); + + expect(uploaded.status).toBe(400); + expect(uploaded.body).toEqual({ + error: 'Image attachments cannot be empty', + }); + }); + + it('accepts attachment uploads through case-insensitive routes', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + const uploaded = await request(app) + .post('/SESSION/s-1/ATTACHMENTS') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'text/plain') + .set('X-Qwen-Attachment-Name', 'notes.txt') + .send(Buffer.from('hello')); + + expect(uploaded.status).toBe(201); + expect(uploaded.body).toMatchObject({ + type: 'resource', + attachmentId: 'notes.txt', + }); + }); + + it('uploads session-scoped JSON attachments as raw bytes', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + const uploaded = await request(app) + .post('/session/s-1/attachments') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'application/json') + .set('X-Qwen-Attachment-Name', 'data.json') + .send('{"enabled":true}'); + + expect(uploaded.status).toBe(201); + expect(uploaded.body).toEqual({ + type: 'resource', + attachmentId: 'data.json', + mimeType: 'application/json', + size: 16, + }); + }); + it('uploads and reads session-scoped binary media', async () => { const app = createServeApp( { ...baseOpts, token: 'secret', workspace: WS_BOUND }, @@ -9557,22 +9686,23 @@ describe('createServeApp', () => { ); const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const uploaded = await request(app) - .post('/session/s-1/media') + .post('/session/s-1/attachments') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') .set('Content-Type', 'image/png') + .set('X-Qwen-Attachment-Name', 'image.png') .send(bytes); expect(uploaded.status).toBe(201); expect(uploaded.body).toEqual({ type: 'image', - mediaId: 'media-1', + attachmentId: 'image.png', mimeType: 'image/png', size: bytes.length, }); const downloaded = await request(app) - .get('/session/s-1/media/media-1') + .get('/session/s-1/attachments/image.png') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') .buffer(true); @@ -9581,53 +9711,99 @@ describe('createServeApp', () => { expect(downloaded.body).toEqual(bytes); const removed = await request(app) - .delete('/session/s-1/media/media-1') + .delete('/session/s-1/attachments/image.png') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret'); expect(removed.status).toBe(200); expect(removed.body).toEqual({ removed: true }); const missing = await request(app) - .get('/session/s-1/media/media-1') + .get('/session/s-1/attachments/image.png') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret'); expect(missing.status).toBe(404); }); - it('rejects non-image uploads', async () => { + it('requires a name for uploads', async () => { const app = createServeApp( { ...baseOpts, token: 'secret', workspace: WS_BOUND }, undefined, { bridge: fakeBridge() }, ); const response = await request(app) - .post('/session/s-1/media') + .post('/session/s-1/attachments') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') - .set('Content-Type', 'audio/wav') + .set('Content-Type', 'text/plain') .send(Buffer.from([1])); expect(response.status).toBe(400); }); - it('rejects SVG uploads', async () => { - // SVG can carry scripts and the bytes are served back to browsers on - // the same origin as the daemon API and Web Shell UI. + it('rejects malformed encoded attachment names', async () => { const app = createServeApp( { ...baseOpts, token: 'secret', workspace: WS_BOUND }, undefined, { bridge: fakeBridge() }, ); const response = await request(app) - .post('/session/s-1/media') + .post('/session/s-1/attachments') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'text/plain') + .set('X-Qwen-Attachment-Name', '%E0%A4%A') + .send('hello'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'attachment name is invalid' }); + }); + + it('maps attachment name and Content-Type mismatches to 400', async () => { + const bridge = fakeBridge(); + bridge.storeSessionAttachment = vi.fn(async () => { + throw new TypeError('Attachment name and Content-Type do not match'); + }); + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const response = await request(app) + .post('/session/s-1/attachments') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('Content-Type', 'text/plain') + .set('X-Qwen-Attachment-Name', 'screenshot.png') + .send('hello'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Attachment name and Content-Type do not match', + }); + }); + + it('uploads SVG as an ordinary file resource', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret', workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + const response = await request(app) + .post('/session/s-1/attachments') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') .set('Content-Type', 'image/svg+xml') + .set('X-Qwen-Attachment-Name', 'image.svg') .send(Buffer.from('')); - expect(response.status).toBe(415); + expect(response.status).toBe(201); expect(response.body).toEqual({ - error: 'SVG uploads are not supported', + type: 'resource', + attachmentId: 'image.svg', + mimeType: 'image/svg+xml', + size: Buffer.byteLength( + '', + ), }); }); @@ -9639,15 +9815,16 @@ describe('createServeApp', () => { ); const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const uploaded = await request(app) - .post('/session/s-1/media') + .post('/session/s-1/attachments') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') .set('Content-Type', 'image/png') + .set('X-Qwen-Attachment-Name', 'image.png') .send(bytes); expect(uploaded.status).toBe(201); const downloaded = await request(app) - .get('/session/s-1/media/media-1') + .get('/session/s-1/attachments/image.png') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') .buffer(true); @@ -9663,10 +9840,11 @@ describe('createServeApp', () => { { bridge: fakeBridge() }, ); const response = await request(app) - .post('/session/s-1/media') + .post('/session/s-1/attachments') .set('Host', `127.0.0.1:${baseOpts.port}`) .set('Authorization', 'Bearer secret') .set('Content-Type', 'image/png') + .set('X-Qwen-Attachment-Name', 'image.png') .send(Buffer.alloc(8 * 1024 * 1024 + 1)); expect(response.status).toBe(413); @@ -9796,6 +9974,31 @@ describe('createServeApp', () => { ]); }); + it('forwards inline resource blocks to the bridge', async () => { + const bridge = fakeBridge(); + const resource = { + type: 'resource', + resource: { + uri: 'attachment:///notes.txt', + mimeType: 'text/plain', + text: 'hello', + }, + }; + const res = await midTurnPost(midTurnApp(bridge), 's-1', { + message: 'read this', + content: [resource], + }); + + expect(res.status).toBe(200); + expect(bridge.enqueueMidTurnCalls).toEqual([ + { + sessionId: 's-1', + message: 'read this', + options: { content: [resource] }, + }, + ]); + }); + it('admits an empty message when media blocks are present', async () => { const bridge = fakeBridge(); const res = await midTurnPost(midTurnApp(bridge), 's-1', { @@ -9818,6 +10021,22 @@ describe('createServeApp', () => { 'missing data', { message: 'hi', content: [{ type: 'image', mimeType: 'image/png' }] }, ], + [ + 'resource with both text and blob', + { + message: 'hi', + content: [ + { + type: 'resource', + resource: { + uri: 'attachment:///notes.txt', + text: 'hello', + blob: 'aGVsbG8=', + }, + }, + ], + }, + ], [ 'mismatched mimeType', { @@ -9839,9 +10058,8 @@ describe('createServeApp', () => { ])( '400 when `content` carries an SVG block: %s (raster-only policy)', async (_label, mimeType) => { - // The upload route rejects SVG after normalizing the media type; - // the inline-block gate must reject the same spelling variants — an - // exact-string match lets standards-conformant variants through. + // SVG files are ordinary resources, but an inline image block must + // reject spelling variants that could bypass an exact-string match. const bridge = fakeBridge(); const res = await midTurnPost(midTurnApp(bridge), 's-1', { message: 'hi', @@ -9853,12 +10071,23 @@ describe('createServeApp', () => { }, ); - it('forwards reference-form media blocks to the bridge verbatim', async () => { + it('forwards reference-form attachment blocks to the bridge verbatim', async () => { const bridge = fakeBridge(); const res = await midTurnPost(midTurnApp(bridge), 's-1', { message: 'see this', content: [ - { type: 'image', mediaId: 'media-1', mimeType: 'image/png', size: 4 }, + { + type: 'image', + attachmentId: 'media-1', + mimeType: 'image/png', + size: 4, + }, + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 0, + }, ], }); expect(res.status).toBe(200); @@ -9870,10 +10099,16 @@ describe('createServeApp', () => { content: [ { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 4, }, + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 0, + }, ], }, }, @@ -13548,7 +13783,7 @@ describe('createServeApp', () => { expect(bridge.promptCalls).toHaveLength(1); }); - it('202 accepts valid inline and reference media blocks', async () => { + it('202 accepts valid inline and reference attachment blocks', async () => { const bridge = fakeBridge({ promptImpl: async () => ({ stopReason: 'end_turn' }), }); @@ -13562,10 +13797,16 @@ describe('createServeApp', () => { { type: 'image', data: 'aW1n', mimeType: 'image/png' }, { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 4, }, + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 0, + }, ], }); expect(res.status).toBe(202); @@ -13574,7 +13815,18 @@ describe('createServeApp', () => { expect(bridge.promptCalls[0]?.req.prompt).toEqual([ { type: 'text', text: 'hi' }, { type: 'image', data: 'aW1n', mimeType: 'image/png' }, - { type: 'image', mediaId: 'media-1', mimeType: 'image/png', size: 4 }, + { + type: 'image', + attachmentId: 'media-1', + mimeType: 'image/png', + size: 4, + }, + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 0, + }, ]); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index a462864133b..b1bfe6ef656 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1018,6 +1018,10 @@ export function createServeApp( injectedWorkspaceRegistry?.primary.bridge ?? deps.bridge ?? createAcpSessionBridge({ + sessionAttachmentsRoot: path.join( + new Storage(boundWorkspace).getProjectTempDir(), + 'attachments', + ), maxSessions: opts.maxSessions, ...(totalSessionAdmission ? { freshSessionAdmission: totalSessionAdmission.admit } diff --git a/packages/cli/src/serve/server/error-handlers.ts b/packages/cli/src/serve/server/error-handlers.ts index f81931ff4c7..c5df422ac21 100644 --- a/packages/cli/src/serve/server/error-handlers.ts +++ b/packages/cli/src/serve/server/error-handlers.ts @@ -11,7 +11,17 @@ import { sendGenerationClosedError } from '../workspace-route-runtime.js'; import { sendJsonBodyParserError } from './request-helpers.js'; export function installJsonBodyParser(app: Application): void { - app.use(express.json({ limit: '10mb' })); + const parseJson = express.json({ limit: '10mb' }); + app.use((req, res, next) => { + if ( + req.method === 'POST' && + /^\/session\/[^/]+\/attachments\/?$/i.test(req.path) + ) { + next(); + return; + } + parseJson(req, res, next); + }); app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => { if (sendJsonBodyParserError(res, err)) return; next(err); diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index d21a80896cd..018f5e4346f 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -124,8 +124,8 @@ describe('sendBridgeError session writer errors', () => { }); it.each([ - ['invalid_session_media_reference', 400], - ['session_media_gone', 410], + ['invalid_session_attachment_reference', 400], + ['session_attachment_gone', 410], ] as const)('maps %s to %i', (code, expectedStatus) => { const { response, status, json } = responseMock(); const error = Object.assign(new Error('media reference failed'), { code }); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 565fcc3d4dc..a2ec30b2466 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -238,11 +238,11 @@ export function sendBridgeError( if ( err instanceof Error && 'code' in err && - (err.code === 'session_media_gone' || - err.code === 'invalid_session_media_reference') + (err.code === 'session_attachment_gone' || + err.code === 'invalid_session_attachment_reference') ) { res - .status(err.code === 'session_media_gone' ? 410 : 400) + .status(err.code === 'session_attachment_gone' ? 410 : 400) .json({ error: err.message, code: err.code }); return; } diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 46a874bf56a..d2caf7d4138 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -966,13 +966,18 @@ describe('deleteDaemonSessions', () => { }, ]); + const deleteSessionAttachments = vi.fn().mockResolvedValue(undefined); const result = await deleteDaemonSessions({ sessionIds: [sessionId], service: new SessionService(workspaceDir), - bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + bridge: { + closeSession: vi.fn().mockResolvedValue(undefined), + deleteSessionAttachments, + }, coordinator: new SessionArchiveCoordinator(), }); expect(result.removed).toEqual([sessionId]); + expect(deleteSessionAttachments).toHaveBeenCalledWith(sessionId); const ids = (await readCronTasks(workspaceDir)).map((t) => t.id).sort(); expect(ids).toEqual(['other']); // bound task deleted, unbound survives @@ -990,7 +995,10 @@ describe('deleteDaemonSessions', () => { const result = await deleteDaemonSessions({ sessionIds: [sessionId], service, - bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + bridge: { + closeSession: vi.fn().mockResolvedValue(undefined), + deleteSessionAttachments: vi.fn().mockResolvedValue(undefined), + }, coordinator: new SessionArchiveCoordinator(), }); expect(result.removed).toEqual([]); @@ -1007,6 +1015,36 @@ describe('deleteDaemonSessions', () => { await lease.release(); }); + it('reports attachment cleanup failures and allows an idempotent retry', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440075'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const deleteSessionAttachments = vi + .fn() + .mockRejectedValueOnce(new Error('cleanup failed')) + .mockResolvedValue(undefined); + const params = { + sessionIds: [sessionId], + service: new SessionService(workspaceDir), + bridge: { + closeSession: vi.fn().mockResolvedValue(undefined), + deleteSessionAttachments, + }, + coordinator: new SessionArchiveCoordinator(), + }; + + await expect(deleteDaemonSessions(params)).resolves.toEqual({ + removed: [], + notFound: [], + errors: [{ sessionId, error: 'cleanup failed' }], + }); + await expect(deleteDaemonSessions(params)).resolves.toEqual({ + removed: [], + notFound: [sessionId], + errors: [], + }); + expect(deleteSessionAttachments).toHaveBeenCalledTimes(2); + }); + it('reports a gate race per session after another batch item was deleted', async () => { const removedId = '550e8400-e29b-41d4-a716-446655440073'; const blockedId = '550e8400-e29b-41d4-a716-446655440074'; @@ -1032,6 +1070,7 @@ describe('deleteDaemonSessions', () => { ); } }), + deleteSessionAttachments: vi.fn().mockResolvedValue(undefined), }, coordinator, }); @@ -1088,7 +1127,10 @@ describe('deleteDaemonSessions', () => { deleteDaemonSessions({ sessionIds: [sessionId], service: new SessionService(workspaceDir), - bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + bridge: { + closeSession: vi.fn().mockResolvedValue(undefined), + deleteSessionAttachments: vi.fn().mockResolvedValue(undefined), + }, coordinator, }), ).rejects.toThrow(DaemonDrainingError); diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 420290a9f66..5bafd3983f6 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -374,7 +374,7 @@ async function deletePersistedSessionWithLease( export async function deleteDaemonSessions(params: { sessionIds: string[]; service: SessionService; - bridge: Pick; + bridge: Pick; coordinator: SessionArchiveCoordinator; coordinatorLockHeld?: boolean; onError?: (entry: { @@ -401,22 +401,40 @@ export async function deleteDaemonSessions(params: { uniqueSessionIds.map(async (sessionId) => { try { const mutateSession = async () => { + const removePersistedSession = async () => { + const result = await deletePersistedSessionWithLease( + service, + sessionId, + ); + if (result.kind === 'error') { + onError?.({ + phase: 'remove', + sessionId, + error: errorMessage(result.error), + }); + return result; + } + try { + await bridge.deleteSessionAttachments(sessionId); + return result; + } catch (error) { + onError?.({ + phase: 'delete', + sessionId, + error: errorMessage(error), + }); + return { + kind: 'error' as const, + error, + mutationApplied: result.mutationApplied, + }; + } + }; try { await bridge.closeSession(sessionId); } catch (error) { if (isSessionNotFoundError(error)) { - const result = await deletePersistedSessionWithLease( - service, - sessionId, - ); - if (result.kind === 'error') { - onError?.({ - phase: 'remove', - sessionId, - error: errorMessage(result.error), - }); - } - return result; + return await removePersistedSession(); } onError?.({ phase: 'close', @@ -430,18 +448,7 @@ export async function deleteDaemonSessions(params: { }; } - const result = await deletePersistedSessionWithLease( - service, - sessionId, - ); - if (result.kind === 'error') { - onError?.({ - phase: 'remove', - sessionId, - error: errorMessage(result.error), - }); - } - return result; + return await removePersistedSession(); }; return await (coordinatorLockHeld ? mutateSession() diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 696f60cd6af..ddb2b28c04d 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -181,21 +181,21 @@ export const legacySessionTelemetryRoutes = [ }, { method: 'POST', - path: '/session/:id/media', + path: '/session/:id/attachments', attribution: 'handler_resolved', - route: 'POST /session/:id/media', + route: 'POST /session/:id/attachments', }, { method: 'GET', - path: '/session/:id/media/:mediaId', + path: '/session/:id/attachments/:attachmentId', attribution: 'handler_resolved', - route: 'GET /session/:id/media/:mediaId', + route: 'GET /session/:id/attachments/:attachmentId', }, { method: 'DELETE', - path: '/session/:id/media/:mediaId', + path: '/session/:id/attachments/:attachmentId', attribution: 'handler_resolved', - route: 'DELETE /session/:id/media/:mediaId', + route: 'DELETE /session/:id/attachments/:attachmentId', }, { method: 'POST', diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 7b62676e2e9..cc515d19e2b 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -636,7 +636,7 @@ describe('resumeHistoryUtils', () => { it('restores media-reference mid-turn messages as an attachment placeholder', () => { // Image-only mid-turn messages are recorded with an empty displayText and - // mediaReferences; resuming must not fall back to the raw internal prefix. + // attachmentReferences; resuming must not fall back to the raw internal prefix. const conversation = { messages: [ { @@ -651,10 +651,10 @@ describe('resumeHistoryUtils', () => { }, systemPayload: { displayText: '', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 8, }, @@ -684,7 +684,7 @@ describe('resumeHistoryUtils', () => { it('restores media-reference ordinary user messages as an attachment placeholder', () => { // Image-only prompts are recorded with an empty displayText and - // mediaReferences; resuming must keep the prompt visible instead of + // attachmentReferences; resuming must keep the prompt visible instead of // dropping it from the restored history. const conversation = { messages: [ @@ -700,10 +700,10 @@ describe('resumeHistoryUtils', () => { systemPayload: { displayText: '', hookContext: '', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'image-1', + attachmentId: 'image-1', mimeType: 'image/png', size: 8, }, diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 5a5110914d2..85f6a71c95b 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -386,14 +386,14 @@ function convertToHistoryItems( } if (record.subtype === 'mid_turn_user_message') { const payload = record.systemPayload as - | { displayText?: string; mediaReferences?: unknown[] } + | { displayText?: string; attachmentReferences?: unknown[] } | undefined; - const hasMediaReferences = - Array.isArray(payload?.mediaReferences) && - payload.mediaReferences.length > 0; + const hasAttachmentReferences = + Array.isArray(payload?.attachmentReferences) && + payload.attachmentReferences.length > 0; const text = payload?.displayText || - (hasMediaReferences + (hasAttachmentReferences ? '[User message with attachments]' : extractTextFromParts(record.message?.parts as Part[])); if (text) { @@ -440,14 +440,14 @@ function convertToHistoryItems( const projection = projectUserTranscriptForDisplay(record); const payload = record.systemPayload as - | { mediaReferences?: unknown[] } + | { attachmentReferences?: unknown[] } | undefined; - const hasMediaReferences = - Array.isArray(payload?.mediaReferences) && - payload.mediaReferences.length > 0; + const hasAttachmentReferences = + Array.isArray(payload?.attachmentReferences) && + payload.attachmentReferences.length > 0; const text = projection.displayText || - (hasMediaReferences + (hasAttachmentReferences ? '[User message with attachments]' : extractTextFromParts(projection.parts)); if (text) { diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index f05c82aa17e..e9f6f1c5ead 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -351,10 +351,10 @@ describe('ChatRecordingService', () => { }); it('records mid-turn media references without inline bytes', async () => { - const mediaReferences = [ + const attachmentReferences = [ { type: 'image' as const, - mediaId: 'media-1', + attachmentId: 'image.png', mimeType: 'image/png', size: 3, }, @@ -364,7 +364,7 @@ describe('ChatRecordingService', () => { [{ text: 'inspect image' }], 'inspect image', undefined, - mediaReferences, + attachmentReferences, ); await chatRecordingService.flush(); @@ -375,15 +375,15 @@ describe('ChatRecordingService', () => { }); expect(record.systemPayload).toEqual({ displayText: 'inspect image', - mediaReferences, + attachmentReferences, }); }); it('records media references when the mid-turn display text is empty', async () => { - const mediaReferences = [ + const attachmentReferences = [ { type: 'image' as const, - mediaId: 'media-only', + attachmentId: 'image.png', mimeType: 'image/png', size: 3, }, @@ -393,14 +393,14 @@ describe('ChatRecordingService', () => { [{ text: '[User message received during tool execution]: ' }], '', undefined, - mediaReferences, + attachmentReferences, ); await chatRecordingService.flush(); const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; expect(record.systemPayload).toEqual({ displayText: '', - mediaReferences, + attachmentReferences, }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index c6b3443edda..ac1f7c1fc0a 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -416,7 +416,7 @@ export interface ChatRecord { export interface NotificationRecordPayload { displayText: string; - mediaReferences?: UserPromptMediaReference[]; + attachmentReferences?: UserPromptAttachmentReference[]; backgroundTask?: { taskId: string; status: string; @@ -438,13 +438,13 @@ export interface UserPromptRecordPayload { displayText: string; /** Sanitized hook context duplicated from the tagged model-bound part. */ hookContext: string; - /** Daemon-owned media references used to restore prompt previews. */ - mediaReferences?: UserPromptMediaReference[]; + /** Daemon-owned attachment references used to restore prompt previews. */ + attachmentReferences?: UserPromptAttachmentReference[]; } -export interface UserPromptMediaReference { - type: 'image' | 'audio'; - mediaId: string; +export interface UserPromptAttachmentReference { + type: 'image' | 'resource'; + attachmentId: string; mimeType: string; size: number; } @@ -1797,7 +1797,7 @@ export class ChatRecordingService { message: PartListUnion, displayText: string, goalContext?: GoalTurnPermit, - mediaReferences?: UserPromptMediaReference[], + attachmentReferences?: UserPromptAttachmentReference[], ): void { try { const record: ChatRecord = { @@ -1807,7 +1807,7 @@ export class ChatRecordingService { message: createUserContent(message), systemPayload: { displayText, - ...(mediaReferences ? { mediaReferences } : {}), + ...(attachmentReferences ? { attachmentReferences } : {}), }, }; this.appendRecord(record); diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index a91bbb0ef1a..22f0268ba03 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -87,7 +87,7 @@ const rootDir = join(__dirname, '..'); // APIs merged in from main. // Bumped from 189KB to 190KB for historical branch sessions and transcript // branch-point projection merged with the upload and reasoning APIs. -// Bumped from 190KB to 195KB for session media upload, cleanup, and hydration +// Bumped from 190KB to 195KB for session attachment upload, cleanup, and hydration // merged with the branch-session APIs and the composer text-file attachment // metadata (#9180). // Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and @@ -95,7 +95,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 196KB to 197KB for the workspace session live-state daemon // surface (catalog version + live snapshot accessors) and immutable, // identity-stable transcript block indexes used by browser renderers. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 1024; +// Bumped from 197KB to 198KB for persistent session attachment read/remove and +// binary resource hydration. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 198 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a49ea56814b..97e99f2326d 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -158,8 +158,8 @@ import type { DaemonMcpManageAction, DaemonMcpManageResult, DaemonSessionBtwResult, - DaemonSessionMediaData, - DaemonSessionMediaReference, + DaemonSessionAttachmentData, + DaemonSessionAttachmentReference, DaemonSessionGenerationEvent, DaemonMidTurnMessageResult, DaemonMidTurnMessagesResult, @@ -3293,36 +3293,43 @@ export class DaemonClient { return (await res.json()) as DaemonSessionBtwResult; } - async uploadSessionMedia( + async uploadSessionAttachment( sessionId: string, data: Blob, + name: string, mimeType: string, opts?: { signal?: AbortSignal; clientId?: string }, - ): Promise { + ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/media`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/attachments`, { method: 'POST', - headers: this.headers({ 'Content-Type': mimeType }, opts?.clientId), + headers: this.headers( + { + 'Content-Type': mimeType, + 'X-Qwen-Attachment-Name': encodeURIComponent(name), + }, + opts?.clientId, + ), body: data, signal: opts?.signal, }, async (res) => { if (!res.ok) { - throw await this.failOnError(res, 'POST /session/:id/media'); + throw await this.failOnError(res, 'POST /session/:id/attachments'); } - return (await res.json()) as DaemonSessionMediaReference; + return (await res.json()) as DaemonSessionAttachmentReference; }, ); } - async readSessionMedia( + async readSessionAttachment( sessionId: string, - mediaId: string, + attachmentId: string, opts?: { signal?: AbortSignal; clientId?: string }, - ): Promise { + ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/media/${urlEncode(mediaId)}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/attachments/${urlEncode(attachmentId)}`, { method: 'GET', headers: this.headers({}, opts?.clientId), @@ -3330,7 +3337,10 @@ export class DaemonClient { }, async (res) => { if (!res.ok) { - throw await this.failOnError(res, 'GET /session/:id/media/:mediaId'); + throw await this.failOnError( + res, + 'GET /session/:id/attachments/:attachmentId', + ); } const bytes = new Uint8Array(await res.arrayBuffer()); // This package also targets browsers, where Node's Buffer is absent. @@ -3350,13 +3360,13 @@ export class DaemonClient { ); } - async removeSessionMedia( + async removeSessionAttachment( sessionId: string, - mediaId: string, + attachmentId: string, opts?: { signal?: AbortSignal; clientId?: string }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/media/${urlEncode(mediaId)}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/attachments/${urlEncode(attachmentId)}`, { method: 'DELETE', headers: this.headers({}, opts?.clientId), @@ -3366,7 +3376,7 @@ export class DaemonClient { if (!res.ok) { throw await this.failOnError( res, - 'DELETE /session/:id/media/:mediaId', + 'DELETE /session/:id/attachments/:attachmentId', ); } return ((await res.json()) as { removed?: unknown }).removed === true; @@ -3380,7 +3390,7 @@ export class DaemonClient { * turn ends. Every accepted request is daemon-owned; a caller-supplied id * makes ambiguous retries idempotent. `opts.content` carries media content * image blocks alongside the text — pre-flight the - * `session_media` capability; older daemons ignore the + * `session_attachments` capability; older daemons ignore the * field and drop the media. */ async enqueueMidTurnMessage( diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 903e86e9ca0..f86635d3d3a 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -23,8 +23,8 @@ import type { DaemonRewindResult, DaemonRewindSnapshotInfo, DaemonSessionBtwResult, - DaemonSessionMediaData, - DaemonSessionMediaReference, + DaemonSessionAttachmentData, + DaemonSessionAttachmentReference, DaemonSessionTranscriptPage, DaemonSessionTranscriptPageOptions, DaemonSessionGenerationEvent, @@ -134,25 +134,27 @@ export interface DaemonSessionSubscribeOptions resume?: boolean; } -function isSessionMediaReference( +function isSessionAttachmentReference( value: unknown, -): value is DaemonSessionMediaReference { +): value is DaemonSessionAttachmentReference { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; return ( - record['type'] === 'image' && - typeof record['mediaId'] === 'string' && - record['mediaId'].length > 0 && + (record['type'] === 'image' || record['type'] === 'resource') && + typeof record['attachmentId'] === 'string' && + record['attachmentId'].length > 0 && typeof record['mimeType'] === 'string' && - record['mimeType'].startsWith(`${record['type']}/`) && + record['mimeType'].length > 0 && + (record['type'] !== 'image' || record['mimeType'].startsWith('image/')) && typeof record['size'] === 'number' && Number.isSafeInteger(record['size']) && - record['size'] > 0 + record['size'] >= 0 && + (record['type'] !== 'image' || record['size'] > 0) ); } -const MAX_MEDIA_CACHE_BYTES = 32 * 1024 * 1024; -const MAX_MEDIA_CACHE_ENTRIES = 128; +const MAX_ATTACHMENT_CACHE_BYTES = 32 * 1024 * 1024; +const MAX_ATTACHMENT_CACHE_ENTRIES = 128; /** * Session-scoped wrapper around `DaemonClient`. @@ -203,11 +205,11 @@ export class DaemonSessionClient { private reattaching?: Promise; private cancelling?: Promise; private readonly promptLimit: number; - private readonly mediaCache = new Map< + private readonly attachmentCache = new Map< string, - { pending: Promise; size: number } + { pending: Promise; size: number } >(); - private mediaCacheBytes = 0; + private attachmentCacheBytes = 0; private readonly _pendingPrompts = new Map< string, { @@ -497,31 +499,52 @@ export class DaemonSessionClient { return accepted; } - async uploadMedia( + async uploadAttachment( data: Blob, + name: string, mimeType: string, signal?: AbortSignal, - ): Promise { + ): Promise { return await this.withClientIdSelfHeal(() => - this.client.uploadSessionMedia(this.sessionId, data, mimeType, { + this.client.uploadSessionAttachment( + this.sessionId, + data, + name, + mimeType, + { + ...(signal ? { signal } : {}), + ...(this.clientId ? { clientId: this.clientId } : {}), + }, + ), + ); + } + + async readAttachment( + attachmentId: string, + signal?: AbortSignal, + ): Promise { + return await this.withClientIdSelfHeal(() => + this.client.readSessionAttachment(this.sessionId, attachmentId, { ...(signal ? { signal } : {}), ...(this.clientId ? { clientId: this.clientId } : {}), }), ); } - async removeMedia(mediaId: string): Promise { + async removeAttachment( + attachmentId: string, + signal?: AbortSignal, + ): Promise { const removed = await this.withClientIdSelfHeal(() => - this.client.removeSessionMedia( - this.sessionId, - mediaId, - this.clientId ? { clientId: this.clientId } : undefined, - ), + this.client.removeSessionAttachment(this.sessionId, attachmentId, { + ...(signal ? { signal } : {}), + ...(this.clientId ? { clientId: this.clientId } : {}), + }), ); if (removed) { - const cached = this.mediaCache.get(mediaId); - this.mediaCache.delete(mediaId); - this.mediaCacheBytes -= cached?.size ?? 0; + const cached = this.attachmentCache.get(attachmentId); + this.attachmentCache.delete(attachmentId); + this.attachmentCacheBytes -= cached?.size ?? 0; } return removed; } @@ -1114,45 +1137,51 @@ export class DaemonSessionClient { } private async hydrateBlock(block: unknown): Promise { - if (!isSessionMediaReference(block)) { + if (!isSessionAttachmentReference(block)) { return block as PromptContentBlock; } - let cached = this.mediaCache.get(block.mediaId); + if (block.type === 'resource') return block; + let cached = this.attachmentCache.get(block.attachmentId); if (cached) { - this.mediaCache.delete(block.mediaId); - this.mediaCache.set(block.mediaId, cached); + this.attachmentCache.delete(block.attachmentId); + this.attachmentCache.set(block.attachmentId, cached); } else { const pending = this.withClientIdSelfHeal(() => - this.client.readSessionMedia(this.sessionId, block.mediaId, { + this.client.readSessionAttachment(this.sessionId, block.attachmentId, { ...(this.clientId ? { clientId: this.clientId } : {}), }), ); cached = { pending, size: block.size }; - this.mediaCache.set(block.mediaId, cached); - this.mediaCacheBytes += block.size; + this.attachmentCache.set(block.attachmentId, cached); + this.attachmentCacheBytes += block.size; while ( - this.mediaCache.size > MAX_MEDIA_CACHE_ENTRIES || - this.mediaCacheBytes > MAX_MEDIA_CACHE_BYTES + this.attachmentCache.size > MAX_ATTACHMENT_CACHE_ENTRIES || + this.attachmentCacheBytes > MAX_ATTACHMENT_CACHE_BYTES ) { - const oldestId = this.mediaCache.keys().next().value; + const oldestId = this.attachmentCache.keys().next().value; if (oldestId === undefined) break; - const evicted = this.mediaCache.get(oldestId); - this.mediaCache.delete(oldestId); - this.mediaCacheBytes -= evicted?.size ?? 0; + const evicted = this.attachmentCache.get(oldestId); + this.attachmentCache.delete(oldestId); + this.attachmentCacheBytes -= evicted?.size ?? 0; } void pending.catch(() => { - if (this.mediaCache.get(block.mediaId)?.pending !== pending) return; - this.mediaCache.delete(block.mediaId); - this.mediaCacheBytes -= block.size; + if (this.attachmentCache.get(block.attachmentId)?.pending !== pending) + return; + this.attachmentCache.delete(block.attachmentId); + this.attachmentCacheBytes -= block.size; }); } try { - const media = await cached.pending; - return { type: block.type, data: media.data, mimeType: media.mimeType }; + const attachment = await cached.pending; + return { + type: 'image', + data: attachment.data, + mimeType: attachment.mimeType, + }; } catch (err) { // 404/410 means the daemon no longer holds the blob, so pin the // placeholder. Any other failure is transient: return the reference - // unchanged so the snapshot keeps its mediaId and a later hydration + // unchanged so the snapshot keeps its attachment id and a later hydration // pass can retry (the failed cache entry evicted itself above). if ( err instanceof DaemonHttpError && @@ -1160,7 +1189,7 @@ export class DaemonSessionClient { ) { return { type: 'text', - text: '[Attached media is no longer available]', + text: '[Attachment is no longer available]', }; } return block; diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 40e2638f465..0f42a446aed 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -708,8 +708,8 @@ export type { PermissionOutcomeSelected, PermissionResponse, PromptContentBlock, - DaemonSessionMediaData, - DaemonSessionMediaReference, + DaemonSessionAttachmentData, + DaemonSessionAttachmentReference, PromptResult, PromptTextContent, SetModelResult, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index fcc54e97616..a9520856eef 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -3938,14 +3938,14 @@ export interface PromptTextContent { text: string; } -export type DaemonSessionMediaReference = Record & { - type: 'image'; - mediaId: string; +export type DaemonSessionAttachmentReference = Record & { + type: 'image' | 'resource'; + attachmentId: string; mimeType: string; size: number; }; -export interface DaemonSessionMediaData { +export interface DaemonSessionAttachmentData { data: string; mimeType: string; } diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index b1f586a2b39..f417dc95880 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -63,7 +63,7 @@ const MAX_DETAILS_LENGTH = 4096; const SESSION_RECORDING_DEGRADED_MESSAGE = 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording.'; -const MEDIA_UNAVAILABLE_TEXT = '[Attached media is no longer available]'; +const ATTACHMENT_UNAVAILABLE_TEXT = '[Attachment is no longer available]'; export function normalizeDaemonEvent( event: DaemonEvent, @@ -589,9 +589,9 @@ function normalizeMidTurnMessageInjected( : []; const items = data['items']; // An injected message is renderable when its text is non-empty OR its - // content carries an image or a non-empty text block. The drain's + // content carries an image, resource, or non-empty text block. The drain's // degraded-media path publishes `messages: ['']` whose items hold only the - // '[Attached media is no longer available]' text block — dropping that + // '[Attachment is no longer available]' text block — dropping that // frame as malformed would erase the echo of the user's message. const hasRenderableItemContent = Array.isArray(items) && @@ -603,6 +603,7 @@ function normalizeMidTurnMessageInjected( (block) => isRecord(block) && (block['type'] === 'image' || + block['type'] === 'resource' || (block['type'] === 'text' && typeof block['text'] === 'string' && (block['text'] as string).length > 0)), @@ -738,17 +739,17 @@ function parseTimestamp(value: unknown): number | undefined { } /** - * True for the session-media reference shape (`mediaId` instead of inline + * True for the session-attachment reference shape (`attachmentId` instead of inline * data/url/source) that replay producers persist for uploaded attachments. * `extractContentPart` cannot render it; see the `user_message_chunk` case * below for how it degrades instead of vanishing. */ -function isMediaReferenceContent(value: unknown): boolean { +function isAttachmentReferenceContent(value: unknown): boolean { return ( isRecord(value) && - value['type'] === 'image' && - typeof value['mediaId'] === 'string' && - (value['mediaId'] as string).length > 0 && + (value['type'] === 'image' || value['type'] === 'resource') && + typeof value['attachmentId'] === 'string' && + (value['attachmentId'] as string).length > 0 && value['data'] === undefined && value['url'] === undefined && value['source'] === undefined @@ -830,12 +831,28 @@ function normalizeSessionUpdate( // Live consumers hydrate reference blocks before normalization; a path // that reaches this point with one (offline record projection, failed // hydrate) keeps the user's message visible via the placeholder. - if (isMediaReferenceContent(content)) { + if (isAttachmentReferenceContent(content)) { + if ((content as Record)['type'] === 'resource') { + const attachmentId = (content as Record)[ + 'attachmentId' + ] as string; + const mimeType = (content as Record)['mimeType']; + return [ + { + ...base, + type: 'user.file.delta', + name: attachmentId, + attachmentId, + mimeType: typeof mimeType === 'string' ? mimeType : '', + ...(meta ? { meta } : {}), + }, + ]; + } return [ { ...base, type: 'user.text.delta', - text: MEDIA_UNAVAILABLE_TEXT, + text: ATTACHMENT_UNAVAILABLE_TEXT, ...(meta ? { meta } : {}), }, ]; diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 61a4beed9dc..8c66e4c4d80 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -62,7 +62,13 @@ export function createDaemonTranscriptStore( text: string, images?: Array<{ data: string; mimeType: string }>, meta?: DaemonTextDeltaMeta, - files?: Array<{ name: string; mimeType: string }>, + files?: Array<{ + name: string; + mimeType: string; + data?: Blob; + text?: string; + attachmentId?: string; + }>, ) { state = appendLocalUserTranscriptMessage(state, text, { images, diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index e689c48afc2..e5961ca96ec 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -240,6 +240,8 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { return ''; case 'user.image.delta': return `[image: ${sanitizeTerminalText(event.mimeType)}]`; + case 'user.file.delta': + return `[file: ${sanitizeTerminalText(event.name)}]`; default: return assertNever(event); } diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index bdb2282122f..d5adc5f17da 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -104,7 +104,13 @@ export function appendLocalUserTranscriptMessage( text: string, opts: DaemonTranscriptReducerOptions & { images?: Array<{ data: string; mimeType: string }>; - files?: Array<{ name: string; mimeType: string }>; + files?: Array<{ + name: string; + mimeType: string; + data?: Blob; + text?: string; + attachmentId?: string; + }>; meta?: DaemonTextDeltaMeta; } = {}, ): DaemonTranscriptState { @@ -182,6 +188,40 @@ export function rebuildDaemonTranscriptBlockIndex( return blockIndexById; } +function userBlockForAttachment( + next: DaemonTranscriptState, + event: Extract< + DaemonUiEvent, + { type: 'user.image.delta' | 'user.file.delta' } + >, +): DaemonTextTranscriptBlock { + const activeUserIndex = next.activeUserBlockId + ? next.blockIndexById[next.activeUserBlockId] + : undefined; + const activeUser = + activeUserIndex !== undefined ? next.blocks[activeUserIndex] : undefined; + if ( + activeUser?.kind === 'user' && + stringArraysEqual(activeUser.sourceRecordIds, event.sourceRecordIds) + ) { + const block = getWritableBlockById(next, activeUser.id); + if (block?.kind === 'user') return block; + } + const block = createTextBlock( + next, + 'user', + '', + event.eventId, + event.serverTimestamp, + event.meta, + event.sourceRecordIds, + event.promptId, + ) as DaemonTextTranscriptBlock; + appendBlock(next, block); + next.activeUserBlockId = block.id; + return block; +} + function applyDaemonTranscriptEvent( next: DaemonTranscriptState, event: DaemonUiEvent, @@ -228,44 +268,25 @@ function applyDaemonTranscriptEvent( appendTextDelta(next, 'user', 'activeUserBlockId', event.text, event); break; case 'user.image.delta': { - const activeUserIndex = next.activeUserBlockId - ? next.blockIndexById[next.activeUserBlockId] - : undefined; - const activeUser = - activeUserIndex !== undefined - ? next.blocks[activeUserIndex] - : undefined; - if ( - activeUser?.kind !== 'user' || - !stringArraysEqual(activeUser.sourceRecordIds, event.sourceRecordIds) - ) { - const block = createTextBlock( - next, - 'user', - '', - event.eventId, - event.serverTimestamp, - event.meta, - event.sourceRecordIds, - event.promptId, - ) as DaemonTextTranscriptBlock; - block.images = [{ data: event.data, mimeType: event.mimeType }]; - appendBlock(next, block); - next.activeUserBlockId = block.id; - } else { - // Use getWritableBlockById to ensure COW safety when mutating block.images - const block = getWritableBlockById(next, next.activeUserBlockId) as - | DaemonTextTranscriptBlock - | undefined; - if (block && block.kind === 'user') { - if (event.meta) block.meta = { ...block.meta, ...event.meta }; - // Use immutable update to avoid mutating a shared array reference - block.images = [ - ...(block.images ?? []), - { data: event.data, mimeType: event.mimeType }, - ]; - } - } + const block = userBlockForAttachment(next, event); + if (event.meta) block.meta = { ...block.meta, ...event.meta }; + block.images = [ + ...(block.images ?? []), + { data: event.data, mimeType: event.mimeType }, + ]; + break; + } + case 'user.file.delta': { + const block = userBlockForAttachment(next, event); + if (event.meta) block.meta = { ...block.meta, ...event.meta }; + block.files = [ + ...(block.files ?? []), + { + name: event.name, + mimeType: event.mimeType, + attachmentId: event.attachmentId, + }, + ]; break; } case 'assistant.text.delta': diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 0935c9bab57..c9fa59d4636 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -20,6 +20,7 @@ export type DaemonUiEventType = // Chat-stream events (Stage 1) | 'user.text.delta' | 'user.image.delta' + | 'user.file.delta' | 'user.shell.command' | 'assistant.text.delta' | 'assistant.done' @@ -109,6 +110,7 @@ export interface DaemonInputReference { kind?: string; label?: string; value?: string; + metadata?: unknown; serialized?: string; removable?: boolean; } @@ -135,6 +137,14 @@ export interface DaemonUiUserImageEvent extends DaemonUiEventBase { meta?: DaemonTextDeltaMeta; } +export interface DaemonUiUserFileEvent extends DaemonUiEventBase { + type: 'user.file.delta'; + name: string; + mimeType: string; + attachmentId: string; + meta?: DaemonTextDeltaMeta; +} + export interface DaemonUiUserShellCommandEvent extends DaemonUiEventBase { type: 'user.shell.command'; command: string; @@ -631,6 +641,7 @@ export type DaemonUiEvent = // Chat-stream events | DaemonUiTextEvent | DaemonUiUserImageEvent + | DaemonUiUserFileEvent | DaemonUiUserShellCommandEvent | DaemonUiAssistantDoneEvent | DaemonUiAssistantUsageEvent @@ -865,13 +876,14 @@ export interface DaemonTextTranscriptBlock extends DaemonTranscriptBlockBase { text: string; /** Images attached to this user message (base64 data URIs). */ images?: Array<{ data: string; mimeType: string }>; - /** - * Text file attachments on this user message (display metadata only — - * the content rides the prompt's resource blocks and is never stored - * on the block). Local optimistic messages only; daemon replays carry - * no attachment metadata. - */ - files?: Array<{ name: string; mimeType: string }>; + /** File attachments on this user message. */ + files?: Array<{ + name: string; + mimeType: string; + data?: Blob; + text?: string; + attachmentId?: string; + }>; streaming?: boolean; collapsed?: boolean; /** Used by the reducer for per-subAgent block routing; renderers may use it for nesting. */ @@ -1079,7 +1091,13 @@ export interface DaemonTranscriptStore { text: string, images?: Array<{ data: string; mimeType: string }>, meta?: DaemonTextDeltaMeta, - files?: Array<{ name: string; mimeType: string }>, + files?: Array<{ + name: string; + mimeType: string; + data?: Blob; + text?: string; + attachmentId?: string; + }>, ): void; reset(seed?: Partial): void; /** diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 653243799e9..0ce95a83afc 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -334,7 +334,7 @@ describe('DaemonSessionClient', () => { expect(calls[1]?.headers['last-event-id']).toBe('42'); }); - it('hydrates media references in a replay snapshot', async () => { + it('hydrates replay images but leaves file attachments lazy', async () => { const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/session/s-1/load')) { return jsonResponse(200, { @@ -352,7 +352,7 @@ describe('DaemonSessionClient', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -367,21 +367,61 @@ describe('DaemonSessionClient', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, }, }, + { + id: 3, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'resource', + attachmentId: 'notes.json', + mimeType: 'application/json', + size: 6, + }, + }, + }, + { + id: 4, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'resource', + attachmentId: 'report.pdf', + mimeType: 'application/pdf', + size: 3, + }, + }, + }, ], }); } - if (req.url.endsWith('/session/s-1/media/media-1')) { + if (req.url.endsWith('/session/s-1/attachments/media-1')) { return new Response(Uint8Array.from([1, 2, 3]), { status: 200, headers: { 'content-type': 'image/png' }, }); } + if (req.url.endsWith('/session/s-1/attachments/notes.json')) { + return new Response(new TextEncoder().encode('你好'), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (req.url.endsWith('/session/s-1/attachments/report.pdf')) { + return new Response(Uint8Array.from([0, 255, 1]), { + status: 200, + headers: { 'content-type': 'application/pdf' }, + }); + } if (req.url.endsWith('/session/s-1/transcript')) { return jsonResponse(200, { v: 1, @@ -395,7 +435,7 @@ describe('DaemonSessionClient', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -420,15 +460,43 @@ describe('DaemonSessionClient', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', data: 'AQID', mimeType: 'image/png' }, }); + expect(session.replaySnapshot.compactedReplay[2]?.data).toEqual({ + sessionUpdate: 'user_message_chunk', + content: { + type: 'resource', + attachmentId: 'notes.json', + mimeType: 'application/json', + size: 6, + }, + }); + expect(session.replaySnapshot.compactedReplay[3]?.data).toEqual({ + sessionUpdate: 'user_message_chunk', + content: { + type: 'resource', + attachmentId: 'report.pdf', + mimeType: 'application/pdf', + size: 3, + }, + }); const page = await session.getTranscriptPage(); expect(page.events[0]?.data).toEqual({ sessionUpdate: 'user_message_chunk', content: { type: 'image', data: 'AQID', mimeType: 'image/png' }, }); - expect(calls[1]?.headers['x-qwen-client-id']).toBe('client-1'); expect( - calls.filter((call) => call.url.endsWith('/media/media-1')), + calls.find((call) => call.url.endsWith('/attachments/media-1'))?.headers[ + 'x-qwen-client-id' + ], + ).toBe('client-1'); + expect( + calls.filter((call) => call.url.endsWith('/attachments/media-1')), ).toHaveLength(1); + expect( + calls.filter((call) => call.url.endsWith('/attachments/notes.json')), + ).toHaveLength(0); + expect( + calls.filter((call) => call.url.endsWith('/attachments/report.pdf')), + ).toHaveLength(0); }); it('keeps a visible placeholder when replay media is unavailable', async () => { @@ -448,7 +516,7 @@ describe('DaemonSessionClient', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'missing-media', + attachmentId: 'missing-media', mimeType: 'image/png', size: 3, }, @@ -457,7 +525,7 @@ describe('DaemonSessionClient', () => { ], }); } - if (req.url.endsWith('/session/s-1/media/missing-media')) { + if (req.url.endsWith('/session/s-1/attachments/missing-media')) { return jsonResponse(410, { error: 'gone' }); } return jsonResponse(500, { error: `unexpected ${req.url}` }); @@ -470,7 +538,7 @@ describe('DaemonSessionClient', () => { sessionUpdate: 'user_message_chunk', content: { type: 'text', - text: '[Attached media is no longer available]', + text: '[Attachment is no longer available]', }, }); const hydrateBlock = ( @@ -480,20 +548,20 @@ describe('DaemonSessionClient', () => { ).hydrateBlock.bind(session); await hydrateBlock({ type: 'image', - mediaId: 'missing-media', + attachmentId: 'missing-media', mimeType: 'image/png', size: 3, }); expect( - calls.filter((call) => call.url.endsWith('/media/missing-media')), + calls.filter((call) => call.url.endsWith('/attachments/missing-media')), ).toHaveLength(2); }); - it('keeps replay media references retryable after a transient media failure', async () => { + it('keeps replay attachment references retryable after a transient media failure', async () => { let mediaRequests = 0; const reference = { type: 'image', - mediaId: 'flaky-media', + attachmentId: 'flaky-media', mimeType: 'image/png', size: 3, }; @@ -529,7 +597,7 @@ describe('DaemonSessionClient', () => { ], }); } - if (req.url.endsWith('/session/s-1/media/flaky-media')) { + if (req.url.endsWith('/session/s-1/attachments/flaky-media')) { mediaRequests += 1; if (mediaRequests === 1) { return jsonResponse(500, { error: 'boom' }); @@ -562,7 +630,7 @@ describe('DaemonSessionClient', () => { const session = await DaemonSessionClient.load(client, 's-1'); - // A transient failure must keep the reference (and its mediaId) in the + // A transient failure must keep the reference (and its attachmentId) in the // snapshot so a later hydration pass can retry instead of pinning the // permanent placeholder for the client's lifetime. expect(session.replaySnapshot.compactedReplay[0]?.data).toEqual({ @@ -572,7 +640,7 @@ describe('DaemonSessionClient', () => { items: [{ content: [reference] }], }); expect( - calls.filter((call) => call.url.endsWith('/media/flaky-media')), + calls.filter((call) => call.url.endsWith('/attachments/flaky-media')), ).toHaveLength(1); const page = await session.getTranscriptPage(); @@ -581,13 +649,13 @@ describe('DaemonSessionClient', () => { content: { type: 'image', data: 'AQID', mimeType: 'image/png' }, }); expect( - calls.filter((call) => call.url.endsWith('/media/flaky-media')), + calls.filter((call) => call.url.endsWith('/attachments/flaky-media')), ).toHaveLength(2); }); it('evicts least-recently-used media when the cache byte cap is exceeded', async () => { const { fetch, calls } = recordingFetch((req) => { - if (req.url.includes('/session/s-1/media/')) { + if (req.url.includes('/session/s-1/attachments/')) { return new Response(Uint8Array.from([1]), { status: 200, headers: { 'content-type': 'image/png' }, @@ -612,42 +680,42 @@ describe('DaemonSessionClient', () => { for (let index = 0; index < 4; index += 1) { await hydrateBlock({ type: 'image', - mediaId: `media-${index}`, + attachmentId: `media-${index}`, mimeType: 'image/png', size: 8 * 1024 * 1024, }); } await hydrateBlock({ type: 'image', - mediaId: 'media-0', + attachmentId: 'media-0', mimeType: 'image/png', size: 8 * 1024 * 1024, }); await hydrateBlock({ type: 'image', - mediaId: 'media-4', + attachmentId: 'media-4', mimeType: 'image/png', size: 8 * 1024 * 1024, }); await hydrateBlock({ type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 8 * 1024 * 1024, }); expect( - calls.filter((call) => call.url.endsWith('/media/media-0')), + calls.filter((call) => call.url.endsWith('/attachments/media-0')), ).toHaveLength(1); expect( - calls.filter((call) => call.url.endsWith('/media/media-1')), + calls.filter((call) => call.url.endsWith('/attachments/media-1')), ).toHaveLength(2); }); - it('uploads session media through the authenticated session route', async () => { + it('uploads session attachment through the authenticated session route', async () => { const reference = { type: 'image' as const, - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }; @@ -667,7 +735,11 @@ describe('DaemonSessionClient', () => { }); await expect( - session.uploadMedia(new Blob([Uint8Array.of(1, 2, 3)]), 'image/png'), + session.uploadAttachment( + new Blob([Uint8Array.of(1, 2, 3)]), + 'image.png', + 'image/png', + ), ).resolves.toEqual(reference); expect(calls[0]).toMatchObject({ method: 'POST', @@ -676,10 +748,10 @@ describe('DaemonSessionClient', () => { 'x-qwen-client-id': 'client-1', }), }); - expect(calls[0]?.url).toContain('/session/s-1/media'); + expect(calls[0]?.url).toContain('/session/s-1/attachments'); }); - it('removes session media through the authenticated session route', async () => { + it('removes session attachment through the authenticated session route', async () => { const { fetch, calls } = recordingFetch((req) => req.method === 'DELETE' ? jsonResponse(200, { removed: true }) @@ -695,12 +767,90 @@ describe('DaemonSessionClient', () => { }, }); - await expect(session.removeMedia('media-1')).resolves.toBe(true); + await expect(session.removeAttachment('media-1')).resolves.toBe(true); expect(calls[0]).toMatchObject({ method: 'DELETE', headers: expect.objectContaining({ 'x-qwen-client-id': 'client-1' }), }); - expect(calls[0]?.url).toContain('/session/s-1/media/media-1'); + expect(calls[0]?.url).toContain('/session/s-1/attachments/media-1'); + }); + + it('evicts a hydrated image after its attachment is removed', async () => { + let removed = false; + const { fetch, calls } = recordingFetch((req) => { + if (req.method === 'DELETE') { + removed = true; + return jsonResponse(200, { removed: true }); + } + if (req.method === 'GET' && !removed) { + return new Response(Uint8Array.of(1, 2, 3), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + return jsonResponse(404, { error: 'not found' }); + }); + const session = new DaemonSessionClient({ + client: new DaemonClient({ baseUrl: 'http://daemon', fetch }), + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + }, + }); + const hydrateBlock = ( + session as unknown as { + hydrateBlock(block: unknown): Promise; + } + ).hydrateBlock.bind(session); + const reference = { + type: 'image', + attachmentId: 'media-1', + mimeType: 'image/png', + size: 3, + }; + + await expect(hydrateBlock(reference)).resolves.toMatchObject({ + type: 'image', + data: 'AQID', + }); + await expect(session.removeAttachment('media-1')).resolves.toBe(true); + await expect(hydrateBlock(reference)).resolves.toEqual({ + type: 'text', + text: '[Attachment is no longer available]', + }); + expect(calls.filter((call) => call.method === 'GET')).toHaveLength(2); + }); + + it('reads session attachments through the authenticated media route', async () => { + const { fetch, calls } = recordingFetch((req) => + req.method === 'GET' + ? new Response('hello', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + : jsonResponse(500, { error: `unexpected ${req.url}` }), + ); + const session = new DaemonSessionClient({ + client: new DaemonClient({ baseUrl: 'http://daemon', fetch }), + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + }, + }); + + await expect(session.readAttachment('attachment-1')).resolves.toEqual({ + data: 'aGVsbG8=', + mimeType: 'text/plain', + }); + expect(calls[0]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ 'x-qwen-client-id': 'client-1' }), + }); + expect(calls[0]?.url).toContain('/session/s-1/attachments/attachment-1'); }); it('loads restored prompt activity from hasActivePrompt responses', async () => { @@ -1086,10 +1236,10 @@ describe('DaemonSessionClient', () => { expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); }); - it('hydrates media references in pending and mid-turn snapshots', async () => { + it('hydrates attachment references in pending and mid-turn snapshots', async () => { const reference = { type: 'image' as const, - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }; @@ -1131,7 +1281,7 @@ describe('DaemonSessionClient', () => { })}\n\n`, ); } - if (req.url.endsWith('/media/media-1')) { + if (req.url.endsWith('/attachments/media-1')) { return new Response(Uint8Array.of(1, 2, 3), { status: 200, headers: { 'content-type': 'image/png' }, @@ -2667,7 +2817,7 @@ describe('DaemonSessionClient clientId self-heal', () => { expect(resumeReq?.body).toBe(JSON.stringify({ cwd: '/work/a' })); }); - it('re-registers and retries media upload, removal, and hydration', async () => { + it('re-registers and retries attachment upload, removal, and hydration', async () => { let resumeCalls = 0; const attempts = new Map(); const { fetch, calls } = recordingFetch((req) => { @@ -2688,7 +2838,7 @@ describe('DaemonSessionClient clientId self-heal', () => { if (req.method === 'POST') { return jsonResponse(201, { type: 'image', - mediaId: 'media-uploaded', + attachmentId: 'media-uploaded', mimeType: 'image/png', size: 3, }); @@ -2709,9 +2859,15 @@ describe('DaemonSessionClient clientId self-heal', () => { ); await expect( - session.uploadMedia(new Blob([Uint8Array.of(1, 2, 3)]), 'image/png'), - ).resolves.toMatchObject({ mediaId: 'media-uploaded' }); - await expect(session.removeMedia('media-uploaded')).resolves.toBe(true); + session.uploadAttachment( + new Blob([Uint8Array.of(1, 2, 3)]), + 'image.png', + 'image/png', + ), + ).resolves.toMatchObject({ attachmentId: 'media-uploaded' }); + await expect(session.removeAttachment('media-uploaded')).resolves.toBe( + true, + ); const hydrateBlock = ( session as unknown as { hydrateBlock(block: unknown): Promise; @@ -2720,7 +2876,7 @@ describe('DaemonSessionClient clientId self-heal', () => { await expect( hydrateBlock({ type: 'image', - mediaId: 'media-read', + attachmentId: 'media-read', mimeType: 'image/png', size: 3, }), diff --git a/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts b/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts index d44198e68aa..0fd333a21d5 100644 --- a/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts @@ -586,10 +586,10 @@ describe('projectChatRecordsToDaemonTranscript', () => { message: { role: 'user', parts: [{ text: 'look at this' }] }, systemPayload: { displayText: 'look at this', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -604,10 +604,10 @@ describe('projectChatRecordsToDaemonTranscript', () => { }, systemPayload: { displayText: '', - mediaReferences: [ + attachmentReferences: [ { type: 'image', - mediaId: 'media-2', + attachmentId: 'media-2', mimeType: 'image/png', size: 3, }, @@ -621,8 +621,8 @@ describe('projectChatRecordsToDaemonTranscript', () => { ); expect(userBlocks.map((block) => block.text)).toEqual([ 'look at this', - '[Attached media is no longer available]', - '[Attached media is no longer available]', + '[Attachment is no longer available]', + '[Attachment is no longer available]', ]); expect(userBlocks.map((block) => block.sourceRecordIds)).toEqual([ ['mid-text-plus-image'], diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 067abb2d0f0..c0cdef9591e 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -6609,7 +6609,7 @@ describe('R5 review batch — coverage additions', () => { it('normalizes a reference-only image block into the media-unavailable placeholder', () => { // Replay producers persist uploaded attachments as media references - // (`mediaId`, no inline bytes). Paths that normalize without hydrating + // (`attachmentId`, no inline bytes). Paths that normalize without hydrating // (offline record projections) must degrade to a visible placeholder // instead of silently dropping the user's message. expect( @@ -6622,7 +6622,7 @@ describe('R5 review batch — coverage additions', () => { sessionUpdate: 'user_message_chunk', content: { type: 'image', - mediaId: 'media-1', + attachmentId: 'media-1', mimeType: 'image/png', size: 3, }, @@ -6637,7 +6637,7 @@ describe('R5 review batch — coverage additions', () => { ).toEqual([ expect.objectContaining({ type: 'user.text.delta', - text: '[Attached media is no longer available]', + text: '[Attachment is no longer available]', sourceRecordIds: ['record-1'], meta: { source: 'mid_turn_message_injected', @@ -6647,6 +6647,59 @@ describe('R5 review batch — coverage additions', () => { ]); }); + it('leaves file attachment references for lazy preview consumers', () => { + const textEvents = normalizeDaemonEvent({ + id: 7, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'check this' }, + }, + }); + const events = normalizeDaemonEvent({ + id: 8, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'user_message_chunk', + content: { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 0, + }, + }, + }); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'user.file.delta', + name: 'notes.txt', + mimeType: 'text/plain', + attachmentId: 'notes.txt', + }), + ]); + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [...textEvents, ...events], + ); + expect(state.blocks).toMatchObject([ + { + kind: 'user', + text: 'check this', + files: [ + { + name: 'notes.txt', + mimeType: 'text/plain', + attachmentId: 'notes.txt', + }, + ], + }, + ]); + }); + it('normalizes an image-only mid-turn message without dropping its slot', () => { const data = { sessionId: 's1', @@ -6674,6 +6727,40 @@ describe('R5 review batch — coverage additions', () => { ]); }); + it('normalizes a resource-only mid-turn message without dropping its slot', () => { + const data = { + sessionId: 's1', + messages: [''], + items: [ + { + content: [ + { + type: 'resource', + attachmentId: 'notes.txt', + mimeType: 'text/plain', + size: 0, + }, + ], + }, + ], + }; + expect( + normalizeDaemonEvent({ + id: 3, + v: 1, + type: 'mid_turn_message_injected', + data, + }), + ).toEqual([ + expect.objectContaining({ + type: 'status', + text: '', + source: 'mid_turn_message_injected', + data, + }), + ]); + }); + it('normalizes a degraded-media mid-turn echo instead of dropping it', () => { // The drain's media-failure path publishes `messages: ['']` whose item // content is the media-unavailable text block (no image blocks); the @@ -6685,7 +6772,7 @@ describe('R5 review batch — coverage additions', () => { items: [ { content: [ - { type: 'text', text: '[Attached media is no longer available]' }, + { type: 'text', text: '[Attachment is no longer available]' }, ], }, ], diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 5f80dfdf310..ae0b79319c1 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -249,6 +249,10 @@ const { cancel: vi.fn().mockResolvedValue(undefined), getStats: vi.fn().mockResolvedValue({}), getContextUsage: vi.fn().mockResolvedValue({}), + readAttachment: vi.fn().mockResolvedValue({ + data: 'aGVsbG8=', + mimeType: 'text/plain', + }), getTasks: vi.fn().mockResolvedValue({ v: 1, sessionId: 'session-1', @@ -267,6 +271,17 @@ const { refreshCapabilities: vi.fn(), }, mockWorkspaceActions: { + readWorkspaceFile: vi.fn().mockResolvedValue({ + content: '', + truncated: false, + }), + stat: vi.fn().mockResolvedValue({ + kind: 'stat', + path: '', + type: 'file', + sizeBytes: 0, + modifiedMs: 0, + }), loadSkillsStatus, loadProviders: vi.fn().mockResolvedValue({ current: null }), loadPreflight: vi.fn().mockResolvedValue(null), @@ -335,6 +350,10 @@ const { failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; onBranchSession?: (branchRecordId?: string) => void | Promise; + onAttachmentPreview?: (file: { + name: string; + attachmentId?: string; + }) => void; isResponding?: boolean; activeTurnStartedAt?: number; } | null, @@ -731,6 +750,10 @@ vi.mock('./components/MessageList', async () => { failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; onBranchSession?: (branchRecordId?: string) => void | Promise; + onAttachmentPreview?: (file: { + name: string; + attachmentId?: string; + }) => void; isResponding?: boolean; activeTurnStartedAt?: number; welcomeHeader?: React.ReactNode; @@ -1330,6 +1353,40 @@ vi.doMock('./components/SplitView', async () => { }, 'open main artifact', ), + React.createElement( + 'button', + { + 'data-testid': 'split-open-attachment-one', + type: 'button', + onClick: () => + props.onRightPanelOpen?.({ + id: 'attachment:shared.txt', + kind: 'attachment', + title: 'shared.txt', + turnId: 'turn-1', + data: new Blob(['one']), + sourceSessionId: 'pane-session-1', + }), + }, + 'open attachment one', + ), + React.createElement( + 'button', + { + 'data-testid': 'split-open-attachment-two', + type: 'button', + onClick: () => + props.onRightPanelOpen?.({ + id: 'attachment:shared.txt', + kind: 'attachment', + title: 'shared.txt', + turnId: 'turn-2', + data: new Blob(['two']), + sourceSessionId: 'pane-session-2', + }), + }, + 'open attachment two', + ), React.createElement( 'button', { @@ -2173,6 +2230,156 @@ describe('task activity key', () => { }); describe('artifact panel fullscreen', () => { + it('opens an @ file with the current workspace identity', async () => { + mockWorkspace.capabilities = { + workspaceCwd: '/tmp/project', + workspaces: [ + { + id: 'primary', + cwd: '/tmp/project', + primary: true, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + mockWorkspaceActions.readWorkspaceFile.mockResolvedValueOnce({ + content: 'hello', + truncated: false, + }); + const { container } = renderApp(); + await flush(); + + await act(async () => { + testState.latestMessageListProps?.onAttachmentPreview?.({ + name: 'notes.txt', + workspacePath: 'notes.txt', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockWorkspaceActions.readWorkspaceFile).toHaveBeenCalledWith( + 'notes.txt', + ); + expect(container.textContent).not.toContain( + 'This workspace may have been removed', + ); + }); + + it('does not open an @ directory in the file preview', async () => { + mockWorkspace.capabilities = { + workspaceCwd: '/tmp/project', + workspaces: [ + { + id: 'primary', + cwd: '/tmp/project', + primary: true, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + mockWorkspaceActions.stat.mockResolvedValueOnce({ + kind: 'stat', + path: 'docs', + type: 'directory', + sizeBytes: 0, + modifiedMs: 0, + }); + const { container } = renderApp(); + await flush(); + + await act(async () => { + testState.latestMessageListProps?.onAttachmentPreview?.({ + name: 'docs', + workspacePath: 'docs', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockWorkspaceActions.stat).toHaveBeenCalledWith('docs'); + expect( + container.querySelector('aside[aria-label="Right panel"]'), + ).toBeNull(); + }); + + it('loads a daemon attachment before opening its preview', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + testState.latestMessageListProps?.onAttachmentPreview?.({ + name: 'notes.txt', + attachmentId: 'attachment-1', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockSessionActions.readAttachment).toHaveBeenCalledWith( + 'attachment-1', + ); + expect( + container.querySelector('aside[aria-label="Right panel"]'), + ).not.toBeNull(); + }); + + it('reports attachment preview failures', async () => { + const onToast = vi.fn(); + mockSessionActions.readAttachment.mockRejectedValueOnce( + new Error('attachment unavailable'), + ); + const { container } = renderApp({ onToast }); + await flush(); + + await act(async () => { + testState.latestMessageListProps?.onAttachmentPreview?.({ + name: 'notes.txt', + attachmentId: 'attachment-1', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onToast).toHaveBeenCalledWith('error', 'attachment unavailable'); + expect( + container.querySelector('aside[aria-label="Right panel"]'), + ).toBeNull(); + }); + + it('does not open an attachment after switching sessions', async () => { + let resolveAttachment: + | ((value: { data: string; mimeType: string }) => void) + | undefined; + mockSessionActions.readAttachment.mockReturnValueOnce( + new Promise((resolve) => { + resolveAttachment = resolve; + }), + ); + const { container, rerender } = renderApp(); + await flush(); + + act(() => { + testState.latestMessageListProps?.onAttachmentPreview?.({ + name: 'notes.txt', + attachmentId: 'attachment-1', + }); + }); + mockConnection.sessionId = 'session-2'; + testState.ownerVersion += 1; + rerender(); + await flush(); + + await act(async () => { + resolveAttachment?.({ data: 'aGVsbG8=', mimeType: 'text/plain' }); + await Promise.resolve(); + }); + + expect( + container.querySelector('aside[aria-label="Right panel"]'), + ).toBeNull(); + }); + it('drops fullscreen when the panel closes and reopens docked', async () => { const { container } = renderApp(); @@ -4607,6 +4814,10 @@ beforeEach(() => { mockSessionActions.cancel.mockResolvedValue(undefined); mockSessionActions.getStats.mockResolvedValue({}); mockSessionActions.getContextUsage.mockResolvedValue({}); + mockSessionActions.readAttachment.mockResolvedValue({ + data: 'aGVsbG8=', + mimeType: 'text/plain', + }); mockSessionActions.getTasks.mockResolvedValue({ v: 1, sessionId: 'session-1', @@ -4618,6 +4829,19 @@ beforeEach(() => { mockStore.getSnapshot.mockClear(); mockStore.dispatch.mockClear(); mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] }); + mockWorkspaceActions.readWorkspaceFile.mockReset(); + mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ + content: '', + truncated: false, + }); + mockWorkspaceActions.stat.mockReset(); + mockWorkspaceActions.stat.mockResolvedValue({ + kind: 'stat', + path: '', + type: 'file', + sizeBytes: 0, + modifiedMs: 0, + }); mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null }); mockWorkspaceActions.loadPreflight.mockResolvedValue(null); mockWorkspaceActions.loadEnv.mockResolvedValue(null); @@ -16393,6 +16617,36 @@ describe('App session callbacks', () => { ).not.toBeNull(); }); + it('keeps same-name attachment tabs separate across split sessions', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + act(() => { + container + .querySelector( + '[data-testid="split-open-attachment-one"]', + ) + ?.click(); + container + .querySelector( + '[data-testid="split-open-attachment-two"]', + ) + ?.click(); + }); + + expect( + document.body.querySelectorAll( + 'aside[aria-label="Right panel"] button[role="tab"]', + ), + ).toHaveLength(2); + }); + it('clears split pane artifact snapshots when switching sessions', async () => { mockWorkspace.capabilities = { workspaceCwd: '/tmp/project', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 8e9a3422252..8d748c00bc8 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -88,6 +88,7 @@ import type { EditorHandle, } from './hooks/useComposerCore'; import type { PromptFile, PromptImage } from './adapters/promptTypes'; +import type { AttachmentPreviewRequest } from './adapters/messageTypes'; import { StatusBar, type StatusBarHandle } from './components/StatusBar'; import { StreamingStatus } from './components/StreamingStatus'; import { @@ -139,7 +140,10 @@ import { getFileChangePreviewContent, TURN_OUTPUT_KINDS, } from './components/artifacts/TurnOutputs'; -import { useArtifactWorkspaceTarget } from './components/artifacts/useArtifactWorkspaceTarget'; +import { + resolveArtifactWorkspaceOwner, + useArtifactWorkspaceTarget, +} from './components/artifacts/useArtifactWorkspaceTarget'; import { getArtifactsByTurn, getFileChangesByTurn, @@ -269,6 +273,7 @@ import { type ComposerPlaceholderState, } from './utils/composerInputState'; import { isDefinitelyRejectedPromptAdmission } from './utils/promptAdmission'; +import { base64ToBlob } from './utils/base64'; import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; import { isBackgroundSubAgentToolCall } from './adapters/toolClassification'; import { @@ -1104,6 +1109,7 @@ function imageTabId(src: string): string { } return `image:${hash.toString(36)}`; } + type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; @@ -2235,6 +2241,14 @@ export function App({ const artifactWorkspaceTarget = useArtifactWorkspaceTarget( connection.workspaceCwd, ); + const artifactWorkspaceCwd = artifactWorkspaceTarget?.workspaceCwd; + const artifactWorkspaceActions = artifactWorkspaceTarget?.actions; + const artifactWorkspaceCwdRef = useRef(artifactWorkspaceCwd); + const artifactWorkspaceActionsRef = useRef(artifactWorkspaceActions); + const workspaceCapabilitiesRef = useRef(workspace.capabilities); + artifactWorkspaceCwdRef.current = artifactWorkspaceCwd; + artifactWorkspaceActionsRef.current = artifactWorkspaceActions; + workspaceCapabilitiesRef.current = workspace.capabilities; const dynamicWorkspaceRegistrationSupported = workspace.capabilities?.features?.includes( 'dynamic_workspace_registration', @@ -3417,6 +3431,107 @@ export function App({ }, [getDefaultReviewPanelWidth, t], ); + const openAttachmentPanel = useCallback( + ( + file: AttachmentPreviewRequest, + workspaceCwd = connection.workspaceCwd, + sourceSessionId = connection.sessionId, + ) => { + const open = (resolvedFile: AttachmentPreviewRequest) => { + const workspacePath = resolvedFile.workspacePath ?? resolvedFile.name; + const workspaceId = resolveArtifactWorkspaceOwner( + workspaceCapabilitiesRef.current, + workspaceCwd, + )?.id; + const previewOnly = + resolvedFile.text !== undefined || + resolvedFile.data !== undefined || + resolvedFile.attachmentId !== undefined; + const tab: ArtifactPanelTab = { + id: previewOnly + ? `attachment:${sourceSessionId ?? ''}:${resolvedFile.attachmentId ?? workspacePath}` + : `file:${workspaceCwd ?? ''}:${workspacePath}`, + kind: 'file', + title: resolvedFile.name, + workspacePath, + ...(resolvedFile.text !== undefined + ? { previewContent: resolvedFile.text } + : {}), + ...(resolvedFile.data ? { previewData: resolvedFile.data } : {}), + ...(resolvedFile.mimeType + ? { previewMimeType: resolvedFile.mimeType } + : {}), + ...(previewOnly ? { previewOnly: true } : {}), + ...(workspaceCwd ? { workspaceCwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + }; + setArtifactPanelTabs((tabs) => + tabs.some((item) => item.id === tab.id) + ? tabs.map((item) => (item.id === tab.id ? tab : item)) + : [tab, ...tabs], + ); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelWidth((width) => + artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(), + ); + setArtifactPanelOpen(true); + }; + if ( + file.attachmentId && + file.text === undefined && + file.data === undefined + ) { + const owner = sessionOwnerGuard.capture(); + void sessionActions + .readAttachment(file.attachmentId) + .then((attachment) => { + if (!owner.isCurrent()) return; + open({ + ...file, + data: base64ToBlob(attachment.data, attachment.mimeType), + mimeType: attachment.mimeType, + }); + }) + .catch((error: unknown) => { + if (!owner.isCurrent()) return; + pushToast( + 'error', + formatError(error, 'Failed to preview attachment'), + ); + }); + return; + } + if ( + file.workspacePath && + file.text === undefined && + file.data === undefined && + artifactWorkspaceCwdRef.current === workspaceCwd && + artifactWorkspaceActionsRef.current + ) { + const owner = sessionOwnerGuard.capture(); + void artifactWorkspaceActionsRef.current + .stat(file.workspacePath) + .then((stat) => { + if (!owner.isCurrent() || stat.type !== 'file') return; + open(file); + }) + .catch((error: unknown) => { + if (!owner.isCurrent()) return; + pushToast('error', formatError(error, 'Failed to preview file')); + }); + return; + } + open(file); + }, + [ + connection.workspaceCwd, + connection.sessionId, + getDefaultReviewPanelWidth, + pushToast, + sessionActions, + sessionOwnerGuard, + ], + ); const openShellPanel = useCallback( ( task: DaemonSessionShellTaskStatus, @@ -3562,6 +3677,22 @@ export function App({ openImagePanel(request.src, request.alt); return; } + if (request.kind === 'attachment') { + openAttachmentPanel( + { + name: request.title, + ...(request.mimeType ? { mimeType: request.mimeType } : {}), + ...(request.data ? { data: request.data } : {}), + ...(request.text !== undefined ? { text: request.text } : {}), + ...(request.workspacePath + ? { workspacePath: request.workspacePath } + : {}), + }, + request.workspaceCwd, + request.sourceSessionId, + ); + return; + } if (request.kind === 'subagent') { openSubagentPanelForSession( request.tool, @@ -3618,6 +3749,7 @@ export function App({ openReviewPanel, openScheduledTaskPanel, openImagePanel, + openAttachmentPanel, openSubagentPanelForSession, ], ); @@ -6139,7 +6271,7 @@ export function App({ 'session_mid_turn_message_query', ) === true; const canInjectMidTurnMedia = - connection.capabilities?.features.includes('session_media') === true; + connection.capabilities?.features.includes('session_attachments') === true; const { queuedPrompts, queuedTexts, @@ -12220,6 +12352,7 @@ export function App({ } onTurnOutputOpen={handleTurnOutputOpen} onImagePreview={openImagePanel} + onAttachmentPreview={openAttachmentPanel} onReviewChanges={openReviewPanel} onOpenArtifact={openArtifactPanel} onOpenScheduledTask={openScheduledTaskPanel} @@ -12522,6 +12655,7 @@ export function App({ } onImageIngestionNotice={pushToast} onImagePreview={openImagePanel} + onAttachmentPreview={openAttachmentPanel} onCycleMode={handleCycleMode} onToggleShortcuts={handleToggleShortcuts} onCancel={handleCancel} diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 82bc1fb81d3..e04342af955 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -6,6 +6,15 @@ import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; +export interface AttachmentPreviewRequest { + name: string; + mimeType?: string; + data?: Blob; + text?: string; + workspacePath?: string; + attachmentId?: string; +} + export type DaemonMessageToolCallStatus = | 'pending' | 'in_progress' @@ -82,7 +91,13 @@ export interface DaemonUserMessage extends DaemonMessageMeta { role: 'user'; content: string; images?: Array<{ data: string; mimeType: string }>; - files?: Array<{ name: string; mimeType: string }>; + files?: Array<{ + name: string; + mimeType: string; + data?: Blob; + text?: string; + attachmentId?: string; + }>; inputAnnotations?: DaemonInputAnnotation[]; source?: string; } diff --git a/packages/web-shell/client/adapters/promptTypes.ts b/packages/web-shell/client/adapters/promptTypes.ts index bd620b226a1..1058aaffdcf 100644 --- a/packages/web-shell/client/adapters/promptTypes.ts +++ b/packages/web-shell/client/adapters/promptTypes.ts @@ -6,6 +6,7 @@ export interface PromptImage { export interface PromptFile { name: string; media_type: string; - text: string; + data?: Blob; + text?: string; size?: number; } diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index b6d4c3c1255..a2b05b5e95c 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -242,9 +242,18 @@ describe('transcriptBlocksToDaemonMessages', () => { }); it('preserves user file attachment metadata', () => { + const data = new Blob(['line one']); const messages = transcriptBlocksToDaemonMessages([ textBlock('user-1', 'user', 'check this', 1, false, { - files: [{ name: 'app.log', mimeType: 'text/plain' }], + files: [ + { + name: 'app.log', + mimeType: 'text/plain', + data, + text: 'line one', + attachmentId: 'app.log', + }, + ], }), ]); @@ -252,8 +261,29 @@ describe('transcriptBlocksToDaemonMessages', () => { id: 'user-1', role: 'user', content: 'check this', - files: [{ name: 'app.log', mimeType: 'text/plain' }], + files: [ + { + name: 'app.log', + mimeType: 'text/plain', + data, + text: 'line one', + attachmentId: 'app.log', + }, + ], + }); + }); + + it('preserves literal attachment-looking user text', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('user-1', 'user', 'check this\n\n@attachment:///data.json', 1), + ]); + + expect(messages[0]).toMatchObject({ + id: 'user-1', + role: 'user', + content: 'check this\n\n@attachment:///data.json', }); + expect(messages[0]).not.toHaveProperty('files'); }); it('preserves user input annotations metadata', () => { @@ -626,7 +656,7 @@ describe('transcriptBlocksToDaemonMessages', () => { content: [ { type: 'text', - text: '[Attached media is no longer available]', + text: '[Attachment is no longer available]', }, ], }, @@ -638,7 +668,7 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toEqual([ expect.objectContaining({ role: 'system', - content: '[Attached media is no longer available]', + content: '[Attachment is no longer available]', source: 'mid_turn_message_injected', }), ]); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 73834ab6bf3..2976dec4bdf 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -466,6 +466,9 @@ export function transcriptBlocksToDaemonMessages( msg.files = textBlock.files.map((file) => ({ name: file.name, mimeType: file.mimeType || 'text/plain', + ...(file.data !== undefined ? { data: file.data } : {}), + ...(file.text !== undefined ? { text: file.text } : {}), + ...(file.attachmentId ? { attachmentId: file.attachmentId } : {}), })); } messages.push(msg); diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 84e269cfae3..78f8113b210 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -1919,10 +1919,12 @@ } .images { + position: relative; + z-index: 2; display: flex; flex: 0 0 auto; gap: 6px; - padding: 4px 0 0; + padding: 0 0 8px; flex-wrap: wrap; } @@ -1941,10 +1943,8 @@ object-fit: cover; } -.imageRemove { - position: absolute; - top: 2px; - right: 2px; +.imageRemove, +.fileChipRemove { width: 16px; height: 16px; display: flex; @@ -1962,20 +1962,31 @@ background 0.15s ease; } -.imageRemove svg { +.imageRemove { + position: absolute; + top: 2px; + right: 2px; +} + +.imageRemove svg, +.fileChipRemove svg { display: block; } .imageThumb:hover .imageRemove:not(:disabled), -.imageRemove:focus-visible { +.fileChip:hover .fileChipRemove:not(:disabled), +.imageRemove:focus-visible, +.fileChipRemove:focus-visible { opacity: 1; } -.imageRemove:not(:disabled):hover { +.imageRemove:not(:disabled):hover, +.fileChipRemove:not(:disabled):hover { background: rgba(0, 0, 0, 0.8); } -.imageRemove:disabled { +.imageRemove:disabled, +.fileChipRemove:disabled { cursor: default; opacity: 0; } @@ -1989,6 +2000,7 @@ } .fileChip { + position: relative; display: inline-flex; align-items: center; gap: 6px; @@ -2001,46 +2013,56 @@ color: var(--chat-editor-text-primary); } +.fileChipPreviewable { + cursor: pointer; +} + +.fileChipPreview:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.fileChipPreview { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: inherit; + font: inherit; +} + +.fileChipPreview:disabled { + cursor: default; +} + .fileChipIcon { flex: 0 0 auto; color: var(--chat-editor-text-secondary, currentColor); } .fileChipName { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.fileChipSize { - flex: 0 0 auto; - color: var(--chat-editor-text-secondary, currentColor); - opacity: 0.75; -} - .fileChipRemove { - flex: 0 0 auto; - width: 16px; - height: 16px; - font-size: 12px; - line-height: 16px; - text-align: center; - background: transparent; - color: var(--error-color); - border: none; - cursor: pointer; - border-radius: 4px; - padding: 0; -} - -.fileChipRemove:not(:disabled):hover { - background: var(--error-color); - color: var(--chat-editor-text-primary); + position: absolute; + top: 50%; + right: 4px; + z-index: 1; + transform: translateY(-50%); + pointer-events: none; } -.fileChipRemove:disabled { - cursor: default; - opacity: 0.55; +.fileChip:hover .fileChipRemove:not(:disabled), +.fileChipRemove:focus-visible { + pointer-events: auto; } .searchPanel { @@ -2152,24 +2174,65 @@ .uploadStrip { display: flex; flex-direction: column; - gap: 4px; - padding: 0 0 8px; + gap: 6px; + padding: 0 0 10px; } .uploadRow { display: flex; align-items: center; - gap: 8px; + gap: 10px; min-width: 0; - padding: 4px 8px; - border: 1px solid var(--chat-editor-border-color); - border-radius: 4px; - background: var(--chat-editor-bg-tertiary); + padding: 7px 10px; + border: 1px solid color-mix(in srgb, var(--agent-blue-500) 24%, transparent); + border-radius: 10px; + background: color-mix( + in srgb, + var(--agent-blue-500) 8%, + var(--chat-editor-bg-primary) + ); color: var(--chat-editor-text-primary); font-size: 12px; line-height: 1.4; } +.uploadRow[data-status='done'] { + border-color: color-mix(in srgb, var(--success-color) 24%, transparent); + background: var(--success-bg); +} + +.uploadRow[data-status='error'] { + border-color: var(--error-border); + background: var(--error-bg); +} + +.uploadRow[data-previewable='true'] { + cursor: pointer; +} + +.uploadRowPreview:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.uploadRowPreview { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 10px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: inherit; + font: inherit; +} + +.uploadRowPreview:disabled { + cursor: default; +} + .uploadRow svg { flex: 0 0 auto; width: 14px; @@ -2177,6 +2240,7 @@ } .uploadRowSpinner { + color: var(--agent-blue-500); animation: uploadRowSpin 1s linear infinite; } @@ -2210,12 +2274,25 @@ text-overflow: ellipsis; white-space: nowrap; color: var(--chat-editor-text-dimmed); + text-align: right; } .uploadRow[data-status='error'] .uploadRowStatus { color: var(--error-color); } +.uploadRow[data-status='done'] .uploadRowStatus { + color: var(--success-color); +} + +.uploadRow[data-status='done'] .uploadRowPreview > svg { + color: var(--success-color); +} + +.uploadRow[data-status='error'] .uploadRowPreview > svg { + color: var(--error-color); +} + .uploadRowAction { display: inline-flex; align-items: center; @@ -2225,7 +2302,7 @@ height: 20px; padding: 0; border: none; - border-radius: 4px; + border-radius: 6px; background: transparent; color: var(--chat-editor-text-dimmed); cursor: pointer; diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 8454677f971..efd8e8c5bc0 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -108,6 +108,7 @@ const composerCoreState = vi.hoisted(() => ({ mobileComposer: null as unknown, openHistorySearch: vi.fn(), imageDropCapture: vi.fn(), + ingestFiles: vi.fn(), clearImageDragState: vi.fn(), addTags: vi.fn(), imageDragActive: false, @@ -195,6 +196,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { pendingImageBatchCount: 0, imageDragActive: composerCoreState.imageDragActive, clearImageDragState: composerCoreState.clearImageDragState, + ingestFiles: composerCoreState.ingestFiles, imageTransferHandlers: { onDropCapture: composerCoreState.imageDropCapture, }, @@ -297,6 +299,7 @@ afterEach(() => { composerCoreState.mobileComposer = null; composerCoreState.openHistorySearch.mockReset(); composerCoreState.imageDropCapture.mockReset(); + composerCoreState.ingestFiles.mockReset(); composerCoreState.clearImageDragState.mockReset(); composerCoreState.addTags.mockReset(); composerCoreState.imageDragActive = false; @@ -337,6 +340,11 @@ interface ChatEditorRenderProps { onSelectModel?: (model: string) => void; onAttachmentsChange?: (hasAttachments: boolean) => void; onImagePreview?: (src: string, alt?: string) => void; + onAttachmentPreview?: (file: { + name: string; + text?: string; + workspacePath?: string; + }) => void; tokenCount?: number; contextWindow?: number; onShowContextUsage?: () => void; @@ -614,9 +622,52 @@ describe('ChatEditor attachment reporting', () => { }); const img = container.querySelector('img'); expect(img).not.toBeNull(); + const imageStrip = container.querySelector( + '[data-web-shell-composer-images]', + ); + const editor = container.querySelector('[data-web-shell-composer-editor]'); + expect(imageStrip?.parentElement).toBe( + editor?.parentElement?.parentElement, + ); act(() => img!.click()); expect(onImagePreview).toHaveBeenCalledWith('data:image/png;base64,abc'); }); + + it('opens a text attachment preview when its chip is clicked', () => { + const onAttachmentPreview = vi.fn(); + const container = renderChatEditor({ + pastedFiles: [ + { + name: 'notes.txt', + text: 'hello attachment', + media_type: 'text/plain', + size: 16, + }, + ], + onAttachmentPreview, + }); + + act(() => { + ( + container.querySelector( + 'button[class*="fileChipPreview"]', + ) as HTMLElement + ).click(); + }); + + expect(onAttachmentPreview).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notes.txt', + text: 'hello attachment', + }), + ); + expect(container.textContent).not.toContain('16 B'); + expect( + container + .querySelector('[class*="fileChip"]') + ?.querySelectorAll('button'), + ).toHaveLength(2); + }); }); describe('ChatEditor composer tag icons', () => { @@ -749,6 +800,55 @@ describe('ChatEditor workspace toolbar integration', () => { }); }); +describe('ChatEditor file tag preview', () => { + it('opens an inserted file tag in the attachment preview', () => { + const onAttachmentPreview = vi.fn(); + const onComposerTagClick = vi.fn(); + renderChatEditor({ onAttachmentPreview, onComposerTagClick }); + const onFileTagClick = latestComposerCoreOptions.current?.[ + 'onFileTagClick' + ] as ((info: { tag: WebShellComposerTag }) => void) | undefined; + const info = { + tag: { + id: 'file:docs/notes.md', + kind: 'file', + value: 'docs/notes.md', + serialized: '@docs/notes.md', + } satisfies WebShellComposerTag, + }; + + act(() => onFileTagClick?.(info)); + + expect(onAttachmentPreview).toHaveBeenCalledWith({ + name: 'notes.md', + workspacePath: 'docs/notes.md', + }); + expect(onComposerTagClick).toHaveBeenCalledWith(info); + }); + + it('does not preview an inserted directory tag', () => { + const onAttachmentPreview = vi.fn(); + renderChatEditor({ onAttachmentPreview }); + const onFileTagClick = latestComposerCoreOptions.current?.[ + 'onFileTagClick' + ] as ((info: { tag: WebShellComposerTag }) => void) | undefined; + + act(() => + onFileTagClick?.({ + tag: { + id: 'file:@docs/', + kind: 'file', + value: 'docs', + metadata: { fileKind: 'directory' }, + serialized: '@docs/', + }, + }), + ); + + expect(onAttachmentPreview).not.toHaveBeenCalled(); + }); +}); + describe('ChatEditor top composer tag tooltip', () => { it('activates the plain tag from click and keyboard with the outer tag rect', () => { mockComposerCoreState.composerTags = [ @@ -1000,6 +1100,17 @@ describe('ChatEditor toolbar popovers', () => { }, }); + const toolbarLeading = document.querySelector( + '[data-web-shell-toolbar-leading]', + )!; + expect(observed.has(toolbarLeading)).toBe(false); + expect(observed.has(toolbarLeading.parentElement!)).toBe(true); + for (const measurement of document.querySelectorAll( + '[data-toolbar-measure]', + )) { + expect(observed.has(measurement)).toBe(true); + } + expect( observed.has(document.querySelector('[data-test-toolbar-start]')!), ).toBe(true); @@ -1014,6 +1125,48 @@ describe('ChatEditor toolbar popovers', () => { } }); + it('does not synchronously loop when toolbar measurements alternate', () => { + let leadingWidthReads = 0; + const bounds = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function () { + if (this.matches('[data-toolbar-measure="mode:expanded"]')) { + return { width: 50 } as DOMRect; + } + if (this.matches('[data-toolbar-measure="mode:collapsed"]')) { + return { width: 10 } as DOMRect; + } + if ( + !this.hasAttribute('data-web-shell-toolbar-leading') && + this.querySelector('[data-web-shell-toolbar-leading]') + ) { + return { width: 100 } as DOMRect; + } + return { width: 0 } as DOMRect; + }); + const scrollWidth = vi + .spyOn(HTMLElement.prototype, 'scrollWidth', 'get') + .mockImplementation(function () { + if (!this.hasAttribute('data-web-shell-toolbar-leading')) return 0; + leadingWidthReads += 1; + const modeButton = this.querySelector('[data-web-shell-mode-button]'); + const expanded = Array.from( + modeButton?.querySelectorAll('span') ?? [], + ).some((span) => Boolean(span.textContent?.trim())); + return expanded ? 101 : 50; + }); + + try { + expect(() => + renderChatEditor({ visibleToolbarActions: ['approvalMode'] }), + ).not.toThrow(); + expect(leadingWidthReads).toBe(1); + } finally { + bounds.mockRestore(); + scrollWidth.mockRestore(); + } + }); + it('opens a searchable model popover and selects the filtered model', () => { const onSelectModel = vi.fn(); const container = renderChatEditor({ @@ -1417,6 +1570,14 @@ describe('ChatEditor file upload gating', () => { return event; }; + const chooseDropAction = (action: 'cancel' | 'reference' | 'upload') => { + const button = document.querySelector( + `[data-web-shell-drop-choice-dialog] [data-drop-action="${action}"]`, + ); + expect(button).not.toBeNull(); + act(() => button?.click()); + }; + afterEach(() => { uploadWorkspaceState.current = undefined; }); @@ -1476,6 +1637,112 @@ describe('ChatEditor file upload gating', () => { expect(container.querySelector('[data-web-shell-upload-strip]')).toBeNull(); }); + it('asks whether dropped files should be referenced or uploaded', () => { + const workspace = makeWorkspace(['workspace_file_upload']); + uploadWorkspaceState.current = workspace; + const container = renderChatEditor({}); + const editor = container.querySelector('[data-web-shell-composer-editor]')!; + const files = [new File(['png'], 'photo.png', { type: 'image/png' })]; + + dispatchDrag(editor, 'drop', ['Files'], files); + + expect( + document.querySelector('[data-web-shell-drop-choice-dialog]'), + ).not.toBeNull(); + expect(composerCoreState.ingestFiles).not.toHaveBeenCalled(); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); + + chooseDropAction('reference'); + expect(composerCoreState.ingestFiles).toHaveBeenCalledWith(files); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('cancels a dropped-file choice without ingesting or uploading', () => { + const workspace = makeWorkspace(['workspace_file_upload']); + uploadWorkspaceState.current = workspace; + const container = renderChatEditor({}); + const editor = container.querySelector('[data-web-shell-composer-editor]')!; + + dispatchDrag(editor, 'drop', ['Files'], [new File(['x'], 'notes.txt')]); + chooseDropAction('cancel'); + + expect(composerCoreState.ingestFiles).not.toHaveBeenCalled(); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('closes a dropped-file choice when the session target changes', () => { + const workspace = makeWorkspace(['workspace_file_upload']); + uploadWorkspaceState.current = workspace; + const container = renderChatEditor({ sessionId: 'session-a' }); + const editor = container.querySelector('[data-web-shell-composer-editor]')!; + + dispatchDrag(editor, 'drop', ['Files'], [new File(['x'], 'notes.txt')]); + expect( + document.querySelector('[data-web-shell-drop-choice-dialog]'), + ).not.toBeNull(); + + rerenderChatEditor(container, { sessionId: 'session-b' }); + + expect( + document.querySelector('[data-web-shell-drop-choice-dialog]'), + ).toBeNull(); + expect(composerCoreState.ingestFiles).not.toHaveBeenCalled(); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('lets an arbitrary file be referenced', () => { + const workspace = makeWorkspace(['workspace_file_upload']); + workspace.client.uploadWorkspaceFile.mockResolvedValue({ + kind: 'file_upload', + path: 'uploaded-file', + sizeBytes: 3, + hash: `sha256:${'a'.repeat(64)}`, + }); + uploadWorkspaceState.current = workspace; + const container = renderChatEditor({}); + const editor = container.querySelector('[data-web-shell-composer-editor]')!; + + dispatchDrag( + editor, + 'drop', + ['Files'], + [new File(['pdf'], 'report.pdf', { type: 'application/pdf' })], + ); + + expect( + document.querySelector('[data-web-shell-drop-choice-dialog]'), + ).not.toBeNull(); + chooseDropAction('reference'); + expect(composerCoreState.ingestFiles).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'report.pdf' }), + ]); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('lets the attachment ingestion path decide whether a large file can be referenced', () => { + const workspace = makeWorkspace(['workspace_file_upload']); + uploadWorkspaceState.current = workspace; + const container = renderChatEditor({}); + const editor = container.querySelector('[data-web-shell-composer-editor]')!; + const files = [ + new File([new Uint8Array(512 * 1024 + 1)], 'large.txt', { + type: 'text/plain', + }), + ]; + + dispatchDrag(editor, 'drop', ['Files'], files); + + expect( + document.querySelector( + '[data-drop-action="reference"]', + )?.disabled, + ).toBe(false); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); + + chooseDropAction('reference'); + expect(composerCoreState.ingestFiles).toHaveBeenCalledWith(files); + }); + it('uploads dropped files into the configured directory', async () => { const workspace = makeWorkspace(['workspace_file_upload']); workspace.client.uploadWorkspaceFile.mockResolvedValue({ @@ -1490,6 +1757,7 @@ describe('ChatEditor file upload gating', () => { }); const editor = container.querySelector('[data-web-shell-composer-editor]')!; dispatchDrag(editor, 'drop', ['Files'], [new File(['abc'], 'notes.txt')]); + chooseDropAction('upload'); await act(async () => {}); expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledTimes(1); @@ -1521,6 +1789,7 @@ describe('ChatEditor file upload gating', () => { const container = renderChatEditor({}); const editor = container.querySelector('[data-web-shell-composer-editor]')!; dispatchDrag(editor, 'drop', ['Files'], [new File(['abc'], 'notes.txt')]); + chooseDropAction('upload'); await act(async () => {}); expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledTimes(1); @@ -1690,7 +1959,7 @@ describe('ChatEditor file upload gating', () => { expect(container.querySelector('[data-web-shell-upload-strip]')).toBeNull(); }); - it('routes mixed image-and-file drops to the upload path', async () => { + it('lets every file in a mixed batch be referenced', () => { const workspace = makeWorkspace(['workspace_file_upload']); uploadWorkspaceState.current = workspace; const container = renderChatEditor({}); @@ -1702,17 +1971,21 @@ describe('ChatEditor file upload gating', () => { [ new File(['png'], 'photo.png', { type: 'image/png' }), new File(['csv'], 'data.csv', { type: 'text/csv' }), + new File(['pdf'], 'report.pdf', { type: 'application/pdf' }), ], ); expect(drop.defaultPrevented).toBe(true); expect(composerCoreState.imageDropCapture).not.toHaveBeenCalled(); expect( - container.querySelector('[data-web-shell-upload-strip]'), + document.querySelector('[data-web-shell-drop-choice-dialog]'), ).not.toBeNull(); - await act(async () => {}); - // Both dropped files enter the upload batch — image-media files are not - // filtered out of mixed drops. - expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledTimes(2); + chooseDropAction('reference'); + expect(composerCoreState.ingestFiles).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'photo.png' }), + expect.objectContaining({ name: 'data.csv' }), + expect.objectContaining({ name: 'report.pdf' }), + ]); + expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); }); it('intercepts file drops before the editor and renders status above it', () => { @@ -1731,6 +2004,7 @@ describe('ChatEditor file upload gating', () => { ['Files'], [new File(['xx'], 'large.txt')], ); + chooseDropAction('upload'); const strip = container.querySelector('[data-web-shell-upload-strip]'); expect(drop.defaultPrevented).toBe(true); @@ -1754,6 +2028,7 @@ describe('ChatEditor file upload gating', () => { const container = renderChatEditor({}); const editor = container.querySelector('[data-web-shell-composer-editor]')!; dispatchDrag(editor, 'drop', ['Files'], [new File(['xx'], 'large.txt')]); + chooseDropAction('upload'); const strip = container.querySelector('[data-web-shell-upload-strip]')!; expect(strip).not.toBeNull(); @@ -1772,6 +2047,7 @@ describe('ChatEditor file upload gating', () => { }); it('uploads picker selections into the captured directory and inserts a tag', async () => { + const onAttachmentPreview = vi.fn(); const workspace = makeWorkspace(['workspace_file_upload']); workspace.client.uploadWorkspaceFile.mockResolvedValue({ kind: 'file_upload', @@ -1780,7 +2056,7 @@ describe('ChatEditor file upload gating', () => { hash: `sha256:${'a'.repeat(64)}`, }); uploadWorkspaceState.current = workspace; - const container = renderChatEditor({}); + const container = renderChatEditor({ onAttachmentPreview }); const input = container.querySelector( '[data-web-shell-upload-input]', )!; @@ -1826,6 +2102,17 @@ describe('ChatEditor file upload gating', () => { ], { placement: 'inline', position: 'end' }, ); + act(() => { + container + .querySelector( + '[data-status="done"] button[class*="uploadRowPreview"]', + ) + ?.click(); + }); + expect(onAttachmentPreview).toHaveBeenCalledWith({ + name: 'notes.txt', + workspacePath: 'docs/notes.txt', + }); }); it('restores the removed mention when the upload picker is canceled', () => { @@ -1921,7 +2208,7 @@ describe('ChatEditor file upload gating', () => { expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); }); - it('keeps supported image drops on the image attachment path', () => { + it('does not infer reference intent from an image file type', () => { const workspace = makeWorkspace(['workspace_file_upload']); uploadWorkspaceState.current = workspace; composerCoreState.imageDropCapture.mockImplementation((event: Event) => { @@ -1938,7 +2225,10 @@ describe('ChatEditor file upload gating', () => { ); expect(drop.defaultPrevented).toBe(true); - expect(composerCoreState.imageDropCapture).toHaveBeenCalledTimes(1); + expect(composerCoreState.imageDropCapture).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-web-shell-drop-choice-dialog]'), + ).not.toBeNull(); expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled(); }); @@ -1955,6 +2245,7 @@ describe('ChatEditor file upload gating', () => { const editor = container.querySelector('[data-web-shell-composer-editor]')!; dispatchDrag(editor, 'drop', ['Files'], [new File(['abc'], 'report.txt')]); + chooseDropAction('upload'); await act(async () => {}); expect(composerCoreState.addTags).toHaveBeenCalledWith( @@ -1988,6 +2279,7 @@ describe('ChatEditor file upload gating', () => { const editor = container.querySelector('[data-web-shell-composer-editor]')!; dispatchDrag(editor, 'drop', ['Files'], [new File(['abc'], 'report.txt')]); + chooseDropAction('upload'); await act(async () => {}); const row = container.querySelector( @@ -2025,6 +2317,7 @@ describe('ChatEditor file upload gating', () => { ['Files'], [oversized, new File(['ok'], 'small.txt')], ); + chooseDropAction('upload'); await act(async () => {}); const strip = container.querySelector('[data-web-shell-upload-strip]'); @@ -2057,6 +2350,7 @@ describe('ChatEditor file upload gating', () => { [folder, file], [{ file: folder, isDirectory: true }, { file }], ); + chooseDropAction('upload'); await act(async () => {}); expect(drop.defaultPrevented).toBe(true); @@ -2101,6 +2395,7 @@ describe('ChatEditor file upload gating', () => { expect(composerCoreState.workspaceUploadBusy).toBe(false); dispatchDrag(editor, 'drop', ['Files'], [new File(['abc'], 'notes.txt')]); + chooseDropAction('upload'); await act(async () => {}); expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledTimes(1); expect(composerCoreState.workspaceUploadBusy).toBe(true); @@ -2129,6 +2424,7 @@ describe('ChatEditor file upload gating', () => { const editor = container.querySelector('[data-web-shell-composer-editor]')!; dispatchDrag(editor, 'drop', ['Files'], [new File(['abc'], 'notes.txt')]); + chooseDropAction('upload'); await act(async () => {}); expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledTimes(1); expect( @@ -2259,6 +2555,7 @@ describe('ChatEditor file upload gating', () => { ['Files'], [new File(['abc'], 'report.txt')], ); + chooseDropAction('upload'); await act(async () => {}); expect(workspaceByCwd).toHaveBeenCalledWith('/secondary'); diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 45b4e34915f..be73a5311e9 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -21,6 +21,7 @@ import { useOptionalWorkspace, } from '@qwen-code/webui/daemon-react-sdk'; import type { CommandInfo } from '../adapters/types'; +import type { AttachmentPreviewRequest } from '../adapters/messageTypes'; import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionGroupPresetColor, @@ -55,6 +56,7 @@ import { cssUrlVar } from '../utils/cssUrlVar'; import { getComposerTagIconUrl, isBuiltinComposerTagIconUrl, + isPreviewableFileComposerTag, } from '../utils/composerTag'; import { isSafeImageSrc } from './messages/Markdown'; import { ModeIcon } from './ModeIcon'; @@ -62,7 +64,6 @@ import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; import { getContextUsageLevel } from '../utils/contextUsage'; import { formatContextUsageDetail } from '../utils/formatTokenCount'; -import { normalizeImageMediaType } from '../utils/imageIngestion'; import { VoiceButton } from '../voice/VoiceButton'; import { LiveVoiceButton } from '../live/LiveVoiceButton'; import type { @@ -80,12 +81,12 @@ import { WorkspaceIndicator } from './WorkspaceIndicator'; import { ChevronDownIcon, ChevronRightIcon, - FileTextIcon, FolderClosedIcon, LoaderCircleIcon, UploadIcon, XIcon, } from 'lucide-react'; +import { FileTypeIcon } from './FileTypeIcon'; import { WorkspaceSelector } from './WorkspaceSelector'; import { Popover, @@ -94,7 +95,17 @@ import { PopoverTrigger, } from './ui/popover'; import { Input } from './ui/input'; +import { Button } from './ui/button'; import { Switch } from './ui/switch'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; import { Tooltip, TooltipContent, @@ -251,6 +262,7 @@ interface ChatEditorProps { onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void; /** Click a pasted image in the composer to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; + onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; } const CHAT_EDITOR_THEME = { @@ -1493,6 +1505,7 @@ export const ChatEditor = memo( voiceStatusRevision, onImageIngestionNotice, onImagePreview, + onAttachmentPreview, } = props; const { @@ -1564,6 +1577,12 @@ export const ChatEditor = memo( const uploadTargetKey = `${sessionId ?? ''}:${ uploadTarget?.targetKey ?? '' }`; + const [pendingDropFiles, setPendingDropFiles] = useState( + null, + ); + useLayoutEffect(() => { + setPendingDropFiles(null); + }, [disabled, uploadTargetKey]); // -- File upload ---------------------------------------------------------- // The hook's cancel/reset granularity includes the session: ChatEditor is @@ -1598,6 +1617,19 @@ export const ChatEditor = memo( restore?.(); }, [uploadTargetKey]); + const handleComposerTagClick = useCallback( + (info: Parameters>[0]) => { + if (isPreviewableFileComposerTag(info.tag)) { + onAttachmentPreview?.({ + name: info.tag.value.split(/[\\/]/).pop() ?? info.tag.value, + workspacePath: info.tag.value, + }); + } + onComposerTagClick?.(info); + }, + [onAttachmentPreview, onComposerTagClick], + ); + const core = useComposerCore({ onSubmit, onInputTextChange, @@ -1613,7 +1645,7 @@ export const ChatEditor = memo( onPopQueuedMessages, currentMode, onFocusFooter, - dialogOpen, + dialogOpen: dialogOpen || pendingDropFiles !== null, followupState, onAcceptFollowup, onDismissFollowup, @@ -1629,6 +1661,7 @@ export const ChatEditor = memo( renderComposerTag, renderComposerTagTooltip, onComposerTagClick, + onFileTagClick: handleComposerTagClick, onImageIngestionNotice, onFileUploadRequest: uploadEnabled ? triggerFilePicker : undefined, workspaceUploadBusy: fileUpload.isBusy, @@ -1760,30 +1793,42 @@ export const ChatEditor = memo( event.preventDefault(); return; } - if ( - !uploadEnabled || - files.length === 0 || - files.every((file) => normalizeImageMediaType(file.type, file.name)) - ) { + if (!uploadEnabled || files.length === 0) { core.imageTransferHandlers.onDropCapture(event); return; } clearImageDragState(); event.preventDefault(); event.stopPropagation(); - uploadFiles(files, fileUploadDirectory ?? '.', insertUploadReference); + setPendingDropFiles(files); }, [ core.imageTransferHandlers, clearImageDragState, disabled, fileUploadEnabled, - fileUploadDirectory, uploadEnabled, - uploadFiles, - insertUploadReference, ], ); + const referenceDroppedFiles = useCallback(() => { + if (!pendingDropFiles) return; + core.ingestFiles(pendingDropFiles); + setPendingDropFiles(null); + }, [core, pendingDropFiles]); + const uploadDroppedFiles = useCallback(() => { + if (!pendingDropFiles) return; + uploadFiles( + pendingDropFiles, + fileUploadDirectory ?? '.', + insertUploadReference, + ); + setPendingDropFiles(null); + }, [ + fileUploadDirectory, + insertUploadReference, + pendingDropFiles, + uploadFiles, + ]); const handleUploadPickerChange = useCallback( (event: ReactChangeEvent) => { const files = Array.from(event.target.files ?? []); @@ -1872,6 +1917,8 @@ export const ChatEditor = memo( mode: false, model: false, }); + const toolbarLabelVisibilityRef = useRef(toolbarLabelVisibility); + toolbarLabelVisibilityRef.current = toolbarLabelVisibility; const [lastConfirmedModelLabel, setLastConfirmedModelLabel] = useState(''); const slashMenu = core.slashMenu; const closeSlashMenu = core.closeSlashMenu; @@ -2311,6 +2358,7 @@ export const ChatEditor = memo( } const update = () => { + const currentVisibility = toolbarLabelVisibilityRef.current; const expansionWidth = (id: string) => { const collapsed = measurements.querySelector( `[data-toolbar-measure="${id}:collapsed"]`, @@ -2370,9 +2418,7 @@ export const ChatEditor = memo( const currentExpansionWidth = items.reduce( (total, item) => total + - (toolbarLabelVisibility[ - item.id as keyof typeof toolbarLabelVisibility - ] + (currentVisibility[item.id as keyof typeof currentVisibility] ? item.expansionWidth : 0), 0, @@ -2391,7 +2437,7 @@ export const ChatEditor = memo( const itemVisibility = getToolbarItemVisibilityWithHysteresis({ availableWidth, items, - currentVisibility: toolbarLabelVisibility, + currentVisibility, // Aggregate scrollWidth can differ from the sum of individually // rounded replicas by one pixel per item. Apply that slack only when // expanding so a collapsed/expanded pair cannot form a two-cycle. @@ -2404,20 +2450,19 @@ export const ChatEditor = memo( mode: itemVisibility.mode ?? false, model: itemVisibility.model ?? false, }; - setToolbarLabelVisibility((current) => { - const unchanged = Object.keys(next).every( - (key) => - current[key as keyof typeof current] === - next[key as keyof typeof next], - ); - return unchanged ? current : next; - }); + const unchanged = Object.keys(next).every( + (key) => + currentVisibility[key as keyof typeof currentVisibility] === + next[key as keyof typeof next], + ); + if (unchanged) return; + toolbarLabelVisibilityRef.current = next; + setToolbarLabelVisibility(next); }; update(); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(toolbar); - resizeObserver.observe(toolbarLeading); resizeObserver.observe(toolbarRight); for (const child of measurements.children) { resizeObserver.observe(child); @@ -2466,7 +2511,6 @@ export const ChatEditor = memo( sessionName, showModelAction, showModeAction, - toolbarLabelVisibility, workspaceIndicatorVisible, workspaceName, workspaceSelectVisible, @@ -2504,26 +2548,43 @@ export const ChatEditor = memo( {fileUpload.uploads.map((upload) => { const busy = upload.status === 'pending' || upload.status === 'uploading'; + const previewable = + Boolean(onAttachmentPreview) && + upload.status === 'done' && + Boolean(upload.resultPath); return (
- {busy ? ( -
)}
- {(core.composerTags.length > 0 || - core.pastedImages.length > 0 || - core.pastedFiles.length > 0) && ( + {core.pastedImages.length > 0 && ( +
+ {core.pastedImages.map((img, i) => { + const src = `data:${img.media_type};base64,${img.data}`; + return ( +
+ onImagePreview(src) : undefined + } + /> + +
+ ); + })} +
+ )} + {(core.composerTags.length > 0 || core.pastedFiles.length > 0) && (
- onComposerTagClick({ + handleComposerTagClick({ ...tagInfo, anchorRect, }) @@ -2697,70 +2805,43 @@ export const ChatEditor = memo(
)} - {core.pastedImages.length > 0 && ( -
- {core.pastedImages.map((img, i) => { - const src = `data:${img.media_type};base64,${img.data}`; - return ( -
- onImagePreview(src) - : undefined - } - /> - -
- ); - })} -
- )} {core.pastedFiles.length > 0 && (
{core.pastedFiles.map((file, i) => (
-
))} @@ -3407,6 +3501,64 @@ export const ChatEditor = memo( showKeyHints={!core.mobileComposer} /> )} + { + if (!open) setPendingDropFiles(null); + }} + > + { + event.preventDefault(); + core.focus(); + }} + > + + + {t('composer.dropChoice.title', { + count: pendingDropFiles?.length ?? 0, + })} + + + {t('composer.dropChoice.description')} + + +
+ {pendingDropFiles?.map((file, index) => ( +
+ {file.name} + + {formatAttachmentSize(file.size)} + +
+ ))} +
+ + + + + + + +
+
); }), diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 2a482fe1704..aec8a2f9a17 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -60,6 +60,7 @@ const setApprovalMode = vi.fn(async (mode: string) => ({ mode })); const setModel = vi.fn(async () => ({}) as any); const loadArtifacts = vi.fn(async () => ({ artifacts: [] })); const getTasks = vi.fn(); +const readAttachment = vi.fn(); const daemonActions = { sendPrompt, submitPermission, @@ -68,6 +69,7 @@ const daemonActions = { setModel, loadArtifacts, getTasks, + readAttachment, }; const enqueuePrompt = vi.fn(() => true); const removeQueuedPrompt = vi.fn(); @@ -186,6 +188,16 @@ vi.mock('./MessageList', () => ({ }) } /> +
), })); @@ -383,6 +395,11 @@ beforeEach(() => { loadArtifacts.mockReset(); loadArtifacts.mockResolvedValue({ artifacts: [] }); getTasks.mockReset(); + readAttachment.mockReset(); + readAttachment.mockResolvedValue({ + data: 'eyJoaSI6IuS9oOWlvSJ9', + mimeType: 'application/json', + }); sendPrompt.mockImplementation(async (_text: string, options?: any) => { sendPromptAdmit = options?.onAdmitted; return {} as any; @@ -888,6 +905,28 @@ describe('ChatPane', () => { }); }); + it('reads daemon attachments through the pane session before previewing', async () => { + const onRightPanelOpen = vi.fn(); + render({ onRightPanelOpen }); + + await act(async () => { + testid('pane-open-attachment')?.click(); + await Promise.resolve(); + }); + + expect(readAttachment).toHaveBeenCalledWith('attachment-1'); + expect(onRightPanelOpen).toHaveBeenCalledWith({ + id: 'attachment:attachment-1', + kind: 'attachment', + title: 'data.json', + turnId: 'sess-1', + mimeType: 'application/json', + data: expect.any(Blob), + workspaceCwd: '/w', + sourceSessionId: 'sess-1', + }); + }); + it('suppresses the rotating loading phrase in its compact status', () => { render(); expect(testid('pane-streaming')?.getAttribute('data-show-phrase')).toBe( diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index be45220c0bb..7ade6eebafd 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -42,6 +42,7 @@ import { useMessagesFromBlocks } from '../hooks/useMessages'; import { useSessionArtifacts } from '../hooks/useSessionArtifacts'; import { extractPendingPermission } from '../adapters/transcriptAdapter'; import type { PromptFile, PromptImage } from '../adapters/promptTypes'; +import type { AttachmentPreviewRequest } from '../adapters/messageTypes'; import type { ComposerSubmitCommit, ComposerSubmitMetadata, @@ -52,6 +53,7 @@ import { isAskUserPermission } from '../utils/askUserPermission'; import { isDaemonApprovalMode } from '../utils/sessionPreparation'; import { isVisibleComposerModel } from '../utils/composerModels'; import { shouldBlockComposerSubmit } from '../utils/composerInputState'; +import { base64ToBlob } from '../utils/base64'; import { isDefinitelyRejectedPromptAdmission } from '../utils/promptAdmission'; import { getActiveTodosForPlanRevision, @@ -523,7 +525,7 @@ export function ChatPane({ 'session_mid_turn_message_query', ) === true; const canInjectMidTurnMedia = - connection.capabilities?.features.includes('session_media') === true; + connection.capabilities?.features.includes('session_attachments') === true; const { queuedPrompts, queuedTexts, @@ -715,6 +717,9 @@ export function ChatPane({ }, [connection.sessionId, onRightPanelOpen], ); + const paneWorkspaceCwd = workspaceCwd ?? connection.workspaceCwd; + const previewSessionIdRef = useRef(connection.sessionId); + previewSessionIdRef.current = connection.sessionId; const handleImagePreview = useCallback( (src: string, alt?: string) => { @@ -730,6 +735,57 @@ export function ChatPane({ }, [connection.sessionId, handleRightPanelOpen, t], ); + const handleAttachmentPreview = useCallback( + (file: AttachmentPreviewRequest) => { + const sessionId = connection.sessionId; + if (!sessionId) return; + const open = (resolvedFile: AttachmentPreviewRequest) => + handleRightPanelOpen({ + id: `attachment:${resolvedFile.attachmentId ?? resolvedFile.workspacePath ?? resolvedFile.name}`, + kind: 'attachment', + title: resolvedFile.name, + turnId: sessionId, + ...(resolvedFile.mimeType ? { mimeType: resolvedFile.mimeType } : {}), + ...(resolvedFile.data ? { data: resolvedFile.data } : {}), + ...(resolvedFile.text !== undefined + ? { text: resolvedFile.text } + : {}), + ...(paneWorkspaceCwd ? { workspaceCwd: paneWorkspaceCwd } : {}), + ...(resolvedFile.workspacePath + ? { workspacePath: resolvedFile.workspacePath } + : {}), + }); + if ( + file.attachmentId && + file.text === undefined && + file.data === undefined + ) { + void actions + .readAttachment(file.attachmentId) + .then((attachment) => { + if (previewSessionIdRef.current !== sessionId) return; + open({ + ...file, + data: base64ToBlob(attachment.data, attachment.mimeType), + mimeType: attachment.mimeType, + }); + }) + .catch((error: unknown) => { + if (previewSessionIdRef.current !== sessionId) return; + reportError(error, 'Failed to preview attachment'); + }); + return; + } + open(file); + }, + [ + actions, + connection.sessionId, + handleRightPanelOpen, + paneWorkspaceCwd, + reportError, + ], + ); // Composer wiring, all scoped to THIS pane's own DaemonSession context. The // slash menu lists the session's daemon commands — they run server-side when @@ -825,7 +881,6 @@ export function ChatPane({ // toolbar chip (next to where the git-branch chip sits), so it's clear which // workspace a message goes to. Multi-workspace-ness comes from the shared // workspace provider (the pane's own session connection may not carry it). - const paneWorkspaceCwd = workspaceCwd ?? connection.workspaceCwd; const showWorkspaceChip = hasMultipleWorkspaces(workspace.capabilities) && !!paneWorkspaceCwd; // Memoized so the array identity is stable across renders — `ChatEditor` is @@ -1005,6 +1060,7 @@ export function ChatPane({ } onTurnOutputOpen={handleRightPanelOpen} onImagePreview={handleImagePreview} + onAttachmentPreview={handleAttachmentPreview} onError={reportError} generateContent={ connection.capabilities?.features.includes('session_generation') @@ -1110,6 +1166,7 @@ export function ChatPane({ onImageIngestionNotice={onImageIngestionNotice} sessionId={connection.sessionId} onImagePreview={handleImagePreview} + onAttachmentPreview={handleAttachmentPreview} atWorkspaceCwd={paneWorkspaceCwd} placeholderText={t('splitView.composerPlaceholder')} /> diff --git a/packages/web-shell/client/components/FileTypeIcon.tsx b/packages/web-shell/client/components/FileTypeIcon.tsx new file mode 100644 index 00000000000..12d050bd961 --- /dev/null +++ b/packages/web-shell/client/components/FileTypeIcon.tsx @@ -0,0 +1,86 @@ +import { + BracesIcon, + FileArchiveIcon, + FileAudioIcon, + FileCode2Icon, + FileIcon, + FileImageIcon, + FileSpreadsheetIcon, + FileTextIcon, + FileVideoIcon, + PresentationIcon, + type LucideIcon, +} from 'lucide-react'; +import type { ComponentProps } from 'react'; + +const EXTENSION_ICONS: ReadonlyArray<[ReadonlySet, LucideIcon]> = [ + [new Set(['json', 'jsonl', 'geojson']), BracesIcon], + [ + new Set([ + 'js', + 'jsx', + 'ts', + 'tsx', + 'mjs', + 'cjs', + 'css', + 'html', + 'htm', + 'xml', + 'svg', + 'py', + 'rb', + 'go', + 'rs', + 'java', + 'c', + 'cc', + 'cpp', + 'h', + 'hpp', + 'sh', + 'sql', + 'yaml', + 'yml', + 'toml', + ]), + FileCode2Icon, + ], + [ + new Set(['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar']), + FileArchiveIcon, + ], + [new Set(['csv', 'tsv', 'xls', 'xlsx', 'ods']), FileSpreadsheetIcon], + [new Set(['ppt', 'pptx', 'odp']), PresentationIcon], + [ + new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tif', 'tiff']), + FileImageIcon, + ], + [new Set(['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac']), FileAudioIcon], + [new Set(['mp4', 'mov', 'webm', 'avi', 'mkv']), FileVideoIcon], + [ + new Set(['txt', 'md', 'mdx', 'log', 'pdf', 'doc', 'docx', 'rtf']), + FileTextIcon, + ], +]; + +function iconForFile(name: string, mimeType?: string): LucideIcon { + const extension = name.split('.').pop()?.toLowerCase() ?? ''; + for (const [extensions, Icon] of EXTENSION_ICONS) { + if (extensions.has(extension)) return Icon; + } + if (mimeType?.startsWith('image/')) return FileImageIcon; + if (mimeType?.startsWith('audio/')) return FileAudioIcon; + if (mimeType?.startsWith('video/')) return FileVideoIcon; + if (mimeType?.startsWith('text/')) return FileTextIcon; + return FileIcon; +} + +export function FileTypeIcon({ + name, + mimeType, + ...props +}: ComponentProps & { name: string; mimeType?: string }) { + const Icon = iconForFile(name, mimeType); + return ; +} diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index 5a577ea580d..cc91b6f8c52 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -23,6 +23,7 @@ import { BtwMessage } from './messages/BtwMessage'; import { UserShellMessage } from './messages/UserShellMessage'; import { InsightProgress } from './InsightProgress'; import { InsightReady } from './InsightReady'; +import type { AttachmentPreviewRequest } from '../adapters/messageTypes'; interface MessageItemProps { message: Message; @@ -31,6 +32,7 @@ interface MessageItemProps { onShowContextDetail?: () => void; /** Click an uploaded image in a user message to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; + onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; workspaceCwd?: string; isLatest?: boolean; showRetryHint?: boolean; @@ -51,6 +53,7 @@ export const MessageItem = memo(function MessageItem({ pendingApproval, onShowContextDetail, onImagePreview, + onAttachmentPreview, workspaceCwd, isLatest = false, showRetryHint = false, @@ -91,6 +94,7 @@ export const MessageItem = memo(function MessageItem({ sendFailed={sendFailed} onRetrySend={onRetrySend} onImagePreview={onImagePreview} + onAttachmentPreview={onAttachmentPreview} /> ); case 'assistant': @@ -287,6 +291,7 @@ function areMessageItemPropsEqual( if (prev.pendingApproval?.id !== next.pendingApproval?.id) return false; if (prev.onShowContextDetail !== next.onShowContextDetail) return false; if (prev.onImagePreview !== next.onImagePreview) return false; + if (prev.onAttachmentPreview !== next.onAttachmentPreview) return false; if (prev.workspaceCwd !== next.workspaceCwd) return false; if (prev.isLatest !== next.isLatest) return false; if (prev.showRetryHint !== next.showRetryHint) return false; diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 1776a82e3fc..584cfc6680d 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -60,6 +60,7 @@ import turnCollapseStyles from './TurnCollapseRow.module.css'; import flashStyles from './MessageLocateFlash.module.css'; import styles from './MessageList.module.css'; import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions'; +import type { AttachmentPreviewRequest } from '../adapters/messageTypes'; const noopTurnOutputAction = () => undefined; const RELOAD_TRANSCRIPT_DELAY_MS = 120_000; @@ -77,6 +78,7 @@ interface MessageListProps { onShowContextDetail?: () => void; /** Click an uploaded image in a user message to preview it in the right panel. */ onImagePreview?: (src: string, alt?: string) => void; + onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; loadingTranscript?: boolean; catchingUp?: boolean; hasOlderHistory?: boolean; @@ -2627,6 +2629,7 @@ export const MessageList = memo( pendingApproval, onShowContextDetail, onImagePreview, + onAttachmentPreview, loadingTranscript, catchingUp, hasOlderHistory = false, @@ -4952,6 +4955,7 @@ export const MessageList = memo( pendingApproval={pendingApproval} onShowContextDetail={onShowContextDetail} onImagePreview={onImagePreview} + onAttachmentPreview={onAttachmentPreview} workspaceCwd={workspaceCwd} isLatest={isLatest} showRetryHint={showRetryHint} @@ -5012,6 +5016,7 @@ export const MessageList = memo( handleAutomaticAgentExpansionChange, onShowContextDetail, onImagePreview, + onAttachmentPreview, generateContent, headerOffset, visibleItems, diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css index 2bf27b5940f..1e46bace295 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css @@ -219,6 +219,7 @@ } .body { + position: relative; flex: 1 1 auto; min-height: 0; overflow: auto; @@ -230,6 +231,81 @@ padding: 0; } +.attachmentPreviewButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 9px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--background); + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.body:hover .attachmentPreviewButton, +.attachmentPreviewButton:focus-visible { + opacity: 1; + pointer-events: auto; +} + +.attachmentPreviewButton:hover { + background: var(--accent); + color: var(--foreground); +} + +.attachmentPreviewButton svg { + width: 14px; + height: 14px; +} + +@media (hover: none) { + .attachmentPreviewButton { + opacity: 1; + pointer-events: auto; + } +} + +.pdfAttachmentPreview { + display: block; + width: 100%; + height: 100%; + border: 0; + background: var(--background); +} + +.unsupportedAttachmentPreview { + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + color: var(--muted-foreground); + text-align: center; +} + +.unsupportedAttachmentPreview > svg { + width: 36px; + height: 36px; + opacity: 0.65; +} + +.unsupportedAttachmentMeta { + font-size: 12px; + opacity: 0.75; +} + .empty { color: var(--muted-foreground); font-size: 13px; diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx index ab0a1e4c4a0..fd420344902 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx @@ -16,6 +16,15 @@ import { TOAST_REQUEST_EVENT, type ToastRequestDetail } from '../ToastHost'; import type { ArtifactWorkspaceTarget } from './useArtifactWorkspaceTarget'; import type { TurnOutputScheduledTask } from './TurnOutputs'; +const originalCreateObjectURL = Object.getOwnPropertyDescriptor( + URL, + 'createObjectURL', +); +const originalRevokeObjectURL = Object.getOwnPropertyDescriptor( + URL, + 'revokeObjectURL', +); + const { mockActions, mockWorkspace, @@ -312,6 +321,16 @@ afterEach(() => { container.remove(); } mounted.length = 0; + if (originalCreateObjectURL) { + Object.defineProperty(URL, 'createObjectURL', originalCreateObjectURL); + } else { + Reflect.deleteProperty(URL, 'createObjectURL'); + } + if (originalRevokeObjectURL) { + Object.defineProperty(URL, 'revokeObjectURL', originalRevokeObjectURL); + } else { + Reflect.deleteProperty(URL, 'revokeObjectURL'); + } mockActions.cancelTask.mockReset(); mockActions.getTasks.mockReset(); mockWorkspaceActions.readFileBytes.mockReset(); @@ -579,6 +598,214 @@ describe('ArtifactPanel code review artifacts', () => { expect(mockWorkspaceActions.stat).not.toHaveBeenCalled(); }); + it('switches supplied Markdown attachments from source to preview without reading the workspace', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + expect( + container.querySelector('[role="tab"] .lucide-file-text'), + ).not.toBeNull(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(container.querySelector('.cm-content')?.textContent).toContain( + '# Hello attachment', + ); + act(() => { + ( + container.querySelector( + 'button[aria-label="Preview"]', + ) as HTMLButtonElement + ).click(); + }); + await flush(); + expect(container.querySelector('h1')?.textContent).toBe('Hello attachment'); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('switches supplied HTML text from source to preview', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + Attachment page', + previewMimeType: 'text/html', + previewOnly: true, + }, + ]} + activeTabId="attachment:page.html" + reviewChanges={[]} + selectedReviewPath={null} + onSelectTab={() => {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + expect(container.querySelector('.cm-content')?.textContent).toContain( + '

Attachment page

', + ); + act(() => { + ( + container.querySelector( + 'button[aria-label="Preview"]', + ) as HTMLButtonElement + ).click(); + }); + await flush(); + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain( + '

Attachment page

', + ); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('shows a clear unsupported state for binary attachments', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + expect(container.textContent).toContain( + 'Preview is not available for this file type.', + ); + expect(container.querySelector('.cm-content')).toBeNull(); + expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('opens PDF attachments in the browser PDF preview', async () => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:attachment-pdf'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => + root.render( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + , + ), + ); + await flush(); + + expect(container.querySelector('iframe')?.src).toContain( + 'blob:attachment-pdf', + ); + expect(container.querySelector('.cm-content')).toBeNull(); + }); + it('dispatches an available workspace artifact to the dedicated renderer', async () => { mockWorkspaceActions.readWorkspaceFile.mockResolvedValue({ content: validCodeReviewDocument, diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 8b7889c9598..cad0d57dfec 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -15,6 +15,8 @@ import { DownloadIcon } from 'lucide-react'; import { ChevronRightIcon, CirclePlusIcon, + Code2Icon, + EyeIcon, ImageIcon, Maximize2Icon, MessageCirclePlusIcon, @@ -38,7 +40,9 @@ import { useI18n } from '../../i18n'; import { extractErrorDetail } from '../../utils/errorDetail'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import { formatRelativeTime } from '../../utils/formatRelativeTime'; +import { normalizeTextMediaType } from '../../utils/imageIngestion'; import { DialogShell } from '../dialogs/DialogShell'; +import { FileTypeIcon } from '../FileTypeIcon'; import { isSafeHref, Markdown } from '../messages/Markdown'; import { DropdownMenu, @@ -127,6 +131,9 @@ export type ArtifactPanelTab = workspaceCwd?: string; workspaceId?: string; previewContent?: string; + previewData?: Blob; + previewMimeType?: string; + previewOnly?: boolean; } | { id: string; @@ -311,6 +318,7 @@ export function ArtifactPanel({ }: ArtifactPanelProps) { const { t } = useI18n(); const [sideTaskMenuOpen, setSideTaskMenuOpen] = useState(false); + const [previewAttachmentId, setPreviewAttachmentId] = useState(); const sideTaskMenuCloseTimerRef = useRef | null>(null); @@ -339,6 +347,19 @@ export function ArtifactPanel({ [], ); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; + const canPreviewAttachment = + activeTab?.kind === 'file' && + activeTab.previewOnly === true && + /\.(?:html?|md|markdown)$/i.test(activeTab.workspacePath) && + (activeTab.previewContent !== undefined || + !activeTab.previewData || + Boolean( + normalizeTextMediaType( + activeTab.previewMimeType || activeTab.previewData.type, + activeTab.workspacePath, + ), + )); + const attachmentPreview = previewAttachmentId === activeTab?.id; const showReviewMenuItem = items.includes('review') && !tabs.some((tab) => tab.kind === 'review'); const showSideTaskMenuItems = @@ -396,7 +417,13 @@ export function ArtifactPanel({