docs/plans/dingtalk-richtext-multi-image.md
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
imageBase64 and imageMimeType compatibility for existing adapters.content.richText[] through the model prompt and Web Shell transcript.Files:
packages/channels/dingtalk/src/DingtalkAdapter.ts:2119-2140,2343-2353packages/channels/dingtalk/src/DingtalkAdapter.test.tsInterfaces:
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.
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',
},
]);
Run:
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.
Replace the first-only call with a sequential loop:
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.
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.
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"
Files:
packages/channels/base/src/ChannelAgentBridge.ts:110-116packages/channels/base/src/ChannelBase.ts:5251-5284,5656-5661packages/channels/base/src/ChannelBase.test.tsInterfaces:
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:
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.
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t "image"
Expected: FAIL because the bridge options contain only singular image fields.
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.
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
Expected: PASS with no change to non-image attachment behavior.
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"
Files:
packages/channels/base/src/AcpBridge.ts:285-299packages/channels/base/src/AcpBridge.test.tsInterfaces:
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:
prompt: [
{ type: 'image', data: 'first', mimeType: 'image/png' },
{ type: 'image', data: 'second', mimeType: 'image/jpeg' },
{ type: 'text', text: 'describe both' },
];
cd packages/channels/base && npx vitest run src/AcpBridge.test.ts -t "multiple images"
Expected: FAIL because images is not consumed.
Normalize options.images with the legacy pair as fallback, push each image block, then push the text block. Do not alter ACP metadata.
cd packages/channels/base && npx vitest run src/AcpBridge.test.ts
Expected: PASS, including existing single-image behavior.
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"
Files:
packages/channels/base/src/DaemonChannelBridge.ts:43-51,125-142,428-441packages/channels/base/src/DaemonChannelBridge.test.ts:79-101,1889-1948Interfaces:
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:
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' },
];
cd packages/channels/base && npx vitest run src/DaemonChannelBridge.test.ts -t "stores channel images"
Expected: FAIL because only the singular image is uploaded.
Iterate all normalized images, generate deterministic names (image.png, image-2.jpeg, ...), await each uploadAttachment, and push every returned reference before text.
cd packages/channels/base && npx vitest run src/DaemonChannelBridge.test.ts
Expected: PASS and the prompt contains all persisted attachment references.
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"
Files:
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
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.
Run npm run dev -- channel reload, then confirm npm run dev -- channel status reports the DingTalk worker running.
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.