mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
fix(qq): prevent turn feed message spam
This commit is contained in:
parent
6ea048b277
commit
ade50210f0
10 changed files with 382 additions and 213 deletions
|
|
@ -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: '钉钉' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(update, [{ type: 'text', text: '钉钉' }]);
|
||||
assert.deepEqual(update, { type: 'text', text: '钉钉' });
|
||||
tracker.consume([
|
||||
{ event: {
|
||||
seq: 6,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
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: '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: '忽略' } },
|
||||
} },
|
||||
{ event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } },
|
||||
]), []);
|
||||
]), null);
|
||||
assert.equal(tracker.finished, false);
|
||||
|
||||
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: '深圳' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(first, [{ type: 'text', text: '深圳' }]);
|
||||
assert.deepEqual(first, { type: 'text', text: '深圳' });
|
||||
|
||||
const second = tracker.consume([
|
||||
{ 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: '明天有雨' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(second, [{ type: 'text', text: '深圳明天有雨' }]);
|
||||
assert.deepEqual(second, { type: 'text', text: '深圳明天有雨' });
|
||||
|
||||
const final = tracker.consume([
|
||||
{ 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' } } } },
|
||||
]);
|
||||
assert.deepEqual(final, [{ type: 'text', text: '深圳明天有阵雨。' }]);
|
||||
assert.deepEqual(final, { type: 'text', text: '深圳明天有阵雨。' });
|
||||
assert.equal(tracker.finished, true);
|
||||
assert.equal(tracker.answer, '深圳明天有阵雨。');
|
||||
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: '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([
|
||||
{ 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', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-batch' });
|
||||
const updates = tracker.consume([
|
||||
const updates = tracker.consumeAll([
|
||||
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
|
||||
{ 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: '先创建再观察:' } } },
|
||||
|
|
|
|||
|
|
@ -554,20 +554,26 @@ test('QQ remembers any authorized private inbound as a connection-test target',
|
|||
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 frames = [];
|
||||
const seen = new Set();
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
openStream: () => ({
|
||||
update: async (text) => frames.push(text),
|
||||
complete: async () => frames.push('DONE'),
|
||||
cancel() {},
|
||||
}),
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-new',
|
||||
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: '最终回答' });
|
||||
return '最终回答';
|
||||
|
|
@ -583,16 +589,52 @@ test('QQ pushes the final answer without streaming intermediate text frames', as
|
|||
});
|
||||
|
||||
await bridge.accept(message());
|
||||
assert.deepEqual(sent, ['最终回答']);
|
||||
assert.deepEqual(frames, ['最终回答', 'DONE']);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(seen.has('msg-1'), true);
|
||||
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 bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
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',
|
||||
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.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 bridge = new QqHarnessBridge({
|
||||
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, [
|
||||
'实体不存在,先创建再添加观察:',
|
||||
'Tool call add_observations\nError: Error calling add_observations. Status code: 404.',
|
||||
'改用创建实体的方式:',
|
||||
'已存入两套记忆。',
|
||||
'已存入两套记忆。\n\n---\n\nTool 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);
|
||||
});
|
||||
|
||||
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 sent = [];
|
||||
let loggedErrors = 0;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ test('chunkMarkdownText hard-splits an oversized single line', () => {
|
|||
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 () => {
|
||||
const calls = [];
|
||||
const results = await sendMarkdownReply({
|
||||
|
|
@ -147,6 +154,44 @@ test('sendMarkdownReply delivers long answers as multiple markdown chunks', asyn
|
|||
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 () => {
|
||||
const results = await sendMarkdownReply({
|
||||
send: async () => { throw new Error('unexpected'); },
|
||||
|
|
|
|||
|
|
@ -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: '微信' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(first, [{ type: 'text', text: '微信' }]);
|
||||
assert.deepEqual(first, { type: 'text', text: '微信' });
|
||||
tracker.consume([
|
||||
{ event: {
|
||||
seq: 6,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue