feat(qq): stream the full turn feed as discrete messages

Show what the agent is actually doing during a turn, the way Claude
Code does: interim explanation text, every Tool call, and the error
detail when a tool fails, each as its own pushed message before the
final markdown answer.

- HarnessReplyTracker.consume now returns every frame of a polling
  batch in order (same-batch text frames collapse to the latest)
  instead of dropping all but the last event, which silently ate
  tool-call frames that shared a batch with their result
- tool frames carry callId; status frames carry the correlated
  toolName and a flattened error text parsed from the tool/result
  error payload (message, or name: code)
- the QQ bridge holds interim step text until the next tool call
  (or the end of the turn) pushes it, emits 'Tool call <name>' per
  call, emits 'Tool call <name>\nError: <reason>' for failures, and
  skips re-sending interim text that already equals the final answer

Other channels keep their existing behaviour: the status frame still
reads '正在整理结果…' and they ignore the new optional fields.
This commit is contained in:
jonah.fu 2026-08-23 22:39:29 +08:00
parent 6171ec0dc1
commit e1bc9dd0d0
7 changed files with 325 additions and 190 deletions

View file

@ -595,6 +595,16 @@ export class QqHarnessBridge {
const content = hasImages
? await promptContentForMessage(promptMessage, { signal: this.#signal })
: undefined;
// 过程流:把一次 Turn 的中间过程逐条推送给用户——
// 模型说明文本、每个 Tool call、失败的工具错误详情,最后是完整回答。
let pendingStepText = null;
const pushNotice = async (text) => {
try {
await this.#bot.sendText(target, text);
} catch (error) {
this.#logger.warn?.('[dsh-im:qq] unable to send a turn progress notice:', error);
}
};
let answer;
let artifacts = [];
try {
@ -609,14 +619,24 @@ export class QqHarnessBridge {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
control: { owner: this, key },
// 中间过程逐条推送:每个工具调用发一条独立消息;text 帧(正文流)
// 不逐帧推送,最终答案一次性发送;status 帧忽略。
onUpdate: async (update) => {
if (update.type !== 'tool' || !nonEmptyString(update.name)) return;
try {
await this.#bot.sendText(target, `正在使用${update.name}…`);
} catch (error) {
this.#logger.warn?.('[dsh-im:qq] unable to send a tool progress notice:', error);
if (update.type === 'text') {
// 当前 step 的累积文本:暂存,等下一个工具或结束时一次性推送。
pendingStepText = update.text;
return;
}
if (update.type === 'tool') {
if (nonEmptyString(pendingStepText)) {
await pushNotice(pendingStepText.trim());
}
pendingStepText = null;
await pushNotice(`Tool call ${update.name}`);
return;
}
if (update.error) {
const label = nonEmptyString(update.toolName)
? `Tool call ${update.toolName}` : 'Tool call';
await pushNotice(`${label}\nError: ${update.error}`);
}
},
onInteraction: (interaction) => this.#handleInteraction(interaction, {
@ -635,7 +655,12 @@ export class QqHarnessBridge {
]);
}
this.#signal?.throwIfAborted();
const displayAnswer = answerTextForDelivery(answer, artifacts);
const answerText = answerTextForDelivery(answer, artifacts);
// 结束前仍有一段未推送的说明文本(且不是最终回答本身)时补发。
if (nonEmptyString(pendingStepText) && pendingStepText.trim() !== answerText.trim()) {
await pushNotice(pendingStepText.trim());
}
const displayAnswer = answerText;
let textReceipt = null;
let textSendError = null;
try {

View file

@ -251,6 +251,21 @@ function assistantMessageText(event) {
.trim();
}
function nonEmptyText(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/** Flatten a tool/result error payload into a displayable one-line reason. */
function toolResultErrorText(error) {
if (!error || typeof error !== 'object') return null;
const message = nonEmptyText(error.message);
if (message) return message;
const name = nonEmptyText(error.name);
const code = nonEmptyText(error.code);
if (name || code) return [name ?? 'Error', code].filter(Boolean).join(': ');
return null;
}
function consumeInteractionOwnership(ownership, entries) {
const ordered = [...entries]
.map((entry) => entry?.event ?? entry)
@ -321,6 +336,8 @@ export class HarnessReplyTracker {
#latestText = '';
#finished = false;
#reason = null;
#toolNames = new Map();
#lastToolName = null;
constructor({ promptRpcId, afterSeq = -1 }) {
this.#promptRpcId = promptRpcId;
@ -348,7 +365,19 @@ export class HarnessReplyTracker {
}
consume(entries) {
let update = null;
const updates = [];
// 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
// 让消费方能按顺序看到每个工具调用与结果。
const pushUpdate = (update) => {
if (update.type === 'text' && updates.length > 0) {
const last = updates[updates.length - 1];
if (last.type === 'text') {
updates[updates.length - 1] = update;
return;
}
}
updates.push(update);
};
const ordered = [...entries]
.map((entry) => entry?.event ?? entry)
.filter(Boolean)
@ -390,7 +419,7 @@ export class HarnessReplyTracker {
.trim();
if (text && text !== this.#latestText) {
this.#latestText = text;
update = { type: 'text', text };
pushUpdate({ type: 'text', text });
}
continue;
}
@ -399,18 +428,34 @@ export class HarnessReplyTracker {
const text = assistantMessageText(event);
if (text && text !== this.#latestText) {
this.#latestText = text;
update = { type: 'text', text };
pushUpdate({ type: 'text', text });
}
continue;
}
if (event.type === 'tool/call') {
update = { type: 'tool', name: event.data?.name ?? '工具' };
const name = nonEmptyText(event.data?.name) ?? '工具';
const callId = nonEmptyText(event.data?.callId)
?? nonEmptyText(event.data?.subCallId);
if (callId) this.#toolNames.set(callId, name);
this.#lastToolName = name;
pushUpdate({ type: 'tool', name, ...(callId ? { callId } : {}) });
} else if (event.type === 'tool/result') {
update = { type: 'status', text: '正在整理结果…' };
const callId = nonEmptyText(event.data?.message?.source?.callId)
?? nonEmptyText(event.data?.callId)
?? nonEmptyText(event.data?.subCallId);
const toolName = (callId ? this.#toolNames.get(callId) : null)
?? this.#lastToolName;
const error = toolResultErrorText(event.data?.error);
pushUpdate({
type: 'status',
text: '正在整理结果…',
...(toolName ? { toolName } : {}),
...(error ? { error } : {}),
});
}
}
return update;
return updates;
}
}
@ -1150,12 +1195,14 @@ export class HarnessClient {
this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
if (!wasActive && ownership.active) ownership.reconnect?.();
}
const update = tracker.consume(history.events ?? []);
if (update && onUpdate) {
try {
await onUpdate(update);
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
const updates = tracker.consume(history.events ?? []);
if (onUpdate) {
for (const update of updates) {
try {
await onUpdate(update);
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
}
}
}
if (!tracker.finished) continue;