mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
fix(wecom): drop pre-tool rehearsal narrations from permanent bubbles
This commit is contained in:
parent
77dd866243
commit
54a2c3c0a3
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user