Skip to content
Prev Previous commit
Next Next commit
fix(dingtalk): close streaming marker review blockers
  • Loading branch information
qqqys committed Aug 14, 2026
commit 092ff525f92189aee3c9f5bbdd7d45279b79ccd6
12 changes: 12 additions & 0 deletions packages/channels/dingtalk/src/interaction-presenter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,18 @@ describe('DingtalkInteractionPresenter', () => {
);
});

it('reassembles a file marker split across streamed chunks', async () => {
const sendFallback = vi.fn().mockResolvedValue(undefined);
const presenter = new DingtalkInteractionPresenter({ sendFallback });
presenter.registerRun('run-1', 'owner-1', target);
presenter.appendOutput(segment('segment-1'), 'A [FILE: /tm');
presenter.appendOutput(segment('segment-1'), 'p/x.pdf] B');

await presenter.closeOutput('segment-1', '', 'response_boundary');

expect(sendFallback).toHaveBeenCalledWith('cid-1', 'A B', 'session-1');
});

it('neutralizes an image marker orphaned by file sanitization', async () => {
const sendFallback = vi.fn().mockResolvedValue(undefined);
const presenter = new DingtalkInteractionPresenter({ sendFallback });
Expand Down
40 changes: 26 additions & 14 deletions packages/channels/dingtalk/src/interaction-presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface SegmentPresentation {
run: RunPresentation;
context: ChannelOutputSegmentContext;
content: string;
displayContent: string;
}

export interface DingtalkInteractionPresenterOptions {
Expand Down Expand Up @@ -135,16 +136,19 @@ export class DingtalkInteractionPresenter {
run,
context: segment,
content: '',
displayContent: '',
};
presentation.content = this.boundContent(presentation.content + chunk);
const accumulated = presentation.content + chunk;
presentation.content = this.boundContent(accumulated);
presentation.displayContent = this.boundDisplayContent(accumulated);
Comment on lines +197 to +199

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复:marker-safe 截断不再受被截断 fence opener 的代码区遮罩影响,展示链路仍 fail-closed 清理。验证:chunked fence 的 fallback、live card snapshots、redelivery 三条路径均 leaked=false;DingTalk 347/347 通过。

this.segments.set(segment.segmentId, presentation);
run.activeSegmentId = segment.segmentId;
const statusContext = this.ensureStatusContext(run, segment);
void this.enqueue(run, () => {
this.options.statusCards?.replace(
statusContext,
this.cardTarget(statusContext.target),
presentation.content,
accumulated,
);
});
}
Expand Down Expand Up @@ -193,15 +197,17 @@ export class DingtalkInteractionPresenter {
(await statusCards.flushPending(statusContext.segmentId));
if (deliveredViaCard) {
run.cardDelivered = {
text: sanitizeFallbackOutput(text || presentation.content),
text: text
? this.boundDisplayContent(text)
: presentation.displayContent,
chatId: presentation.context.target.chatId,
sessionId: presentation.context.sessionId,
};
return true;
}
const fallbackText = sanitizeFallbackOutput(
text || presentation.content,
);
const fallbackText = text
? this.boundDisplayContent(text)
: presentation.displayContent;
if (!fallbackText || !this.options.sendFallback) return false;
await this.options.sendFallback(
presentation.context.target.chatId,
Expand All @@ -215,13 +221,13 @@ export class DingtalkInteractionPresenter {
statusCards !== undefined &&
(await statusCards.complete(
statusContext.segmentId,
this.withSenderPrefix(run, text || presentation.content),
this.withSenderPrefix(run, text || presentation.displayContent),
));
run.statusContext = undefined;
if (completed) return true;
const fallbackText = sanitizeFallbackOutput(
text || presentation.content,
);
const fallbackText = text
? this.boundDisplayContent(text)
: presentation.displayContent;
if (!fallbackText || !this.options.sendFallback) return false;
await this.options.sendFallback(
presentation.context.target.chatId,
Expand All @@ -235,10 +241,12 @@ export class DingtalkInteractionPresenter {
statusCards !== undefined &&
(await statusCards.complete(
statusContext.segmentId,
this.withSenderPrefix(run, text || presentation.content),
this.withSenderPrefix(run, text || presentation.displayContent),
));
if (completed) return true;
const fallbackText = sanitizeFallbackOutput(text || presentation.content);
const fallbackText = text
? this.boundDisplayContent(text)
: presentation.displayContent;
if (!fallbackText || !this.options.sendFallback) return false;
await this.options.sendFallback(
presentation.context.target.chatId,
Expand Down Expand Up @@ -395,6 +403,10 @@ export class DingtalkInteractionPresenter {
}

private boundContent(content: string, limit = CONTENT_LIMIT): string {
return truncateOutboundMediaText(content, limit, TRUNCATION_MARKER);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。验证证据:commit cab867a;相关 5 个 Vitest 文件 118/118 通过;@qwen-code/channel-dingtalk build、Prettier、ESLint 通过。

}

private boundDisplayContent(content: string, limit = CONTENT_LIMIT): string {
return truncateOutboundMediaText(
sanitizeFallbackOutput(content),
Comment on lines 461 to +467

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复:累积缓冲保持原始文本,卡片与回退只在输出边界生成安全快照,跨 chunk marker 可完整重组。验证:独立 split-marker harness 通过;DingTalk Vitest 342/342、package build、ESLint、Prettier、git diff --check 均通过。

limit,
Expand All @@ -403,15 +415,15 @@ export class DingtalkInteractionPresenter {
}

private withSenderPrefix(run: RunPresentation, content: string): string {
if (!run.senderPrefix) return this.boundContent(content);
if (!run.senderPrefix) return this.boundDisplayContent(content);
const body = this.withoutExistingSenderPrefix(run, content);
if (!body) return run.senderPrefix;
const separator = '\n\n';
const bodyLimit = Math.max(
0,
CONTENT_LIMIT - run.senderPrefix.length - separator.length,
);
return `${run.senderPrefix}${separator}${this.boundContent(
return `${run.senderPrefix}${separator}${this.boundDisplayContent(
body,
bodyLimit,
)}`;
Expand Down
20 changes: 20 additions & 0 deletions packages/channels/dingtalk/src/outbound-markers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ describe('outbound media markers', () => {
'FILE',
)[0]?.path,
).toBe('/workspace/report.pdf');
expect(
findOutboundMediaMarkers(
'Chart [IMAGE: /tmp/chart.png] as shown [1].',
'IMAGE',
)[0]?.path,
).toBe('/tmp/chart.png');
});

it('walks past index zero and strips the earliest partial marker', () => {
Expand All @@ -36,6 +42,13 @@ describe('outbound media markers', () => {
'[File pending]',
),
).toBe('see [File pending]');
expect(
stripPartialOutboundMediaMarker(
'see [IMAGE: /Users/ben/private/a [FILE: /tmp/b]',
'IMAGE',
'[Image pending]',
),
).toBe('see [Image pending]');
});

it('keeps truncation boundaries outside partial markers', () => {
Expand All @@ -51,5 +64,12 @@ describe('outbound media markers', () => {
expect(
truncateOutboundMediaText(`[${'a'.repeat(200)}`, 100, truncationMarker),
).toHaveLength(100);
expect(
truncateOutboundMediaText(
`${'a'.repeat(114)}[${'b'.repeat(53)}`,
80,
truncationMarker,
),
).toHaveLength(80);
});
});
102 changes: 74 additions & 28 deletions packages/channels/dingtalk/src/outbound-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,75 @@ function maskCode(text: string): string {
return masked.join('');
}

const MEDIA_MARKER_PREFIXES = ['IMAGE:', 'FILE:'];
const MEDIA_MARKER_PATTERN = /\[(IMAGE|FILE):\s*/giu;

function previousOpenBracket(text: string, open: number): number {
return open === 0 ? -1 : text.lastIndexOf('[', open - 1);
}

function isMarkerCloseBoundary(
text: string,
close: number,
boundary: number,
): boolean {
let cursor = close + 1;
if (cursor >= boundary || /\s/u.test(text[cursor]!)) return true;
while (
cursor < boundary &&
/[.,;:!?)}\],。;:!?)】]/u.test(text[cursor]!)
) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。验证证据:DingTalk 12 files/409 tests、ChannelBase 19 files/1038 tests、root build/typecheck、ESLint/Prettier、git diff --check 均通过;修复 commit 68490fb。

cursor++;
}
return (
cursor > close + 1 && (cursor >= boundary || /\s/u.test(text[cursor]!))
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。验证:Node 22 下 DingTalk 全包 12 files / 363 tests、ChannelBase 定向 608 tests、root build/typecheck/bundle、ESLint、Prettier、git diff --check 均通过;独立 generated-dist 复验 11/11 通过。

}

function findMarkerClose(
text: string,
pathStart: number,
boundary: number,
): number {
let close = text.indexOf(']', pathStart);
let lastClose = -1;
while (close !== -1 && close < boundary) {
lastClose = close;
if (isMarkerCloseBoundary(text, close, boundary)) return close;
close = text.indexOf(']', close + 1);
}
return lastClose;
}

function markerBoundary(
visibleText: string,
pathStart: number,
nextOpening: number | undefined,
): number {
let boundary = nextOpening ?? visibleText.length;
const lineBreak = visibleText.slice(pathStart, boundary).search(/[\r\n]/u);
if (lineBreak !== -1) boundary = pathStart + lineBreak;
return boundary;
}

function markerSafeTruncationStart(text: string, start: number): number {
const before = text.slice(0, start);
const lastClose = before.lastIndexOf(']');
const visibleText = maskCode(text);
const before = visibleText.slice(0, start);
let open = before.lastIndexOf('[');
while (open > lastClose) {
const candidate = before.slice(open + 1);
if (!/[\r\n]/u.test(candidate)) {
const normalized = candidate.toUpperCase();
if (
MEDIA_MARKER_PREFIXES.some(
(prefix) =>
prefix.startsWith(normalized) || normalized.startsWith(prefix),
)
) {
const close = text.indexOf(']', start);
const newline = text
.slice(start, close === -1 ? undefined : close)
.search(/[\r\n]/u);
if (close === -1 || newline !== -1) return text.length;
return close + 1;
}
while (open !== -1) {
const opening = visibleText.slice(open).match(/^\[(IMAGE|FILE):\s*/iu);
if (opening) {
const pathStart = open + opening[0].length;
const nextOpeningOffset = visibleText
.slice(pathStart)
.search(MEDIA_MARKER_PATTERN);
const boundary = markerBoundary(
visibleText,
pathStart,
nextOpeningOffset === -1 ? undefined : pathStart + nextOpeningOffset,
);
const close = findMarkerClose(visibleText, pathStart, boundary);
if (close >= start) return close + 1;
if (close === -1 && boundary >= start) return boundary;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。验证:DingTalk 全包 12 files / 353 tests 通过;package build、root build + bundle、ESLint、Prettier、git diff --check 均通过。

}
open = previousOpenBracket(before, open);
}
Expand Down Expand Up @@ -140,8 +182,7 @@ export function findOutboundMediaMarkers(
markerName: 'IMAGE' | 'FILE',
): OutboundMediaMarker[] {
const visibleText = maskCode(text);
const markerPattern = /\[(IMAGE|FILE):\s*/giu;
const openings = [...visibleText.matchAll(markerPattern)];
const openings = [...visibleText.matchAll(MEDIA_MARKER_PATTERN)];
const markers: OutboundMediaMarker[] = [];

for (let i = 0; i < openings.length; i++) {
Expand All @@ -150,11 +191,13 @@ export function findOutboundMediaMarkers(
continue;
}
const pathStart = match.index + match[0].length;
let boundary = openings[i + 1]?.index ?? visibleText.length;
const lineBreak = visibleText.slice(pathStart, boundary).search(/[\r\n]/u);
if (lineBreak !== -1) boundary = pathStart + lineBreak;
const close = visibleText.lastIndexOf(']', boundary - 1);
if (close < pathStart) continue;
const boundary = markerBoundary(
visibleText,
pathStart,
openings[i + 1]?.index,
);
const close = findMarkerClose(visibleText, pathStart, boundary);
if (close === -1) continue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复:不完整外层 marker 只清理到下一媒体 marker 边界,保留并投递内层有效文件;完整带中括号路径不再误判。验证:nested bracket 探针及 webhook 端到端回归通过,输出不含本地路径。

const path = text.slice(pathStart, close).trim();
if (!path) continue;
markers.push({
Expand Down Expand Up @@ -199,7 +242,10 @@ export function stripPartialOutboundMediaMarker(
while (open !== -1) {
const candidate = visibleText.slice(open + 1);
if (/[\r\n]/u.test(candidate)) break;
if (candidate.includes(']')) {
const nextOpen = visibleText.indexOf('[', open + 1);
const ownSegment =
nextOpen === -1 ? candidate : candidate.slice(0, nextOpen - open - 1);
if (ownSegment.includes(']')) {
open = previousOpenBracket(visibleText, open);
continue;
}
Expand Down
25 changes: 11 additions & 14 deletions packages/channels/dingtalk/src/status-card-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export interface StatusCardControllerOptions {
}

function boundContent(content: string): string {
return truncateOutboundMediaText(content, CONTENT_LIMIT, TRUNCATION_MARKER);
}

function boundDisplayContent(content: string): string {
return truncateOutboundMediaText(
sanitizeStreamingMediaMarkers(content),
CONTENT_LIMIT,
Expand Down Expand Up @@ -96,7 +100,7 @@ export class StatusCardController {
if (record.terminal) return;
record.content = boundContent(content);
if (record.streamFailed) return;
record.pendingSnapshot = sanitizeStreamingMediaMarkers(record.content);
record.pendingSnapshot = boundDisplayContent(content);
this.scheduleFlush(record);
}

Expand Down Expand Up @@ -140,6 +144,7 @@ export class StatusCardController {
initialContent = '',
): StatusRecord {
const outTrackId = `qwen-status-${randomUUID()}`;
const initialSnapshot = boundDisplayContent(initialContent);
const record: StatusRecord = {
segmentId: segment.segmentId,
runId: segment.runId,
Expand All @@ -165,7 +170,7 @@ export class StatusCardController {
this.segmentIdsByRun.get(record.runId) ?? new Set<string>();
segmentIds.add(record.segmentId);
this.segmentIdsByRun.set(record.runId, segmentIds);
record.ready = this.create(record, target);
record.ready = this.create(record, target, initialSnapshot);
void record.ready.then((ready) => {
if (ready) this.scheduleStatusRefresh(record);
});
Expand All @@ -177,17 +182,11 @@ export class StatusCardController {
text: string,
retainedContent?: (content: string) => string,
): Promise<boolean> {
return this.finalize(
segmentId,
boundContent(text),
'Completed',
false,
retainedContent,
);
return this.finalize(segmentId, text, 'Completed', false, retainedContent);
}

fail(segmentId: string, error: string): void {
void this.finalize(segmentId, boundContent(error), 'Failed', true);
void this.finalize(segmentId, error, 'Failed', true);
}

cancelRun(runId: string, reason: ChannelTaskCancellationReason): void {
Expand Down Expand Up @@ -241,9 +240,9 @@ export class StatusCardController {
private async create(
record: StatusRecord,
target: { chatId: string; isGroup: boolean },
initialContent: string,
): Promise<boolean> {
try {
const initialContent = sanitizeStreamingMediaMarkers(record.content);
await this.options.client.createAndDeliver({
templateId: STATUS_CARD_TEMPLATE_ID,
outTrackId: record.outTrackId,
Expand Down Expand Up @@ -360,9 +359,7 @@ export class StatusCardController {
const retained =
content ||
(retainedContent ? retainedContent(record.content) : record.content);
const finalContent = boundContent(
sanitizeStreamingMediaMarkers(retained),
);
const finalContent = boundDisplayContent(retained);
await this.options.client.openOrUpdateStream({
outTrackId: record.outTrackId,
key: 'content',
Expand Down
Loading