fix(qq): prevent turn feed message spam

This commit is contained in:
xmanrui 2026-08-24 00:48:21 +08:00
parent 6ea048b277
commit ade50210f0
10 changed files with 382 additions and 213 deletions

View file

@ -52,7 +52,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息 | | 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息 |
| 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 | | 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 |
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 | | 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 | | QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”并以单条 Markdown 回复,群聊被 @ 后只发送最终答案 |
| Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API | | Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API |
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;默认私聊直接响应、群聊被提及或回复时响应,也可为每个机器人独立启用私聊白名单安全模式;通过编辑消息流式显示回答 | | Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;默认私聊直接响应、群聊被提及或回复时响应,也可为每个机器人独立启用私聊白名单安全模式;通过编辑消息流式显示回答 |
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 | | Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,21 @@
// QQ markdown 回复投递:长文按代码块/表格边界切分,以 msg_type=2 发送, // QQ markdown 回复投递:长文尽量按结构边界切分,以 msg_type=2 发送,
// 平台拒绝 markdown 时逐条回退纯文本。 // 平台拒绝 markdown 时逐条回退纯文本。
const DEFAULT_CHUNK_LIMIT = 4_500; const DEFAULT_CHUNK_LIMIT = 4_500;
const CODE_FENCE_OPEN = /^```/; const CODE_FENCE_OPEN = /^```/;
const GFM_TABLE_LINE = /^\|.+\|$/; const GFM_TABLE_LINE = /^\|.+\|$/;
const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
const PARTIAL_REPLY_NOTICE = '回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。';
function safeSliceIndex(value, limit) {
let index = Math.min(limit, value.length);
const before = value.charCodeAt(index - 1);
const after = value.charCodeAt(index);
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
index -= 1;
}
return Math.max(1, index);
}
/** /**
* 按换行边界切分 Markdown 文本: * 按换行边界切分 Markdown 文本:
@ -44,8 +56,9 @@ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
} }
let remaining = block; let remaining = block;
while (remaining.length > bound) { while (remaining.length > bound) {
chunks.push(remaining.slice(0, bound)); const index = safeSliceIndex(remaining, bound);
remaining = remaining.slice(bound); chunks.push(remaining.slice(0, index));
remaining = remaining.slice(index);
} }
current = remaining; current = remaining;
}; };
@ -65,8 +78,9 @@ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
chunks.push(current); chunks.push(current);
current = ''; current = '';
} }
chunks.push(remaining.slice(0, bound)); const index = safeSliceIndex(remaining, bound);
remaining = remaining.slice(bound); chunks.push(remaining.slice(0, index));
remaining = remaining.slice(index);
} }
appendBlock(remaining); appendBlock(remaining);
}; };
@ -115,11 +129,32 @@ function nextMsgSeq() {
export async function sendMarkdownReply(bot, target, text, { logger } = {}) { export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
const chunks = chunkMarkdownText(text); const chunks = chunkMarkdownText(text);
const results = []; const results = [];
for (const chunk of chunks) { const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
const overflow = passiveLimit !== null && chunks.length > passiveLimit;
const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
const proactiveTarget = target?.msgId
? { scope: target.scope, targetId: target.targetId }
: target;
let partialNoticeSent = false;
const sendPartialNotice = async () => {
if (partialNoticeSent || !target?.msgId) return;
partialNoticeSent = true;
try {
results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE));
} catch (error) {
logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
}
};
for (const [index, chunk] of chunks.entries()) {
const deliveryTarget = overflow && index >= passiveContentCount
? proactiveTarget
: target;
if (typeof bot?.send === 'function') { if (typeof bot?.send === 'function') {
try { try {
results.push(await bot.send({ results.push(await bot.send({
target, target: deliveryTarget,
msgType: 2, msgType: 2,
markdown: { content: chunk }, markdown: { content: chunk },
extra: { msg_seq: nextMsgSeq() }, extra: { msg_seq: nextMsgSeq() },
@ -129,7 +164,13 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error); logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
} }
} }
results.push(await bot.sendText(target, chunk)); try {
results.push(await bot.sendText(deliveryTarget, chunk));
} catch (error) {
if (results.length === 0) throw error;
await sendPartialNotice();
break;
}
} }
return results; return results;
} }

View file

@ -592,6 +592,7 @@ export class QqHarnessBridge {
const text = promptMessage.content; const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage); const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage); const hasFiles = hasInboundFiles(promptMessage);
let stream = null;
try { try {
if (!text && !hasImages && !hasFiles) { if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。'); await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
@ -644,16 +645,17 @@ export class QqHarnessBridge {
const content = hasImages const content = hasImages
? await promptContentForMessage(promptMessage, { signal: this.#signal }) ? await promptContentForMessage(promptMessage, { signal: this.#signal })
: undefined; : undefined;
// 过程流:把一次 Turn 的中间过程逐条推送给用户—— // QQ C2C keeps one stream bubble. Progress is collected but never submitted:
// 模型说明文本、每个 Tool call、失败的工具错误详情,最后是完整回答。 // some clients reject replacing an already visible stream frame, which would
let pendingStepText = null; // otherwise leave a stale progress bubble plus a separate fallback answer.
const pushNotice = async (text) => { if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
try { try {
await this.#bot.sendText(target, text); stream = this.#bot.openStream({ target });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-im:qq] unable to send a turn progress notice:', error); this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
} }
}; }
const toolErrors = [];
let answer; let answer;
let artifacts = []; let artifacts = [];
try { try {
@ -668,25 +670,13 @@ export class QqHarnessBridge {
timeoutMs: this.#replyTimeoutMs, timeoutMs: this.#replyTimeoutMs,
signal: this.#signal, signal: this.#signal,
control: { owner: this, key }, control: { owner: this, key },
onUpdate: async (update) => { progressMode: 'all',
if (update.type === 'text') { onUpdate: (update) => {
// 当前 step 的累积文本:暂存,等下一个工具或结束时一次性推送。
pendingStepText = update.text;
return;
}
if (update.type === 'tool') {
// 工具调用本身不推送:agent 任务工具调用频繁,逐条推送会刷屏;
// 它只作为边界把上一段说明文本定稿推送。失败时随错误详情带出工具名。
if (nonEmptyString(pendingStepText)) {
await pushNotice(pendingStepText.trim());
}
pendingStepText = null;
return;
}
if (update.error) { if (update.error) {
const label = nonEmptyString(update.toolName) const label = nonEmptyString(update.toolName)
? `Tool call ${update.toolName}` : 'Tool call'; ? `Tool call ${update.toolName}` : 'Tool call';
await pushNotice(`${label}\nError: ${update.error}`); const text = `${label}\nError: ${update.error}`;
toolErrors.push(text);
} }
}, },
onInteraction: (interaction) => this.#handleInteraction(interaction, { onInteraction: (interaction) => this.#handleInteraction(interaction, {
@ -707,22 +697,42 @@ export class QqHarnessBridge {
} }
this.#signal?.throwIfAborted(); this.#signal?.throwIfAborted();
const answerText = answerTextForDelivery(answer, artifacts); const answerText = answerTextForDelivery(answer, artifacts);
// 结束前仍有一段未推送的说明文本(且不是最终回答本身)时补发。 const displayAnswer = toolErrors.length > 0
if (nonEmptyString(pendingStepText) && pendingStepText.trim() !== answerText.trim()) { ? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
await pushNotice(pendingStepText.trim()); : answerText;
}
const displayAnswer = answerText;
let textReceipt = null; let textReceipt = null;
let textSendError = null; let textSendError = null;
try { try {
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, { let streamFinished = false;
logger: this.#logger, if (stream) {
}); try {
textReceipt = createDeliveryReceipt({ await stream.update(displayAnswer);
deliveryId: messageId, streamFinished = true;
presentation: 'qq-text', textReceipt = createDeliveryReceipt({
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)), deliveryId: messageId,
}); presentation: 'qq-text',
providerMessageIds: providerMessageIdsFor(stream),
});
try {
await stream.complete();
} catch (error) {
this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
}
} catch (error) {
stream.cancel?.();
this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
}
}
if (!streamFinished) {
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
logger: this.#logger,
});
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'qq-text',
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
});
}
} catch (error) { } catch (error) {
textSendError = error; textSendError = error;
this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error); this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error);
@ -741,6 +751,11 @@ export class QqHarnessBridge {
return delivery.receipt; return delivery.receipt;
} catch (error) { } catch (error) {
if (error?.code === 'turn-stopped') { if (error?.code === 'turn-stopped') {
try {
stream?.cancel?.();
} catch (streamError) {
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
}
try { try {
await this.#bot.sendText(target, '已停止。'); await this.#bot.sendText(target, '已停止。');
} catch (sendError) { } catch (sendError) {
@ -749,6 +764,11 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
try {
stream?.cancel?.();
} catch (streamError) {
this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
}
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error); this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error); this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);

View file

@ -368,7 +368,7 @@ export class HarnessReplyTracker {
return this.#targetTurn; return this.#targetTurn;
} }
consume(entries) { consumeAll(entries) {
const updates = []; const updates = [];
// 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出, // 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
// 让消费方能按顺序看到每个工具调用与结果。 // 让消费方能按顺序看到每个工具调用与结果。
@ -461,6 +461,10 @@ export class HarnessReplyTracker {
} }
return updates; return updates;
} }
consume(entries) {
return this.consumeAll(entries).at(-1) ?? null;
}
} }
export class HarnessRpcError extends Error { export class HarnessRpcError extends Error {
@ -1081,6 +1085,7 @@ export class HarnessClient {
const timeoutMs = options.timeoutMs ?? 600_000; const timeoutMs = options.timeoutMs ?? 600_000;
const signal = options.signal; const signal = options.signal;
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null; const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
const progressMode = options.progressMode === 'all' ? 'all' : 'latest';
const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null; const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
const onInteraction = typeof options.onInteraction === 'function' const onInteraction = typeof options.onInteraction === 'function'
? options.onInteraction ? options.onInteraction
@ -1234,9 +1239,10 @@ export class HarnessClient {
this.#consumeInteractionOwnerships(sessionId, history.events ?? []); this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
if (!wasActive && ownership.active) ownership.reconnect?.(); if (!wasActive && ownership.active) ownership.reconnect?.();
} }
const updates = tracker.consume(history.events ?? []); const updates = tracker.consumeAll(history.events ?? []);
if (onUpdate) { if (onUpdate) {
for (const update of updates) { const visibleUpdates = progressMode === 'all' ? updates : updates.slice(-1);
for (const update of visibleUpdates) {
try { try {
await onUpdate(update); await onUpdate(update);
} catch (error) { } catch (error) {

View file

@ -399,7 +399,7 @@ test('reply tracker associates only the Harness turn created by the DingTalk pro
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '钉钉' } }, data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '钉钉' } },
} }, } },
]); ]);
assert.deepEqual(update, [{ type: 'text', text: '钉钉' }]); assert.deepEqual(update, { type: 'text', text: '钉钉' });
tracker.consume([ tracker.consume([
{ event: { { event: {
seq: 6, seq: 6,

View file

@ -869,7 +869,7 @@ test('HarnessClient delivers an existing file-only Turn directly', async (t) =>
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => { test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 }); const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
assert.deepEqual(tracker.consume([ assert.equal(tracker.consume([
{ event: { type: 'turn/start', seq: 11, data: { turn: 4 } } }, { event: { type: 'turn/start', seq: 11, data: { turn: 4 } } },
{ event: { { event: {
type: 'user/message', type: 'user/message',
@ -882,7 +882,7 @@ test('HarnessReplyTracker correlates the prompt and emits only answer text', ()
data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: '忽略' } }, data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: '忽略' } },
} }, } },
{ event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } }, { event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } },
]), []); ]), null);
assert.equal(tracker.finished, false); assert.equal(tracker.finished, false);
const first = tracker.consume([ const first = tracker.consume([
@ -903,7 +903,7 @@ test('HarnessReplyTracker correlates the prompt and emits only answer text', ()
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '深圳' } }, data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '深圳' } },
} }, } },
]); ]);
assert.deepEqual(first, [{ type: 'text', text: '深圳' }]); assert.deepEqual(first, { type: 'text', text: '深圳' });
const second = tracker.consume([ const second = tracker.consume([
{ event: { { event: {
@ -917,7 +917,7 @@ test('HarnessReplyTracker correlates the prompt and emits only answer text', ()
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '明天有雨' } }, data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '明天有雨' } },
} }, } },
]); ]);
assert.deepEqual(second, [{ type: 'text', text: '深圳明天有雨' }]); assert.deepEqual(second, { type: 'text', text: '深圳明天有雨' });
const final = tracker.consume([ const final = tracker.consume([
{ event: { { event: {
@ -934,7 +934,7 @@ test('HarnessReplyTracker correlates the prompt and emits only answer text', ()
} }, } },
{ event: { type: 'turn/end', seq: 21, data: { turn: 5, reason: { kind: 'completed' } } } }, { event: { type: 'turn/end', seq: 21, data: { turn: 5, reason: { kind: 'completed' } } } },
]); ]);
assert.deepEqual(final, [{ type: 'text', text: '深圳明天有阵雨。' }]); assert.deepEqual(final, { type: 'text', text: '深圳明天有阵雨。' });
assert.equal(tracker.finished, true); assert.equal(tracker.finished, true);
assert.equal(tracker.answer, '深圳明天有阵雨。'); assert.equal(tracker.answer, '深圳明天有阵雨。');
assert.deepEqual(tracker.reason, { kind: 'completed' }); assert.deepEqual(tracker.reason, { kind: 'completed' });
@ -947,16 +947,16 @@ test('HarnessReplyTracker emits tool progress without exposing tool results', ()
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-tool' } } }, { type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-tool' } } },
{ type: 'tool/call', seq: 3, data: { turn: 1, step: 1, name: 'web_search' } }, { type: 'tool/call', seq: 3, data: { turn: 1, step: 1, name: 'web_search' } },
]); ]);
assert.deepEqual(update, [{ type: 'tool', name: 'web_search' }]); assert.deepEqual(update, { type: 'tool', name: 'web_search' });
assert.deepEqual(tracker.consume([ assert.deepEqual(tracker.consume([
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } }, { type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
]), [{ type: 'status', text: '正在整理结果…', toolName: 'web_search' }]); ]), { type: 'status', text: '正在整理结果…', toolName: 'web_search' });
}); });
test('HarnessReplyTracker keeps every frame of a batched turn in order', () => { test('HarnessReplyTracker keeps every frame of a batched turn in order', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-batch' }); const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-batch' });
const updates = tracker.consume([ const updates = tracker.consumeAll([
{ type: 'turn/start', seq: 1, data: { turn: 1 } }, { type: 'turn/start', seq: 1, data: { turn: 1 } },
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-batch' } } }, { type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-batch' } } },
{ type: 'assistant/chunk', seq: 3, data: { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: '先创建再观察:' } } }, { type: 'assistant/chunk', seq: 3, data: { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: '先创建再观察:' } } },

View file

@ -554,20 +554,26 @@ test('QQ remembers any authorized private inbound as a connection-test target',
assert.equal(sent.length, 2); assert.equal(sent.length, 2);
}); });
test('QQ pushes the final answer without streaming intermediate text frames', async () => { test('QQ private messages produce one final stream bubble', async () => {
const sent = []; const sent = [];
const frames = [];
const seen = new Set(); const seen = new Set();
const bridge = new QqHarnessBridge({ const bridge = new QqHarnessBridge({
bot: { bot: {
sendText: async (_target, text) => sent.push(text), sendText: async (_target, text) => sent.push(text),
openStream: () => ({
update: async (text) => frames.push(text),
complete: async () => frames.push('DONE'),
cancel() {},
}),
}, },
ownerUserOpenid: 'owner-openid', ownerUserOpenid: 'owner-openid',
harness: { harness: {
sessionExists: async () => true, sessionExists: async () => true,
createSession: async () => 'session-new', createSession: async () => 'session-new',
ensureRunning: async () => true, ensureRunning: async () => true,
ask: async (_session, _text, { onUpdate }) => { ask: async (_session, _text, { onUpdate, progressMode }) => {
// 正文流式帧不逐帧推送;最终回答与已暂存文本相同时不重复补发。 assert.equal(progressMode, 'all');
await onUpdate({ type: 'text', text: '最终回' }); await onUpdate({ type: 'text', text: '最终回' });
await onUpdate({ type: 'text', text: '最终回答' }); await onUpdate({ type: 'text', text: '最终回答' });
return '最终回答'; return '最终回答';
@ -583,16 +589,52 @@ test('QQ pushes the final answer without streaming intermediate text frames', as
}); });
await bridge.accept(message()); await bridge.accept(message());
assert.deepEqual(sent, ['最终回答']); assert.deepEqual(frames, ['最终回答', 'DONE']);
assert.deepEqual(sent, []);
assert.equal(seen.has('msg-1'), true); assert.equal(seen.has('msg-1'), true);
assert.equal(bridge.status.messagesReplied, 1); assert.equal(bridge.status.messagesReplied, 1);
}); });
test('QQ pushes one notice per tool call and ignores status frames', async () => { test('QQ does not duplicate a visible private answer when stream completion fails', async () => {
const frames = [];
const sent = []; const sent = [];
const bridge = new QqHarnessBridge({ const bridge = new QqHarnessBridge({
bot: { bot: {
sendText: async (_target, text) => sent.push(text), sendText: async (_target, text) => sent.push(text),
openStream: () => ({
update: async (text) => frames.push(text),
complete: async () => { throw new Error('already submitted'); },
cancel() {},
}),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => '最终回答',
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-stream-complete-failure',
setSession: async () => {},
clearSession: async () => {},
},
logger: { warn() {}, error() {} },
});
await bridge.accept(message({ messageId: 'msg-stream-complete-failure' }));
assert.deepEqual(frames, ['最终回答']);
assert.deepEqual(sent, []);
assert.equal(bridge.status.messagesReplied, 1);
});
test('QQ group messages suppress every successful progress update', async () => {
const sent = [];
let streamCalls = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: () => { streamCalls += 1; throw new Error('group stream must not open'); },
}, },
ownerUserOpenid: 'owner-openid', ownerUserOpenid: 'owner-openid',
harness: { harness: {
@ -615,12 +657,18 @@ test('QQ pushes one notice per tool call and ignores status frames', async () =>
}, },
}); });
await bridge.accept(message({ messageId: 'msg-status-frame' })); await bridge.accept(message({
// 工具调用本身不推送,避免多工具任务刷屏。 kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-progress',
messageId: 'msg-status-frame',
replyTarget: { scope: 'group', targetId: 'group-progress', msgId: 'msg-status-frame' },
}));
assert.deepEqual(sent, ['最终回答']); assert.deepEqual(sent, ['最终回答']);
assert.equal(streamCalls, 0);
}); });
test('QQ pushes a failed tool error and any interim explanation text', async () => { test('QQ group messages append tool failures to one final answer', async () => {
const sent = []; const sent = [];
const bridge = new QqHarnessBridge({ const bridge = new QqHarnessBridge({
bot: { bot: {
@ -653,12 +701,15 @@ test('QQ pushes a failed tool error and any interim explanation text', async ()
}, },
}); });
await bridge.accept(message({ messageId: 'msg-tool-error' })); await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-error',
messageId: 'msg-tool-error',
replyTarget: { scope: 'group', targetId: 'group-error', msgId: 'msg-tool-error' },
}));
assert.deepEqual(sent, [ assert.deepEqual(sent, [
'实体不存在,先创建再添加观察:', '已存入两套记忆。\n\n---\n\nTool call add_observations\nError: Error calling add_observations. Status code: 404.',
'Tool call add_observations\nError: Error calling add_observations. Status code: 404.',
'改用创建实体的方式:',
'已存入两套记忆。',
]); ]);
}); });
@ -734,7 +785,7 @@ test('QQ falls back to plain text when the platform rejects markdown', async ()
assert.equal(bridge.status.lastError, null); assert.equal(bridge.status.lastError, null);
}); });
test('QQ announces a stopped turn after any tool notices already pushed', async () => { test('QQ announces a stopped turn and closes its unused private stream', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]); const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]);
const sent = []; const sent = [];
let loggedErrors = 0; let loggedErrors = 0;

View file

@ -65,6 +65,13 @@ test('chunkMarkdownText hard-splits an oversized single line', () => {
assert.deepEqual(chunks, ['x'.repeat(100), 'x'.repeat(100), 'x'.repeat(50)]); assert.deepEqual(chunks, ['x'.repeat(100), 'x'.repeat(100), 'x'.repeat(50)]);
}); });
test('chunkMarkdownText does not split an emoji surrogate pair', () => {
const chunks = chunkMarkdownText(`1234😀5678`, 5);
assert.equal(chunks.join(''), '1234😀5678');
assert.equal(chunks.some((chunk) => chunk.includes('\uFFFD')), false);
for (const chunk of chunks) assert.ok(chunk.length <= 5);
});
test('sendMarkdownReply sends markdown with unique msg_seq per chunk', async () => { test('sendMarkdownReply sends markdown with unique msg_seq per chunk', async () => {
const calls = []; const calls = [];
const results = await sendMarkdownReply({ const results = await sendMarkdownReply({
@ -147,6 +154,44 @@ test('sendMarkdownReply delivers long answers as multiple markdown chunks', asyn
assert.equal(markdownChunks.join('\n'), text); assert.equal(markdownChunks.join('\n'), text);
}); });
test('sendMarkdownReply moves overflow chunks off the passive group reply target', async () => {
const targets = [];
const groupTarget = { scope: 'group', targetId: 'group-1', msgId: 'group-msg' };
await sendMarkdownReply({
send: async ({ target: sentTarget }) => {
targets.push(sentTarget);
return { id: `id-${targets.length}` };
},
sendText: async () => { throw new Error('unexpected'); },
}, groupTarget, 'x'.repeat(4_500 * 6));
assert.equal(targets.length, 6);
assert.equal(targets.slice(0, 4).every((sentTarget) => sentTarget.msgId === 'group-msg'), true);
assert.deepEqual(targets.slice(4), [
{ scope: 'group', targetId: 'group-1' },
{ scope: 'group', targetId: 'group-1' },
]);
});
test('sendMarkdownReply uses the reserved passive reply for a visible partial notice', async () => {
const groupTarget = { scope: 'group', targetId: 'group-1', msgId: 'group-msg' };
const notices = [];
const results = await sendMarkdownReply({
send: async ({ target: sentTarget }) => {
if (!sentTarget.msgId) throw new Error('proactive disabled');
return { id: 'passive' };
},
sendText: async (sentTarget, text) => {
if (!sentTarget.msgId) throw new Error('proactive disabled');
notices.push(text);
return { id: 'partial-notice' };
},
}, groupTarget, 'x'.repeat(4_500 * 6), { logger: { warn() {} } });
assert.equal(results.length, 5);
assert.deepEqual(notices, ['回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。']);
});
test('sendMarkdownReply returns no deliveries for empty text', async () => { test('sendMarkdownReply returns no deliveries for empty text', async () => {
const results = await sendMarkdownReply({ const results = await sendMarkdownReply({
send: async () => { throw new Error('unexpected'); }, send: async () => { throw new Error('unexpected'); },

View file

@ -513,7 +513,7 @@ test('reply tracker associates only the Harness turn created by the Weixin promp
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '微信' } }, data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '微信' } },
} }, } },
]); ]);
assert.deepEqual(first, [{ type: 'text', text: '微信' }]); assert.deepEqual(first, { type: 'text', text: '微信' });
tracker.consume([ tracker.consume([
{ event: { { event: {
seq: 6, seq: 6,