fix(channels): preserve DingTalk rich-text multi-image messages (#9922)

* docs(channels): design DingTalk multi-image delivery

* docs(channels): plan DingTalk multi-image delivery

* fix(channels): retain all DingTalk rich-text images

* feat(channels): carry ordered prompt images

* feat(channels): send all prompt images over ACP

* feat(channels): persist all channel images in daemon sessions

* fix(channels): clean up failed daemon image uploads

* fix(channels): deliver channel images on older daemons and unknown MIME types

* fix(channels): harden channel image delivery against malformed and rejected turns

- Drop image entries missing data/mimeType in resolvePromptImages so one
  malformed attachment degrades to a prompt without the image instead of
  throwing a TypeError that kills the whole turn (restores the pre-diff
  legacy-guard behavior for out-of-contract extension adapters).
- Require the image/ MIME prefix in channelImageName so non-image types
  whose tail spells a known extension (audio/png) are skipped with a
  warning instead of tripping the daemon store's name/content-type check
  and failing the turn.
- Roll uploaded images back when the daemon definitively rejects prompt
  admission (DaemonHttpError on the admission request, local prompt-queue
  limit), matching the webui client; admitted-turn errors and transport
  failures keep the uploads because the daemon may already reference them.
- Upload prompt images concurrently like the webui path, preserving input
  order and the existing rollback contract.
- Log failed attachment removals during rollback via sanitizeLogText.
- Point CHANNEL_IMAGE_EXTENSIONS at the daemon store's set as source of
  truth; document the ordered images contract in the README.

* fix(channels): skip oversized channel images and pin review gaps (#9922)

* fix(channels): keep channel image delivery within daemon admission limits (#9922)

- Skip channel images whose base64 decodes to zero bytes before
  uploading: the daemon store rejects empty images with 400, which
  failed the whole turn instead of degrading by omission.
- Bound the inline fallback for daemons without session_attachments:
  apply the same per-item admission gate and keep the aggregate base64
  payload under the daemon's 10mb prompt-body limit, so multi-image
  turns against older daemons degrade by omission instead of failing
  with 413.
- Reject oversized images from the base64 length before allocating the
  decoded buffer, so several oversized images no longer allocate their
  full decoded payloads concurrently before being discarded.
- Tag the skip log lines with the session id so operators can correlate
  a skipped image to its channel session.

* fix(channels): close channel image size-gate padding undercount (#9922)

The pre-decode size estimate subtracted trailing '=' padding even when the input length does not complete a quantum; Node's lenient base64 decoder ignores such stray padding, so malformed input decoding to the 8 MiB limit plus one byte slipped past the gate and the daemon's 413 then failed the whole turn. Subtract padding only for multiple-of-4 input so the estimate is a strict upper bound.

Also give the skip reason a caller-supplied label so the inline fallback for daemons without session_attachments names the inline budget instead of an attachment-store limit those daemons do not have.

* fix(channels): roll back uploads on pre-admission cancel

* fix(channels): roll back channel images when cancelled before prompt admission (#9922)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(channels): preserve daemon session client compatibility

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
BaboBen 2026-08-26 14:01:43 +00:00 committed by GitHub
parent dab4ba67ed
commit 83da7233a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2265 additions and 97 deletions

View file

@ -0,0 +1,70 @@
# DingTalk rich-text multi-image delivery
Issue: [#9878](https://github.com/QwenLM/qwen-code/issues/9878)
## Problem
A DingTalk message can contain several `picture` parts in one
`content.richText` array. The DingTalk adapter extracts every part into
`downloadCodes[]`, but the processing path calls `attachMedia` only for
`downloadCodes[0]`. Consequently the model and daemon-backed Web Shell receive
only the first image.
This design covers multiple images in one DingTalk callback. It does not change
how separate messages received during an active turn are steered or collected.
## Design
Extend `ChannelAgentBridgePromptOptions` with an ordered `images` collection
whose entries contain base64 data and MIME type. Keep the existing
`imageBase64` and `imageMimeType` fields as compatibility inputs for adapters
and bridge callers that still send one image.
`ChannelBase` will normalize the legacy image fields and every data-backed
image attachment into one ordered collection. It will pass that collection to
the bridge once per inbound turn. File-backed non-image attachments retain the
existing prompt-path behavior.
The DingTalk adapter will call `attachMedia` for every download code extracted
from a rich-text callback. Image media from the same callback will remain
data-backed so that `ChannelBase` can pass every image as a native vision
input, rather than degrading later images into file-path instructions.
`AcpBridge` will emit one ACP image content block per normalized image before
the text block. `DaemonChannelBridge` will upload each image to the owning
session and place every returned attachment reference before the text block.
This lets the daemon persist all references and lets Web Shell render every
image in the transcript.
## Compatibility
- Existing adapters using `imageBase64` and `imageMimeType` continue to work.
- Existing one-image callers keep their current ordering and behavior.
- An invalid partial legacy pair is ignored as it is today.
- No new configuration or dependency is introduced.
## Failure behavior
DingTalk media downloads remain sequential and preserve callback order. A
failed download keeps the existing per-media warning behavior and does not
prevent successfully downloaded images from reaching the turn. Daemon upload
failure keeps the current prompt failure semantics; the turn is not submitted
with a silently incomplete image set.
## Tests
1. DingTalk adapter: one `richText` callback with multiple picture parts
downloads every code and produces ordered image attachments.
2. ChannelBase: legacy and structured image inputs normalize into an ordered
bridge image collection without dropping later attachments.
3. ACP bridge: every image becomes an ACP image content block before text.
4. Daemon bridge: every image is uploaded, referenced in the prompt, and
therefore available to session persistence and Web Shell replay.
5. Existing single-image tests remain green to prove compatibility.
## Acceptance criteria
Sending five images together in one DingTalk message produces five attachment
references in the daemon session turn, displays five images in Web Shell, and
provides all five images to the selected multimodal model in the original
order.

View file

@ -0,0 +1,313 @@
# DingTalk Rich-Text Multi-Image Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver every image from one DingTalk `richText` callback to the multimodal model and persist every image reference for Web Shell replay.
**Architecture:** Normalize legacy single-image input and structured image attachments into an ordered bridge-level image array. DingTalk downloads every picture part, while ACP emits every image block and the daemon uploads every image before submitting one prompt.
**Tech Stack:** TypeScript, Vitest, DingTalk Stream adapter, Agent Client Protocol, Qwen daemon session attachment API.
**Spec:** `docs/design/dingtalk-richtext-multi-image.md`
## Global Constraints
- Preserve `imageBase64` and `imageMimeType` compatibility for existing adapters.
- Preserve source order from `content.richText[]` through the model prompt and Web Shell transcript.
- Do not change collection of separate messages received during an active turn.
- Add no dependency or configuration.
- Run tests from each package directory.
---
### Task 1: DingTalk downloads every picture part
**Files:**
- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts:2119-2140,2343-2353`
- Test: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`
**Interfaces:**
- Consumes: `extractContent(data).downloadCodes: string[]` in callback order.
- Produces: `Envelope.attachments` containing one data-backed image per successful download in the same order.
- [ ] **Step 1: Write the failing adapter test**
Add a test that sends one `richText` callback with two literal picture parts, returns distinct bytes for each `downloadCode`, and asserts that both codes were downloaded and both base64 attachments reached `handleInbound` in order.
```ts
expect(downloadCodes).toEqual(['picture-1', 'picture-2']);
expect(envelope.attachments).toEqual([
{
type: 'image',
data: Buffer.from([1]).toString('base64'),
mimeType: 'image/png',
},
{
type: 'image',
data: Buffer.from([2]).toString('base64'),
mimeType: 'image/png',
},
]);
```
- [ ] **Step 2: Run the test and verify RED**
Run:
```bash
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts -t "downloads every picture in one richText callback"
```
Expected: FAIL because only `picture-1` is requested and only one attachment exists.
- [ ] **Step 3: Implement ordered multi-download**
Replace the first-only call with a sequential loop:
```ts
for (const downloadCode of content.downloadCodes) {
await this.attachMedia(
envelope,
downloadCode,
content.mediaType,
content.fileName,
content.placeholder,
);
}
```
Make every successful image download append a data-backed attachment; retain the existing temp-file path for file, audio, and video media.
- [ ] **Step 4: Run the targeted adapter tests and verify GREEN**
```bash
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts
```
Expected: PASS, including updated quoted-two-image expectations that both images are data-backed.
- [ ] **Step 5: Commit the adapter behavior**
```bash
git add packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts
git commit -m "fix(channels): retain all DingTalk rich-text images"
```
### Task 2: ChannelBase carries an ordered image collection
**Files:**
- Modify: `packages/channels/base/src/ChannelAgentBridge.ts:110-116`
- Modify: `packages/channels/base/src/ChannelBase.ts:5251-5284,5656-5661`
- Test: `packages/channels/base/src/ChannelBase.test.ts`
**Interfaces:**
- Produces: `ChannelPromptImage { data: string; mimeType: string }` and `ChannelAgentBridgePromptOptions.images?: ChannelPromptImage[]`.
- Compatibility input: `imageBase64?: string` plus `imageMimeType?: string`.
- [ ] **Step 1: Write failing ChannelBase tests**
Add one test with two data-backed `Envelope.attachments` and assert the bridge receives:
```ts
images: [
{ data: 'first', mimeType: 'image/png' },
{ data: 'second', mimeType: 'image/jpeg' },
];
```
Keep the legacy test and change its assertion to the same one-element `images` shape.
- [ ] **Step 2: Run the focused tests and verify RED**
```bash
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t "image"
```
Expected: FAIL because the bridge options contain only singular image fields.
- [ ] **Step 3: Add the bridge image type and normalization**
```ts
export interface ChannelPromptImage {
data: string;
mimeType: string;
}
export interface ChannelAgentBridgePromptOptions {
images?: ChannelPromptImage[];
imageBase64?: string;
imageMimeType?: string;
displayText?: string;
}
```
Build `images` in legacy-first, attachment-order sequence and pass it to `promptBridge.prompt`. Preserve file-path rendering for attachments without image data.
- [ ] **Step 4: Run ChannelBase tests and verify GREEN**
```bash
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
```
Expected: PASS with no change to non-image attachment behavior.
- [ ] **Step 5: Commit the bridge contract**
```bash
git add packages/channels/base/src/ChannelAgentBridge.ts packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts
git commit -m "feat(channels): carry ordered prompt images"
```
### Task 3: ACP sends every image as a native content block
**Files:**
- Modify: `packages/channels/base/src/AcpBridge.ts:285-299`
- Test: `packages/channels/base/src/AcpBridge.test.ts`
**Interfaces:**
- Consumes: `ChannelAgentBridgePromptOptions.images` with legacy single-image fallback.
- Produces: ACP prompt content containing every `{ type: 'image', data, mimeType }` block before text.
- [ ] **Step 1: Write the failing ACP test**
Call `prompt` with two literal images and assert the connection receives:
```ts
prompt: [
{ type: 'image', data: 'first', mimeType: 'image/png' },
{ type: 'image', data: 'second', mimeType: 'image/jpeg' },
{ type: 'text', text: 'describe both' },
];
```
- [ ] **Step 2: Run the ACP test and verify RED**
```bash
cd packages/channels/base && npx vitest run src/AcpBridge.test.ts -t "multiple images"
```
Expected: FAIL because `images` is not consumed.
- [ ] **Step 3: Implement image iteration**
Normalize `options.images` with the legacy pair as fallback, push each image block, then push the text block. Do not alter ACP metadata.
- [ ] **Step 4: Run ACP tests and verify GREEN**
```bash
cd packages/channels/base && npx vitest run src/AcpBridge.test.ts
```
Expected: PASS, including existing single-image behavior.
- [ ] **Step 5: Commit ACP support**
```bash
git add packages/channels/base/src/AcpBridge.ts packages/channels/base/src/AcpBridge.test.ts
git commit -m "feat(channels): send all prompt images over ACP"
```
### Task 4: Daemon persists every image for Web Shell
**Files:**
- Modify: `packages/channels/base/src/DaemonChannelBridge.ts:43-51,125-142,428-441`
- Test: `packages/channels/base/src/DaemonChannelBridge.test.ts:79-101,1889-1948`
**Interfaces:**
- Consumes: ordered `ChannelAgentBridgePromptOptions.images` with legacy fallback.
- Produces: one `session.uploadAttachment` call and one attachment reference prompt block per image.
- [ ] **Step 1: Extend the existing failing daemon replay test**
Pass two images, return two distinct attachment references, and assert upload order plus this prompt:
```ts
prompt: [
{ type: 'image', attachmentId: 'image.png', mimeType: 'image/png', size: 12 },
{
type: 'image',
attachmentId: 'image-2.jpeg',
mimeType: 'image/jpeg',
size: 13,
},
{ type: 'text', text: 'describe' },
];
```
- [ ] **Step 2: Run the daemon test and verify RED**
```bash
cd packages/channels/base && npx vitest run src/DaemonChannelBridge.test.ts -t "stores channel images"
```
Expected: FAIL because only the singular image is uploaded.
- [ ] **Step 3: Implement ordered uploads with unique names**
Iterate all normalized images, generate deterministic names (`image.png`, `image-2.jpeg`, ...), await each `uploadAttachment`, and push every returned reference before text.
- [ ] **Step 4: Run daemon tests and verify GREEN**
```bash
cd packages/channels/base && npx vitest run src/DaemonChannelBridge.test.ts
```
Expected: PASS and the prompt contains all persisted attachment references.
- [ ] **Step 5: Commit daemon persistence**
```bash
git add packages/channels/base/src/DaemonChannelBridge.ts packages/channels/base/src/DaemonChannelBridge.test.ts
git commit -m "feat(channels): persist all channel images in daemon sessions"
```
### Task 5: Verify the complete behavior
**Files:**
- Modify only if verification exposes a defect in the files above.
- Record runtime evidence in the final handoff and Issue/PR text; do not commit credentials or callback payloads.
**Interfaces:**
- Consumes: built channel packages and the configured local DingTalk daemon.
- Produces: unit, build, typecheck, and user-visible Web Shell evidence.
- [ ] **Step 1: Run package verification**
```bash
npm run build
npx tsc --noEmit -p packages/channels/base/tsconfig.json
npx tsc --noEmit -p packages/channels/dingtalk/tsconfig.json
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts src/AcpBridge.test.ts src/DaemonChannelBridge.test.ts
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts
```
Expected: all commands exit 0.
- [ ] **Step 2: Reload the configured channel worker**
Run `npm run dev -- channel reload`, then confirm `npm run dev -- channel status` reports the DingTalk worker running.
- [ ] **Step 3: Perform the five-image E2E**
Send five hand images together as one DingTalk message and verify:
- one DingTalk callback is accepted;
- five media downloads complete;
- five daemon attachment uploads return HTTP 201;
- the persisted user turn has five `attachmentReferences` in order;
- Web Shell shows five image previews;
- the model reports five hands.
- [ ] **Step 4: Self-audit the final diff**
Read `git diff HEAD^` and all untracked files without filtering for expected changes. Verify no secrets, callback payloads, unrelated lockfile changes, or separate-message buffer behavior entered the patch.

View file

@ -197,7 +197,11 @@ interface ChannelAgentBridge {
prompt(
sessionId: string,
text: string,
options?: { imageBase64?: string; imageMimeType?: string },
options?: {
images?: Array<{ data: string; mimeType: string }>; // ordered, preferred
imageBase64?: string; // legacy fallback (first image only)
imageMimeType?: string; // legacy fallback (first image only)
},
): Promise<string>;
cancelSession(sessionId: string): Promise<void>;
shellCommand?(
@ -208,6 +212,8 @@ interface ChannelAgentBridge {
}
```
`prompt` carries images through `options.images` — an ordered array of `{ data, mimeType }` entries delivered to the model in array order. The legacy `imageBase64`/`imageMimeType` pair is a fallback that carries only the first image. Built-in bridges normalize MIME types before use: lowercased, parameters stripped (`image/png; charset=binary``image/png`), and the `image/jpg` alias mapped to `image/jpeg`. Daemon-backed bridges upload images to the daemon attachment store and restrict them to its supported subtypes (`image/bmp`, `image/gif`, `image/jpeg`, `image/png`, `image/webp`) and its per-item admission rule (non-empty and at most 8 MiB once decoded); daemons without attachment support take the same images inline in the prompt body, kept under an aggregate base64 budget. Images outside those limits are skipped with a log line instead of failing the turn, while `AcpBridge` delivers any normalized `image/*` inline.
### AcpBridge
`AcpBridge` is the current implementation used by standalone `qwen channel start`. It manages the `qwen-code --acp` child process and implements `ChannelAgentBridge`.
@ -216,14 +222,14 @@ interface ChannelAgentBridge {
constructor(options: { cliEntryPath: string; cwd: string; model?: string })
```
| Method | Description |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `start()` | Spawn the agent process |
| `stop()` | Kill the agent process |
| `newSession(cwd)` | Create a new ACP session, returns `sessionId` |
| `loadSession(sessionId, cwd)` | Restore an existing session |
| `prompt(sessionId, text, options?)` | Send a message to the agent, returns the full response text. Supports optional `imageBase64` and `imageMimeType`. |
| `isConnected` | Whether the agent process is alive |
| Method | Description |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `start()` | Spawn the agent process |
| `stop()` | Kill the agent process |
| `newSession(cwd)` | Create a new ACP session, returns `sessionId` |
| `loadSession(sessionId, cwd)` | Restore an existing session |
| `prompt(sessionId, text, options?)` | Send a message to the agent, returns the full response text. Supports an ordered `images` array (`{ data, mimeType }` per image, preferred), falling back to the legacy `imageBase64`/`imageMimeType` pair for a single image. |
| `isConnected` | Whether the agent process is alive |
**Events** (EventEmitter):

View file

@ -12,6 +12,7 @@ import {
ACP_PRIVATE_PARENT_CAPABILITY_META_KEY,
CHANNEL_PROMPT_META_KEY,
type ChannelLoopToolHandler,
type ChannelPromptImage,
} from './ChannelAgentBridge.js';
const child = vi.hoisted(() => {
@ -515,6 +516,85 @@ describe('AcpBridge', () => {
});
});
it('sends multiple images before the text prompt', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
const prompt = vi.fn().mockResolvedValue({});
bridge.child = { killed: false, exitCode: null };
bridge.connection = { extMethod: vi.fn(), prompt };
await bridge.prompt('s-1', 'describe both', {
images: [
{ data: 'first', mimeType: 'image/png' },
{ data: 'second', mimeType: 'image/jpeg' },
],
});
expect(prompt).toHaveBeenCalledWith({
sessionId: 's-1',
prompt: [
{ type: 'image', data: 'first', mimeType: 'image/png' },
{ type: 'image', data: 'second', mimeType: 'image/jpeg' },
{ type: 'text', text: 'describe both' },
],
_meta: { [CHANNEL_PROMPT_META_KEY]: true },
});
});
it('sends a legacy-only image pair as one inline image block', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
const prompt = vi.fn().mockResolvedValue({});
bridge.child = { killed: false, exitCode: null };
bridge.connection = { extMethod: vi.fn(), prompt };
await bridge.prompt('s-1', 'describe', {
imageBase64: 'base64-image',
imageMimeType: 'image/png',
});
expect(prompt).toHaveBeenCalledWith({
sessionId: 's-1',
prompt: [
{ type: 'image', data: 'base64-image', mimeType: 'image/png' },
{ type: 'text', text: 'describe' },
],
_meta: { [CHANNEL_PROMPT_META_KEY]: true },
});
});
it('drops malformed prompt image entries before the text prompt', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
const prompt = vi.fn().mockResolvedValue({});
bridge.child = { killed: false, exitCode: null };
bridge.connection = { extMethod: vi.fn(), prompt };
await bridge.prompt('s-1', 'describe', {
images: [
{ data: 'AQID', mimeType: 'image/png' },
{ data: '', mimeType: 'image/webp' },
{ data: 'BAUG', mimeType: '' },
null as unknown as ChannelPromptImage,
],
});
expect(prompt).toHaveBeenCalledWith({
sessionId: 's-1',
prompt: [
{ type: 'image', data: 'AQID', mimeType: 'image/png' },
{ type: 'text', text: 'describe' },
],
_meta: { [CHANNEL_PROMPT_META_KEY]: true },
});
});
it('excludes nested subagent text from the final response', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',

View file

@ -19,6 +19,7 @@ import {
ACP_PRIVATE_PARENT_CAPABILITY_META_KEY,
CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY,
CHANNEL_PROMPT_META_KEY,
resolvePromptImages,
type AvailableCommand,
type ChannelAgentBridge,
type ChannelAgentBridgePromptOptions,
@ -283,11 +284,11 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
this.on('responseBoundary', clearChunks);
const prompt: Array<Record<string, unknown>> = [];
if (options?.imageBase64 && options.imageMimeType) {
for (const image of resolvePromptImages(options)) {
prompt.push({
type: 'image',
data: options.imageBase64,
mimeType: options.imageMimeType,
data: image.data,
mimeType: image.mimeType,
});
}
prompt.push({ type: 'text', text });

View file

@ -107,7 +107,13 @@ export interface ChannelAgentBridgeSessionOptions {
sourceId?: string;
}
export interface ChannelPromptImage {
data: string;
mimeType: string;
}
export interface ChannelAgentBridgePromptOptions {
images?: ChannelPromptImage[];
imageBase64?: string;
imageMimeType?: string;
/** User-authored text shown in transcripts when `text` includes hidden context.
@ -115,6 +121,44 @@ export interface ChannelAgentBridgePromptOptions {
displayText?: string;
}
/**
* Resolves the ordered `images` contract, falling back to the legacy
* single-image pair, and normalizes MIME types in one place: channel
* adapters forward CDN `content-type` headers verbatim, so values arrive
* with parameters and mixed case (e.g. `image/png; charset=binary`), and
* the non-standard `image/jpg` alias rides them too. Entries missing
* `data` or `mimeType` are dropped so one malformed attachment degrades
* to a prompt without that image, like the legacy field guards did.
*/
export function resolvePromptImages(
options?: ChannelAgentBridgePromptOptions,
): ChannelPromptImage[] {
const images =
options?.images && options.images.length > 0
? options.images
: options?.imageBase64 && options.imageMimeType
? [{ data: options.imageBase64, mimeType: options.imageMimeType }]
: [];
return images
.filter(
(image) =>
!!image &&
typeof image.data === 'string' &&
image.data.length > 0 &&
typeof image.mimeType === 'string' &&
image.mimeType.length > 0,
)
.map((image) => {
const cleaned =
image.mimeType.split(';', 1)[0]?.trim().toLowerCase() ?? '';
return {
data: image.data,
// Normalize the alias like the daemon attachment store's own naming.
mimeType: cleaned === 'image/jpg' ? 'image/jpeg' : cleaned,
};
});
}
export interface ChannelAgentBridge {
readonly availableCommands: AvailableCommand[];
getAvailableCommands?(sessionId: string): AvailableCommand[];

View file

@ -10189,7 +10189,7 @@ describe('ChannelBase', () => {
expect(pathLine).not.toContain(rlo);
});
it('extracts image from attachments', async () => {
it('forwards every image attachment in order', async () => {
const ch = createChannel();
await ch.handleInbound(
envelope({
@ -10200,11 +10200,20 @@ describe('ChannelBase', () => {
data: 'base64data',
mimeType: 'image/png',
},
{
type: 'image',
data: 'second-image',
mimeType: 'image/jpeg',
},
],
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = (bridge.prompt as any).mock.calls[0][2];
expect(options.images).toEqual([
{ data: 'base64data', mimeType: 'image/png' },
{ data: 'second-image', mimeType: 'image/jpeg' },
]);
expect(options.imageBase64).toBe('base64data');
expect(options.imageMimeType).toBe('image/png');
});
@ -10220,7 +10229,33 @@ describe('ChannelBase', () => {
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = (bridge.prompt as any).mock.calls[0][2];
expect(options.imageBase64).toBe('legacydata');
expect(options.images).toEqual([
{ data: 'legacydata', mimeType: 'image/jpeg' },
]);
});
it('orders the legacy image before attachment images', async () => {
const ch = createChannel();
await ch.handleInbound(
envelope({
text: 'see image',
imageBase64: 'legacydata',
imageMimeType: 'image/jpeg',
attachments: [
{
type: 'image',
data: 'attachmentdata',
mimeType: 'image/png',
},
],
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = (bridge.prompt as any).mock.calls[0][2];
expect(options.images).toEqual([
{ data: 'legacydata', mimeType: 'image/jpeg' },
{ data: 'attachmentdata', mimeType: 'image/png' },
]);
});
it('prepends instructions on first message only', async () => {

View file

@ -54,6 +54,7 @@ import {
import type {
AvailableCommand,
ChannelAgentBridge,
ChannelPromptImage,
ChannelLoopToolCreateInput,
ChannelLoopToolResult,
PermissionRequestEvent,
@ -5248,15 +5249,22 @@ export abstract class ChannelBase {
promptText = `[Replying to: "${quoted}"]\n\n${promptText}`;
}
// Resolve attachments: extract image for bridge, append file paths to text
// Resolve attachments: extract images for bridge, append file paths to text
let imageBase64 = envelope.imageBase64;
let imageMimeType = envelope.imageMimeType;
const images: ChannelPromptImage[] = [];
if (imageBase64 && imageMimeType) {
images.push({ data: imageBase64, mimeType: imageMimeType });
}
if (envelope.attachments?.length) {
const filePaths: string[] = [];
for (const att of envelope.attachments) {
if (att.type === 'image' && att.data && !imageBase64) {
imageBase64 = att.data;
imageMimeType = att.mimeType;
if (att.type === 'image' && att.data) {
images.push({ data: att.data, mimeType: att.mimeType });
if (!imageBase64) {
imageBase64 = att.data;
imageMimeType = att.mimeType;
}
} else if (att.filePath) {
const label = att.type === 'file' ? 'file' : att.type;
// The filename is attacker-supplied (e.g. DingTalk), so neutralize both
@ -5655,6 +5663,7 @@ export abstract class ChannelBase {
try {
const response = await promptBridge.prompt(sessionId, promptToSend, {
...(images.length > 0 ? { images } : {}),
imageBase64,
imageMimeType,
displayText,

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import {
CHANNEL_PROMPT_AUTHORIZATION_META_KEY,
CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY,
CHANNEL_PROMPT_META_KEY,
resolvePromptImages,
type AvailableCommand,
type BridgeSessionInfo,
type ChannelAgentBridge,
@ -16,6 +17,7 @@ import {
type ToolCallEvent,
} from './ChannelAgentBridge.js';
import { readAvailableCommandAltNames } from './AcpBridge.js';
import { sanitizeLogText } from './sanitize.js';
import {
ChannelLoopMcpServer,
type JsonRpcMessage,
@ -43,6 +45,13 @@ export interface DaemonChannelSessionClient {
},
signal?: AbortSignal,
): Promise<{ stopReason?: string; [key: string]: unknown }>;
uploadAttachment?(
data: Blob,
name: string,
mimeType: string,
signal?: AbortSignal,
): Promise<Record<string, unknown>>;
removeAttachment?(attachmentId: string): Promise<boolean>;
events(opts?: {
signal?: AbortSignal;
lastEventId?: number;
@ -91,6 +100,12 @@ export interface DaemonChannelBridgeOptions {
channelLoopMcpHost?: DaemonChannelLoopMcpHost;
deleteSessionData?: (sessionId: string) => Promise<void>;
promptAuthorization?: string;
/**
* The daemon advertises the `session_attachments` capability. Daemons
* predating the attachment upload routes receive prompt images inline
* instead, as before the upload path existed.
*/
sessionAttachments?: boolean;
}
export interface DaemonPermissionRequestEvent {
@ -125,6 +140,80 @@ function getTextContent(content: unknown): string | undefined {
return getString(content['text']);
}
// Mirrors the daemon attachment store's SUPPORTED_IMAGE_MIME_TYPES
// (packages/acp-bridge/src/sessionAttachments.ts): the store rejects uploads
// outside that set, and channels/base keeps no acp-bridge dependency, so the
// set is repeated here and checked before uploading.
const CHANNEL_IMAGE_EXTENSIONS = ['bmp', 'gif', 'jpeg', 'png', 'webp'];
// Mirrors the store's SESSION_ATTACHMENT_MAX_ITEM_BYTES and empty-image
// rejection (same file): checked before delivery so one inadmissible image
// degrades by omission instead of failing the whole turn.
const CHANNEL_IMAGE_MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
// Daemons without `session_attachments` parse the prompt body with
// express.json({ limit: '10mb' }), so the inline fallback keeps the
// aggregate base64 payload below that cap with headroom for the text
// prompt and the JSON envelope.
const CHANNEL_IMAGE_INLINE_MAX_BASE64_BYTES = 8 * 1024 * 1024;
function channelImageName(mimeType: string, index = 0): string | undefined {
if (!mimeType.startsWith('image/')) {
return undefined;
}
const extension = mimeType.slice('image/'.length);
if (!CHANNEL_IMAGE_EXTENSIONS.includes(extension)) {
return undefined;
}
return index === 0 ? `image.${extension}` : `image-${index + 1}.${extension}`;
}
function decodeChannelImage(
data: string,
oversizedReason: string,
): { bytes: Buffer } | { skip: string } {
// Valid base64 decodes to at most this many bytes, so an oversized image
// is rejected on length alone instead of allocating a buffer the size
// check would discard. Padding is subtracted only when the input length
// completes a quantum: Node's decoder ignores a stray trailing '=' on
// malformed input, and counting it would undercount the decoded size.
let estimatedBytes = Math.floor((data.length * 3) / 4);
if (data.length % 4 === 0) {
if (data.endsWith('==')) estimatedBytes -= 2;
else if (data.endsWith('=')) estimatedBytes -= 1;
}
if (estimatedBytes > CHANNEL_IMAGE_MAX_UPLOAD_BYTES) {
return { skip: oversizedReason };
}
const bytes = Buffer.from(data, 'base64');
if (bytes.byteLength === 0) {
return { skip: 'empty once base64-decoded' };
}
return { bytes };
}
/**
* Structural match for the daemon SDK's definite prompt-admission
* rejections: `DaemonHttpError` from the admission request itself, or
* `DaemonPendingPromptLimitError` raised before any request. channels/base
* keeps no dependency on the SDK, so match by shape; post-admission turn
* errors carry `_daemonTurnError` and must NOT match by then the daemon
* may already have resolved the uploaded attachments.
*/
function isDefinitePromptAdmissionRejection(error: unknown): boolean {
if (!isRecord(error)) {
return false;
}
if (error['name'] === 'DaemonPendingPromptLimitError') {
return true;
}
return (
error['name'] === 'DaemonHttpError' &&
typeof error['status'] === 'number' &&
error['_daemonTurnError'] !== true
);
}
function getSessionUpdate(data: unknown): Record<string, unknown> | undefined {
if (!isRecord(data) || !isRecord(data['update'])) {
return undefined;
@ -407,41 +496,153 @@ export class DaemonChannelBridge
this.on('responseBoundary', clearChunks);
this.on('sessionDied', onSessionDied);
const turnBarrier = this.createTurnBarrier(sessionId);
const prompt: Array<Record<string, unknown>> = [];
if (options?.imageBase64 && options.imageMimeType) {
prompt.push({
type: 'image',
data: options.imageBase64,
mimeType: options.imageMimeType,
});
}
prompt.push({ type: 'text', text });
// Always presented: the daemon validates it for the channel-turn
// classification as well as the display projection, and channel
// prompts without display text still need the classification.
const promptAuthorization = this.options.promptAuthorization;
const uploadedAttachmentIds: string[] = [];
let rollbackUploadedAttachments = false;
const uploadAttachment = session.uploadAttachment?.bind(session);
const removeAttachment = session.removeAttachment?.bind(session);
try {
const result = await session.prompt(
{
prompt,
_meta: {
[CHANNEL_PROMPT_META_KEY]: true,
...(promptAuthorization
? {
[CHANNEL_PROMPT_AUTHORIZATION_META_KEY]: promptAuthorization,
}
: {}),
...(options?.displayText !== undefined
? {
[CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: options.displayText,
}
: {}),
const prompt: Array<Record<string, unknown>> = [];
const images = resolvePromptImages(options);
if (
this.options.sessionAttachments &&
uploadAttachment &&
removeAttachment
) {
try {
// Fan the uploads out like the webui's attachment path: names are
// index-disambiguated and prompt order comes from the array order,
// so nothing serializes the uploads themselves.
const uploads = await Promise.allSettled(
images.map(async (image, index) => {
const name = channelImageName(image.mimeType, index);
if (!name) {
// One unrecognized subtype must not fail the whole turn;
// degrade by omission.
process.stderr.write(
`[DaemonChannelBridge] skipped channel image with unsupported MIME type ${sanitizeLogText(image.mimeType, 128)} for session ${sanitizeLogText(sessionId, 128)}\n`,
);
return undefined;
}
const decoded = decodeChannelImage(
image.data,
'above the daemon attachment size limit',
);
if ('skip' in decoded) {
process.stderr.write(
`[DaemonChannelBridge] skipped channel image ${decoded.skip} ${sanitizeLogText(image.mimeType, 128)} for session ${sanitizeLogText(sessionId, 128)}\n`,
);
return undefined;
}
const attachment = await uploadAttachment(
new Blob([decoded.bytes], {
type: image.mimeType,
}),
name,
image.mimeType,
controller.signal,
);
const attachmentId = getString(attachment['attachmentId']);
if (attachmentId) uploadedAttachmentIds.push(attachmentId);
return attachment;
}),
);
const failure = uploads.find(
(upload): upload is PromiseRejectedResult =>
upload.status === 'rejected',
);
if (failure) {
throw failure.reason;
}
for (const upload of uploads) {
if (upload.status === 'fulfilled' && upload.value) {
prompt.push(upload.value);
}
}
} catch (error) {
rollbackUploadedAttachments = true;
throw error;
}
} else {
// Daemons without `session_attachments` take images inline.
let inlineBase64Bytes = 0;
for (const image of images) {
const decoded = decodeChannelImage(
image.data,
'above the inline image budget',
);
if ('skip' in decoded) {
process.stderr.write(
`[DaemonChannelBridge] skipped channel image ${decoded.skip} ${sanitizeLogText(image.mimeType, 128)} for session ${sanitizeLogText(sessionId, 128)}\n`,
);
continue;
}
if (
inlineBase64Bytes + image.data.length >
CHANNEL_IMAGE_INLINE_MAX_BASE64_BYTES
) {
process.stderr.write(
`[DaemonChannelBridge] skipped channel image to keep the inline prompt under the daemon body limit ${sanitizeLogText(image.mimeType, 128)} for session ${sanitizeLogText(sessionId, 128)}\n`,
);
continue;
}
inlineBase64Bytes += image.data.length;
prompt.push({
type: 'image',
data: image.data,
mimeType: image.mimeType,
});
}
}
prompt.push({ type: 'text', text });
if (controller.signal.aborted) {
rollbackUploadedAttachments = true;
controller.signal.throwIfAborted();
}
// Always presented: the daemon validates it for the channel-turn
// classification as well as the display projection, and channel
// prompts without display text still need the classification.
const promptAuthorization = this.options.promptAuthorization;
// Aborted after the uploads settled but before admission: the SDK
// rejects an already-aborted signal with a pre-request AbortError that
// isDefinitePromptAdmissionRejection does not match, so the uploads
// would leak. Non-admission is certain at this point; roll back.
if (controller.signal.aborted) {
rollbackUploadedAttachments = true;
throw controller.signal.reason;
}
let result: { stopReason?: string; [key: string]: unknown };
try {
result = await session.prompt(
{
prompt,
_meta: {
[CHANNEL_PROMPT_META_KEY]: true,
...(promptAuthorization
? {
[CHANNEL_PROMPT_AUTHORIZATION_META_KEY]:
promptAuthorization,
}
: {}),
...(options?.displayText !== undefined
? {
[CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: options.displayText,
}
: {}),
},
},
},
controller.signal,
);
controller.signal,
);
} catch (error) {
// Roll back only when the turn was never admitted; once admitted the
// daemon may already have resolved the uploads.
if (isDefinitePromptAdmissionRejection(error)) {
rollbackUploadedAttachments = true;
}
throw error;
}
// Prefer turn_complete for deterministic chunk collection (SSE path).
// Fall back to one event-loop tick for non-SSE prompt paths (blocking
// HTTP, non-202 responses) where turn_complete never arrives.
@ -470,6 +671,24 @@ export class DaemonChannelBridge
) {
this.activePromptControllers.delete(sessionId);
}
if (rollbackUploadedAttachments && removeAttachment) {
const removals = await Promise.allSettled(
uploadedAttachmentIds.map((attachmentId) =>
removeAttachment(attachmentId),
),
);
removals.forEach((removal, index) => {
if (removal.status === 'rejected') {
const reason =
removal.reason instanceof Error
? removal.reason.message
: String(removal.reason);
process.stderr.write(
`[DaemonChannelBridge] failed to remove channel image ${sanitizeLogText(uploadedAttachmentIds[index] ?? '', 128)} for session ${sanitizeLogText(sessionId, 128)} during rollback: ${sanitizeLogText(reason, 256)}\n`,
);
}
});
}
}
}

View file

@ -77,6 +77,8 @@ function daemonSession(
sessionId,
workspaceCwd: '/tmp',
prompt: vi.fn().mockResolvedValue({}),
uploadAttachment: vi.fn(),
removeAttachment: vi.fn().mockResolvedValue(true),
events: vi.fn(async function* (options?: { signal?: AbortSignal }) {
await new Promise<void>((resolve) => {
if (options?.signal?.aborted) {

View file

@ -3790,6 +3790,74 @@ describe('DingtalkChannel quoted media', () => {
).onMessage(downstream);
}
it('downloads every picture in one richText callback', async () => {
const downloadCodes: string[] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation(
(input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.startsWith('https://oapi.dingtalk.com/gettoken')) {
return Promise.resolve(
new Response(
JSON.stringify({ errcode: 0, access_token: 'app-token' }),
{ status: 200 },
),
);
}
if (
url === 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
) {
const request = JSON.parse(String(init?.body)) as {
downloadCode: string;
};
downloadCodes.push(request.downloadCode);
return Promise.resolve(
new Response(
JSON.stringify({
downloadUrl: `https://example.com/${request.downloadCode}`,
}),
{ status: 200 },
),
);
}
const bytes = url.endsWith('/picture-1')
? new Uint8Array([1])
: new Uint8Array([2]);
return Promise.resolve(
new Response(bytes, {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
},
);
const channel = createChannel();
sendDirectMedia(channel, 'richText', {
richText: [
{ type: 'picture', downloadCode: 'picture-1' },
{ type: 'picture', downloadCode: 'picture-2' },
],
});
await vi.waitFor(() => {
expect(channel.handleInbound).toHaveBeenCalledOnce();
});
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
expect(downloadCodes).toEqual(['picture-1', 'picture-2']);
expect(envelope.attachments).toEqual([
{
type: 'image',
data: Buffer.from([1]).toString('base64'),
mimeType: 'image/png',
},
{
type: 'image',
data: Buffer.from([2]).toString('base64'),
mimeType: 'image/png',
},
]);
});
it('downloads a replied picture and attaches it to the prompt', async () => {
const downloadCodes = mockMediaDownload(
'image/png',
@ -4000,11 +4068,7 @@ describe('DingtalkChannel quoted media', () => {
if (filePath) tempDirs.add(dirname(filePath));
});
// R4-1: ChannelBase resolves a single inline image per envelope (the first
// data-only image attachment fills imageBase64) and silently drops every
// later data-only attachment, so the quoted image must be file-backed when
// the message's own image already occupies the slot.
it('file-backs a quoted image when the message already carries its own image', async () => {
it('keeps a quoted image data-backed when the message carries its own image', async () => {
const downloadCodes = mockMediaDownload(
'image/png',
new Uint8Array([1, 2, 3]),
@ -4054,26 +4118,18 @@ describe('DingtalkChannel quoted media', () => {
referencedText: '[image]',
});
expect(envelope.attachments).toHaveLength(2);
// The own image keeps the single inline slot ChannelBase resolves.
expect(envelope.attachments?.[0]).toEqual({
type: 'image',
data: Buffer.from([1, 2, 3]).toString('base64'),
mimeType: 'image/png',
});
// The quoted image must not be a second data-only attachment — that shape
// is silently dropped by ChannelBase's single-image resolution.
const quotedAttachment = envelope.attachments?.[1];
expect(quotedAttachment).toMatchObject({
type: 'image',
mimeType: 'image/png',
});
expect(quotedAttachment).not.toHaveProperty('data');
const filePath = quotedAttachment?.filePath;
if (filePath) tempDirs.add(dirname(filePath));
expect(filePath).toBeTruthy();
expect(existsSync(filePath!)).toBe(true);
expect(readFileSync(filePath!)).toEqual(Buffer.from([1, 2, 3]));
expect(quotedAttachment?.fileName).toMatch(/^dingtalk_image_\d+\.png$/);
expect(envelope.attachments).toEqual([
{
type: 'image',
data: Buffer.from([1, 2, 3]).toString('base64'),
mimeType: 'image/png',
},
{
type: 'image',
data: Buffer.from([1, 2, 3]).toString('base64'),
mimeType: 'image/png',
},
]);
});
it('cleans the generated placeholder for a direct file message', async () => {

View file

@ -2116,16 +2116,7 @@ export class DingtalkChannel extends ChannelBase {
const media = await downloadMedia(downloadCode, robotCode, token);
if (!media) return;
// ChannelBase fills a single imageBase64 slot from the FIRST data-only
// image attachment and silently drops every later one, so an image
// arriving after the slot is taken (e.g. a quoted picture alongside the
// message's own picture) falls through to the file-backed path — the
// `saved to:` prompt line is what keeps it reachable for the agent.
const inlineImageSlotFree = !(envelope.attachments || []).some(
(attachment) => attachment.type === 'image' && attachment.data,
);
if (mediaType === 'image' && inlineImageSlotFree) {
if (mediaType === 'image') {
const mimeType = media.mimeType.startsWith('image/')
? media.mimeType
: 'image/jpeg';
@ -2341,15 +2332,17 @@ export class DingtalkChannel extends ChannelBase {
}
const processMessage = async () => {
// Download media if present (first downloadCode only for images)
// Download media in callback order.
if (content.downloadCodes.length > 0 && content.mediaType) {
await this.attachMedia(
envelope,
content.downloadCodes[0]!,
content.mediaType,
content.fileName,
content.placeholder,
);
for (const downloadCode of content.downloadCodes) {
await this.attachMedia(
envelope,
downloadCode,
content.mediaType,
content.fileName,
content.placeholder,
);
}
}
if (quoted.media) {
await this.attachMedia(

View file

@ -1319,6 +1319,43 @@ describe('runChannelDaemonWorker', () => {
expect(bridgeFacade.shellCommand).toBeTypeOf('function');
});
it('enables attachment uploads only when capabilities include session_attachments', async () => {
const sdk = createSdk();
sdk.client.capabilities.mockResolvedValueOnce({
v: 1,
mode: 'http-bridge',
features: ['session_attachments'],
modelServices: [],
workspaceCwd: '/workspace',
});
await runChannelDaemonWorker({
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
});
expect(mockDaemonChannelBridge).toHaveBeenCalledWith(
expect.objectContaining({ sessionAttachments: true }),
);
});
it('keeps attachment uploads off for daemons without session_attachments', async () => {
const sdk = createSdk();
await runChannelDaemonWorker({
daemonUrl: 'http://127.0.0.1:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
});
expect(mockDaemonChannelBridge).toHaveBeenCalledWith(
expect.objectContaining({ sessionAttachments: false }),
);
});
it('fails fast for unknown selected channel names', async () => {
const sdk = createSdk();

View file

@ -95,6 +95,7 @@ import {
} from './loop-runtime.js';
const SESSION_SHELL_COMMAND_FEATURE = 'session_shell_command';
const SESSION_ATTACHMENTS_FEATURE = 'session_attachments';
const MAX_ACTIVE_WEBHOOK_TASKS = 16;
const WORKER_SHUTDOWN_DRAIN_MS = 10_000;
@ -492,6 +493,9 @@ export async function runChannelDaemonWorker(
DaemonSessionClient: sdk.DaemonSessionClient,
clientId: `qwen-channel-worker:${process.pid}`,
}),
sessionAttachments: capabilities.features.includes(
SESSION_ATTACHMENTS_FEATURE,
),
...(opts.promptAuthorization
? { promptAuthorization: opts.promptAuthorization }
: {}),