Skip to content

Commit 282c206

Browse files
JohanYPclaude
andcommitted
fix(aggregator): strip reasoning content from v1.15 idle flush
OpenCode v1.15 emits message.part.delta events with field=text and no type discriminator for both text AND reasoning parts. The corresponding message.part.updated events (which would distinguish them) don't fire in realtime for short streams, so the bot was treating reasoning deltas as text and leaking model thinking into the user-facing chat. Fix: at session.idle, query the server for the message's parts to learn which partIDs are reasoning, then strip them from the rendered final text before invoking onCompleteCallback. Also chain the onSessionIdleCallback off the now-async flush so TTS still gets the accumulated text in waitForIdle mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6da3eb2 commit 282c206

2 files changed

Lines changed: 159 additions & 14 deletions

File tree

src/bot/event-subscription.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { t } from "../i18n/index.js";
1818
import { interactionManager as _interactionManager } from "../interaction/manager.js";
1919
import { clearAllInteractionState } from "../interaction/cleanup.js";
2020
import { keyboardManager } from "../keyboard/manager.js";
21+
import { opencodeClient } from "../opencode/client.js";
2122
import { subscribeToEvents } from "../opencode/events.js";
2223
import { pinnedMessageManager } from "../pinned/manager.js";
2324
import { questionManager } from "../question/manager.js";
@@ -94,6 +95,29 @@ export function createEventSubscriber(
9495
}
9596

