fix(wecom): drop pre-tool rehearsal narrations from permanent bubbles

This commit is contained in:
matevip 2026-08-04 06:14:55 -04:00
parent 77dd866243
commit 54a2c3c0a3
2 changed files with 133 additions and 24 deletions

View File

@ -1416,7 +1416,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
} }
}) })
.blockLast(Duration.ofMinutes(10)); .blockLast(Duration.ofMinutes(10));
outcome = new StreamOutcome(null, false); outcome = new StreamOutcome(null, false, false);
} else { } else {
outcome = consumeWithProgress(stream, message, replyTarget, ctx, contentAccumulator); outcome = consumeWithProgress(stream, message, replyTarget, ctx, contentAccumulator);
} }
@ -1428,9 +1428,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// (routine once the answer is short the model restates it before the // (routine once the answer is short the model restates it before the
// last tool call), publishing both puts the identical bubble on screen // last tool call), publishing both puts the identical bubble on screen
// twice. Same text drop the narration, the final answer covers it. // twice. Same text drop the narration, the final answer covers it.
// A pre-tool narration (no observation behind it, tools ran after it)
// is likewise dropped once a grounded answer exists: whatever it says
// process narration or a predicted result it was written before
// this turn's observations, and IM bubbles cannot be retracted later.
String pendingNarration = outcome.pendingNarration(); String pendingNarration = outcome.pendingNarration();
if (!finalContent.isBlank()) { if (!finalContent.isBlank()) {
if (pendingNarration != null && !sameOutboundText(pendingNarration, finalContent)) { if (pendingNarration != null && !outcome.pendingNarrationPreTool()
&& !sameOutboundText(pendingNarration, finalContent)) {
publishNarrationBubble(replyTarget, pendingNarration); publishNarrationBubble(replyTarget, pendingNarration);
} }
renderAndSend(replyTarget, finalContent); renderAndSend(replyTarget, finalContent);
@ -1454,10 +1459,39 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
* which only {@link #processStream} can act on (it needs the final answer * which only {@link #processStream} can act on (it needs the final answer
* first). * first).
* *
* @param pendingNarration the last per-stage narration, still unpublished * @param pendingNarration the last per-stage narration, still unpublished
* @param approvalPending a tool call is parked on human approval * @param pendingNarrationPreTool the pending narration was written before any
* tool observation and tools ran after it not
* grounded, so it must not become a permanent
* bubble once a grounded answer exists
* @param approvalPending a tool call is parked on human approval
*/ */
private record StreamOutcome(String pendingNarration, boolean approvalPending) {} private record StreamOutcome(String pendingNarration, boolean pendingNarrationPreTool,
boolean approvalPending) {}
/**
* A per-stage narration held back from publication, with the structural
* facts needed to decide later whether it may become a permanent bubble.
*
* @param text the narration text
* @param followsToolResult a tool observation completed between the previous
* narration (or turn start) and this one the text
* was written with real output in hand
* @param toolMark tool-observation count at staging time; a higher
* count later means tools ran after this narration
*/
private record StagedNarration(String text, boolean followsToolResult, int toolMark) {
/**
* Whether this narration is superseded by whatever content follows it:
* it was not grounded in an observation, and tool calls ran after it
* the same structural rule the Web segments timeline applies, evaluated
* online. The caller only asks once follow-up content exists.
*/
boolean supersededAt(int toolResultsNow) {
return !followsToolResult && toolResultsNow > toolMark;
}
}
/** Compare two outbound texts the way the user sees them (post-filter, trimmed). */ /** Compare two outbound texts the way the user sees them (post-filter, trimmed). */
private boolean sameOutboundText(String a, String b) { private boolean sameOutboundText(String a, String b) {
@ -1520,11 +1554,16 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// as the new progress bubble, so chat chronology stays intact and // as the new progress bubble, so chat chronology stays intact and
// the final answer always lands in the newest bubble. // the final answer always lands in the newest bubble.
AtomicReference<WeComReplyContext> liveCtx = new AtomicReference<>(initialCtx); AtomicReference<WeComReplyContext> liveCtx = new AtomicReference<>(initialCtx);
AtomicReference<String> pendingNarration = new AtomicReference<>(); AtomicReference<StagedNarration> pendingNarration = new AtomicReference<>();
AtomicInteger toolResults = new AtomicInteger();
final int[] lastNarrationToolMark = {0};
final long[] lastFlushAt = {0L}; final long[] lastFlushAt = {0L};
stream.doOnNext(delta -> { stream.doOnNext(delta -> {
boolean flushNow = false; boolean flushNow = false;
if (delta.isEvent()) { if (delta.isEvent()) {
if ("tool_call_completed".equals(delta.eventType())) {
toolResults.incrementAndGet();
}
flushNow = progress.onEvent(delta.eventType(), delta.eventData()); flushNow = progress.onEvent(delta.eventType(), delta.eventData());
if (standaloneToolMessages) { if (standaloneToolMessages) {
maybeSendToolEventMessage(replyTarget, delta.eventType(), delta.eventData()); maybeSendToolEventMessage(replyTarget, delta.eventType(), delta.eventData());
@ -1537,22 +1576,32 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// LLM history with unanswered chain-of-thought. // LLM history with unanswered chain-of-thought.
// //
// Publishing lags one narration behind: the newest one is only // Publishing lags one narration behind: the newest one is only
// staged (visible live in the bubble, not yet finalized) so // staged (visible live in the bubble, not yet finalized) so a
// processStream can still drop it if the final answer turns out // later decision can still drop it when the final answer
// to be the same text. Without the lag the user reads the same // turns out to be the same text, or when it proves to be a
// paragraph in two adjacent bubbles. // pre-tool rehearsal. A narration written before any tool
// observation, with tool calls running after it, is not
// grounded in this turn's results (it may even be a predicted
// result table); once grounded content follows, it is dropped
// instead of becoming an unretractable permanent bubble.
// Narrations written after an observation keep standalone
// value (e.g. intermediate results) and publish as before.
String narration = delta.content() != null ? delta.content().trim() : ""; String narration = delta.content() != null ? delta.content().trim() : "";
if (!narration.isEmpty()) { if (!narration.isEmpty()) {
String previous = pendingNarration.getAndSet(narration); int mark = toolResults.get();
StagedNarration staged = new StagedNarration(
narration, mark > lastNarrationToolMark[0], mark);
lastNarrationToolMark[0] = mark;
StagedNarration previous = pendingNarration.getAndSet(staged);
progress.onNarration(narration); progress.onNarration(narration);
if (previous != null) { if (previous != null && !previous.supersededAt(mark)) {
WeComReplyContext ctx = liveCtx.get(); WeComReplyContext ctx = liveCtx.get();
if (replyContexts.get(replyTarget) == ctx) { if (replyContexts.get(replyTarget) == ctx) {
liveCtx.set(rollProgressBubble(replyTarget, ctx, previous, progress)); liveCtx.set(rollProgressBubble(replyTarget, ctx, previous.text(), progress));
} else { } else {
// Bubble already force-finished (180s ceiling) the // Bubble already force-finished (180s ceiling) the
// narration still goes out as a plain message. // narration still goes out as a plain message.
sendMessage(replyTarget, previous); sendMessage(replyTarget, previous.text());
} }
} }
flushNow = true; flushNow = true;
@ -1589,7 +1638,11 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
} }
}).blockLast(Duration.ofMinutes(10)); }).blockLast(Duration.ofMinutes(10));
return new StreamOutcome(pendingNarration.get(), progress.isApprovalPending()); StagedNarration last = pendingNarration.get();
return new StreamOutcome(
last != null ? last.text() : null,
last != null && last.supersededAt(toolResults.get()),
progress.isApprovalPending());
} }
/** /**

View File

@ -154,7 +154,7 @@ class WeComProcessStreamTest {
} }
@Test @Test
@DisplayName("stage narrations roll the bubble: each stage finishes its own bubble, final answer excludes them") @DisplayName("post-tool narrations roll the bubble; the pre-tool opener is dropped, final answer excludes both")
void stageNarrationsRollBubbles() throws Exception { void stageNarrationsRollBubbles() throws Exception {
TestableAdapter adapter = newAdapter("{\"progress_interval_ms\": 0}"); TestableAdapter adapter = newAdapter("{\"progress_interval_ms\": 0}");
seedReplyContext(adapter, "alice", "req-1", "stream-1"); seedReplyContext(adapter, "alice", "req-1", "stream-1");
@ -176,18 +176,74 @@ class WeComProcessStreamTest {
List<Map<String, Object>> streamBodies = streamBodies(adapter.drainFrames()); List<Map<String, Object>> streamBodies = streamBodies(adapter.drainFrames());
List<Map<String, Object>> finished = streamBodies.stream() List<Map<String, Object>> finished = streamBodies.stream()
.filter(s -> Boolean.TRUE.equals(s.get("finish"))).toList(); .filter(s -> Boolean.TRUE.equals(s.get("finish"))).toList();
// Three finished bubbles in chronological order: narration #1, // Two finished bubbles: the observation-grounded narration #2 and the
// narration #2, final answer each on its own stream id. // final answer. Narration #1 ran before any tool observation and tools
assertEquals(3, finished.size(), "each stage plus the final answer closes one bubble"); // ran after it a pre-tool rehearsal never becomes a permanent bubble
assertTrue(String.valueOf(finished.get(0).get("content")).contains("我先查一下当前时间")); // (it stays visible only transiently in the live progress snapshot).
assertTrue(String.valueOf(finished.get(1).get("content")).contains("再查会议室")); assertEquals(2, finished.size(),
assertTrue(String.valueOf(finished.get(2).get("content")).contains("已预约")); "grounded narration + final answer close one bubble each; the rehearsal closes none");
assertEquals(3, finished.stream().map(s -> s.get("id")).distinct().count(), assertTrue(finished.stream().noneMatch(
s -> String.valueOf(s.get("content")).contains("我先查一下当前时间")),
"the pre-tool rehearsal must not finalize a bubble of its own");
assertTrue(String.valueOf(finished.get(0).get("content")).contains("再查会议室"));
assertTrue(String.valueOf(finished.get(1).get("content")).contains("已预约"));
assertEquals(2, finished.stream().map(s -> s.get("id")).distinct().count(),
"each finished bubble must ride its own stream id"); "each finished bubble must ride its own stream id");
// The first narration finalizes the original placeholder stream. // The first published narration finalizes the original placeholder stream.
assertEquals("stream-1", finished.get(0).get("id")); assertEquals("stream-1", finished.get(0).get("id"));
} }
@Test
@DisplayName("a pre-tool rehearsal pending at stream end is dropped once a grounded answer exists")
void preToolRehearsalDroppedForGroundedAnswer() throws Exception {
TestableAdapter adapter = newAdapter("{\"progress_interval_ms\": 0}");
seedReplyContext(adapter, "alice", "req-1", "stream-1");
// The model writes a full predicted result (fabricated numbers) before
// its first tool call; the real observation then produces the answer.
Flux<StreamDelta> stream = Flux.just(
StreamDelta.segmentOnly("环境监测结果:温度 29.0°C湿度 63.0%。需要进一步操作吗?", null),
StreamDelta.event("tool_call_started",
Map.of("toolCallId", "c1", "toolName", "query_env")),
StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "c1", "toolName", "query_env", "success", true)),
new StreamDelta("接口返回为空,所有会议室均无环境数据。", null));
String result = adapter.processStream(stream, inbound("alice"), "wecom:alice");
assertEquals("接口返回为空,所有会议室均无环境数据。", result);
List<Map<String, Object>> finished = streamBodies(adapter.drainFrames()).stream()
.filter(s -> Boolean.TRUE.equals(s.get("finish"))).toList();
assertEquals(1, finished.size(), "only the grounded answer may close a bubble");
String content = String.valueOf(finished.get(0).get("content"));
assertTrue(content.contains("接口返回为空"));
assertFalse(content.contains("29.0"), "the fabricated rehearsal must never reach the user: " + content);
}
@Test
@DisplayName("a pre-tool narration is still published when the turn produced no answer at all")
void preToolNarrationKeptWhenNoAnswer() throws Exception {
TestableAdapter adapter = newAdapter("{\"progress_interval_ms\": 0}");
seedReplyContext(adapter, "alice", "req-1", "stream-1");
// No content after the tool ran the narration is everything the user
// gets (approval park / stop / empty answer). With no replacement
// content it is not superseded, so it must still close the bubble.
Flux<StreamDelta> stream = Flux.just(
StreamDelta.segmentOnly("我先调用工具查询状态:", null),
StreamDelta.event("tool_call_started",
Map.of("toolCallId", "c1", "toolName", "query_env")),
StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "c1", "toolName", "query_env", "success", true)));
assertEquals("", adapter.processStream(stream, inbound("alice"), "wecom:alice"));
List<Map<String, Object>> finished = streamBodies(adapter.drainFrames()).stream()
.filter(s -> Boolean.TRUE.equals(s.get("finish"))).toList();
assertEquals(1, finished.size(), "the narration must close the bubble when nothing else can");
assertTrue(String.valueOf(finished.get(0).get("content")).contains("我先调用工具查询状态"));
}
@Test @Test
@DisplayName("stream_progress=false degrades to accumulate-then-send with no interim overwrites") @DisplayName("stream_progress=false degrades to accumulate-then-send with no interim overwrites")
void progressDisabledDegrades() throws Exception { void progressDisabledDegrades() throws Exception {