9697
summaryAggregator.setTypingIndicatorEnabled(true);
98+
99+
// Lookup que el aggregator usa en session.idle para preguntarle al server
100+
// qué partIDs son reasoning. v1.15 emite los deltas sin discriminador de
101+
// tipo (todos con field=text), así que esta es la única forma fiable de
102+
// separar reasoning de texto real antes de renderizar al chat.
103+
summaryAggregator.setMessagePartTypeLookup(async (sessionID, messageID) => {
104+
const types = new Map<string, string>();
105+
try {
106+
const { data } = await opencodeClient.session.message({ sessionID, messageID });
107+
const parts = (data as { parts?: Array<{ id: string; type: string }> })?.parts;
108+
if (Array.isArray(parts)) {
109+
for (const p of parts) {
110+
if (p?.id && p?.type) {
111+
types.set(p.id, p.type);
112+
}
113+
}
114+
}
115+
} catch (err) {
116+
logger.warn(`[Bot] messagePartTypeLookup failed for ${messageID}:`, err);
117+
}
118+
return types;
119+
});
120+
97121
summaryAggregator.setOnCleared(() => {
98122
toolMessageBatcher.clearAll("summary_aggregator_clear");
99123
toolCallStreamer.clearAll("summary_aggregator_clear");

src/summary/aggregator.ts

Lines changed: 135 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,22 @@ class SummaryAggregator {
165165
private onSessionDiffCallback: SessionDiffCallback | null = null;
166166
private onFileChangeCallback: FileChangeCallback | null = null;
167167
private onClearedCallback: ClearedCallback | null = null;
168+
// Optional async lookup to ask the opencode server "which partIDs in this
169+
// message are type=reasoning?". Used at session.idle to strip reasoning
170+
// text from the rendered final message — see comment in handleSessionIdle.
171+
private messagePartTypeLookup:
172+
| ((sessionID: string, messageID: string) => Promise<Map<string, string>>)
173+
| null = null;
168174
private processedToolStates: Set<string> = new Set();
169175
private thinkingFiredForMessages: Set<string> = new Set();
170176
private deliveredExternalUserMessageIds: Set<string> = new Set();
171177
private knownTextPartIds: Map<string, Set<string>> = new Map();
178+
// Parts confirmed as reasoning by message.part.updated. Deltas for these
179+
// partIDs are dropped (they're "thinking" content, not user-facing).
180+
// Required because v1.15 emits deltas before the type-discriminator
181+
// updated event for short reasoning streams — we tentatively apply as
182+
// text, then rectify here once the updated arrives.
183+
private knownReasoningPartIds: Map<string, Set<string>> = new Map();
172184
private bot: Bot | null = null;
173185
private chatId: number | null = null;
174186
private typingTimer: ReturnType<typeof setInterval> | null = null;
@@ -191,6 +203,12 @@ class SummaryAggregator {
191203
this.onCompleteCallback = callback;
192204
}
193205

206+
setMessagePartTypeLookup(
207+
fn: (sessionID: string, messageID: string) => Promise<Map<string, string>>,
208+
): void {
209+
this.messagePartTypeLookup = fn;
210+
}
211+
194212
setOnPartial(callback: MessagePartialCallback): void {
195213
this.onPartialCallback = callback;
196214
}
@@ -385,6 +403,7 @@ class SummaryAggregator {
385403
this.messages.clear();
386404
this.partHashes.clear();
387405
this.knownTextPartIds.clear();
406+
this.knownReasoningPartIds.clear();
388407
this.processedToolStates.clear();
389408
this.thinkingFiredForMessages.clear();
390409
this.deliveredExternalUserMessageIds.clear();
@@ -651,6 +670,13 @@ class SummaryAggregator {
651670
}
652671

653672
if (part.type === "reasoning") {
673+
// Confirm this partID is reasoning so future deltas for it get dropped.
674+
this.registerKnownReasoningPart(messageID, part.id);
675+
// Rectify: if deltas arrived before this update and were optimistically
676+
// applied as text, remove them now. Without this, short reasoning leaks
677+
// into the user-facing chat (issue with qwen, deepseek, sonnet w/o
678+
// extended thinking).
679+
this.unapplyMistakenlyTextPart(part.sessionID, messageID, part.id);
654680
// Fire the thinking callback once per message on the first reasoning part.
655681
// This is the signal that the model is actually doing extended thinking.
656682
if (!this.thinkingFiredForMessages.has(messageID) && this.onThinkingCallback) {
@@ -797,6 +823,15 @@ class SummaryAggregator {
797823
// `knownTextPartIds`, which `handleMessagePartUpdated` populates from
798824
// `message.part.updated.properties.part.type === "text"`. That is the
799825
// real Part-type discriminator and fires before deltas for the same part.
826+
// EDGE CASE: with short reasoning streams (qwen, deepseek, Claude Sonnet
827+
// in non-thinking mode), the deltas can arrive before the
828+
// `message.part.updated` that says `type=reasoning`. We optimistically
829+
// apply them as text below, then `handleMessagePartUpdated` rectifies
830+
// by removing the wrongly-accumulated text when it confirms reasoning.
831+
const knownReasoningIds = this.knownReasoningPartIds.get(messageID);
832+
if (knownReasoningIds?.has(partID)) {
833+
return; // already confirmed as reasoning — drop
834+
}
800835
const knownTextIds = this.knownTextPartIds.get(messageID);
801836
const isKnownTextPart = knownTextIds?.has(partID) ?? false;
802837
const thinkingFired = this.thinkingFiredForMessages.has(messageID);
@@ -886,6 +921,7 @@ class SummaryAggregator {
886921
this.messages.delete(messageId);
887922
this.partHashes.delete(messageId);
888923
this.knownTextPartIds.delete(messageId);
924+
this.knownReasoningPartIds.delete(messageId);
889925

890926
if (this.textMessageStates.size === 0) {
891927
logger.debug("[Aggregator] No more active messages, stopping typing indicator");
@@ -928,6 +964,48 @@ class SummaryAggregator {
928964
this.knownTextPartIds.get(messageID)!.add(partID);
929965
}
930966

967+
private registerKnownReasoningPart(messageID: string, partID: string): void {
968+
if (!this.knownReasoningPartIds.has(messageID)) {
969+
this.knownReasoningPartIds.set(messageID, new Set());
970+
}
971+
972+
this.knownReasoningPartIds.get(messageID)!.add(partID);
973+
}
974+
975+
/**
976+
* Undo the optimistic "applied as text" of a partID we now know was
977+
* reasoning. Strips it from textMessageStates and re-emits the corrected
978+
* combined text. Idempotent — safe to call when the part was never
979+
* applied as text (early return on no-op).
980+
*/
981+
private unapplyMistakenlyTextPart(
982+
sessionID: string,
983+
messageID: string,
984+
partID: string,
985+
): void {
986+
const textIds = this.knownTextPartIds.get(messageID);
987+
const wasTreatedAsText = textIds?.has(partID) ?? false;
988+
if (wasTreatedAsText) {
989+
textIds!.delete(partID);
990+
}
991+
992+
const state = this.textMessageStates.get(messageID);
993+
if (!state) return;
994+
995+
const hadPart = state.partTexts.has(partID) || state.orderedPartIds.includes(partID);
996+
if (!hadPart) return;
997+
998+
state.partTexts.delete(partID);
999+
state.orderedPartIds = state.orderedPartIds.filter((id) => id !== partID);
1000+
1001+
// Re-emit corrected partial. If combined is now empty, the streamer
1002+
// gates on `messageText.trim()` and skips the edit — no harm done.
1003+
const messageInfo = this.messages.get(messageID);
1004+
if (messageInfo?.role !== "assistant") return;
1005+
const combined = this.getCombinedMessageText(messageID);
1006+
this.emitPartialText(sessionID, messageID, combined);
1007+
}
1008+
9311009
private registerTextPart(messageID: string, partID: string): void {
9321010
const state = this.getOrCreateTextMessageState(messageID);
9331011
if (!state.orderedPartIds.includes(partID)) {
@@ -1161,23 +1239,66 @@ class SummaryAggregator {
11611239
// este flush, `onCompleteCallback` no se dispararía nunca y todo lo que
11621240
// dependa de él (TTS, footer con modelo/agent, run-state cleanup) queda
11631241
// huérfano. Drenamos cualquier mensaje acumulado en textMessageStates aquí.
1242+
//
1243+
// ANTES de drenar, si tenemos un lookup configurado, le preguntamos al
1244+
// server qué partes son `reasoning` y las stripamos. El SDK NO emite
1245+
// `message.part.updated` con type=reasoning en tiempo real (al menos para
1246+
// razonamientos cortos), así que el aggregator no puede discriminarlas
1247+
// por sí solo — todos los deltas llegan con `field=text` y `type=?`. El
1248+
// server SÍ las tiene categorizadas en su storage.
11641249
if (this.onCompleteCallback && this.textMessageStates.size > 0) {
1165-
for (const messageID of Array.from(this.textMessageStates.keys())) {
1166-
const finalText = this.getCombinedMessageText(messageID);
1167-
if (!finalText.trim()) {
1250+
const messagesToFlush = Array.from(this.textMessageStates.keys());
1251+
const callback = this.onCompleteCallback;
1252+
const lookup = this.messagePartTypeLookup;
1253+
const flushImpl = async () => {
1254+
for (const messageID of messagesToFlush) {
1255+
if (lookup) {
1256+
try {
1257+
const partTypes = await lookup(sessionID, messageID);
1258+
for (const [partID, type] of partTypes) {
1259+
if (type === "reasoning") {
1260+
this.unapplyMistakenlyTextPart(sessionID, messageID, partID);
1261+
}
1262+
}
1263+
} catch (err) {
1264+
logger.warn(
1265+
`[Aggregator] Reasoning strip lookup failed for ${messageID}; will deliver as-is:`,
1266+
err,
1267+
);
1268+
}
1269+
}
1270+
const finalText = this.getCombinedMessageText(messageID);
1271+
if (!finalText.trim()) {
1272+
this.cleanupCompletedMessage(messageID);
1273+
continue;
1274+
}
1275+
logger.debug(
1276+
`[Aggregator] Flushing pending assistant message on session.idle: messageId=${messageID}, textLength=${finalText.length}`,
1277+
);
1278+
try {
1279+
callback(sessionID, messageID, finalText, {});
1280+
} catch (err) {
1281+
logger.error("[Aggregator] Error in onComplete during idle flush:", err);
1282+
}
11681283
this.cleanupCompletedMessage(messageID);
1169-
continue;
11701284
}
1171-
logger.debug(
1172-
`[Aggregator] Flushing pending assistant message on session.idle: messageId=${messageID}, textLength=${finalText.length}`,
1173-
);
1174-
try {
1175-
this.onCompleteCallback(sessionID, messageID, finalText, {});
1176-
} catch (err) {
1177-
logger.error("[Aggregator] Error in onComplete during idle flush:", err);
1178-
}
1179-
this.cleanupCompletedMessage(messageID);
1180-
}
1285+
};
1286+
// El idle callback DEBE dispararse DESPUÉS de que el flush termine,
1287+
// porque el handler de TTS depende del texto acumulado durante
1288+
// onCompleteCallback (waitForIdle mode). Si fuera al revés, el idle
1289+
// callback corre flushTtsText antes de que onComplete acumule —
1290+
// resultado: no se envía audio.
1291+
const idleCb = this.onSessionIdleCallback;
1292+
flushImpl()
1293+
.catch((err) => {
1294+
logger.error("[Aggregator] Idle flush failed:", err);
1295+
})
1296+
.finally(() => {
1297+
if (idleCb) {
1298+
setImmediate(() => idleCb(sessionID));
1299+
}
1300+
});
1301+
return;
11811302
}
11821303

11831304
if (this.onSessionIdleCallback) {

0 commit comments

Comments
 (0)