diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index d5eb3a7c..3a32a716 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -884,30 +884,47 @@ public class ChannelMessageRouter { // and IM channels still need the text for the outgoing reply), // segmentOnly narration excluded (issue #120). AgentStreamAccumulator accumulator = newAccumulator(); + // Narration lifecycle: relayed messages on this path are + // permanent (IM messages cannot be retracted), so per-stage + // narration publishes one behind through the shared tracker — + // a pre-tool rehearsal (possibly a fabricated result table) + // is dropped once later content supersedes it instead of + // reaching the user verbatim. + final ProvisionalContentTracker narrationTracker = + new ProvisionalContentTracker(channelType); agentService.chatStructuredStream(agentId, promptText, conversationId, message.getSenderId(), chatOrigin) .doOnNext(delta -> { accumulator.accept(delta, conversationId); - if (!delta.isEvent() && delta.segmentOnly()) { + if (delta.isEvent()) { + if ("tool_call_completed".equals(delta.eventType())) { + narrationTracker.onToolObservation(); + } + return; + } + if (delta.segmentOnly()) { // Per-stage narration ("Let me look that up…"), emitted as - // one complete delta per agent loop iteration. Relay it - // immediately as its own outgoing message so the user sees - // progress mid-run. + // one complete delta per agent loop iteration, each becoming + // its own outgoing message so the user sees progress mid-run. String narration = delta.content() != null ? delta.content().trim() : ""; if (relayNarration && !narration.isEmpty() && replyTarget != null) { - try { - adapter.renderAndSend(replyTarget, narration); - } catch (Exception sendErr) { - // A failed progress send must not abort the agent - // run — the final reply still goes out below. - log.warn("[{}] Narration relay failed (non-fatal): {}", - channelType, sendErr.getMessage()); + String publishable = narrationTracker.stageNarration(narration, delta.kind()); + if (publishable != null) { + relayNarrationSafely(adapter, replyTarget, publishable); } } } }) .blockLast(Duration.ofMinutes(10)); String reply = accumulator.getContent(); + // The last narration was held back until the answer was + // known: superseded → dropped, otherwise it still goes out + // (before the final reply) unless it duplicates it. + String heldNarration = narrationTracker.settle(!reply.isBlank()); + if (heldNarration != null && replyTarget != null + && !heldNarration.equals(reply.trim())) { + relayNarrationSafely(adapter, replyTarget, heldNarration); + } // The IM sync path bypasses FinalAnswerNode, so hallucinated // /api/v1/files/generated/{id} URLs (LLM wrote a fake link @@ -1290,6 +1307,19 @@ public class ChannelMessageRouter { return s == null || s.isBlank() ? null : s; } + /** + * Send a progress narration as its own outgoing message. A failed send + * must not abort the agent run — the final reply still goes out. + */ + private void relayNarrationSafely(ChannelAdapter adapter, String replyTarget, String narration) { + try { + adapter.renderAndSend(replyTarget, narration); + } catch (Exception sendErr) { + log.warn("[{}] Narration relay failed (non-fatal): {}", + adapter.getChannelType(), sendErr.getMessage()); + } + } + /** * 流式处理路径(渠道无关) *
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java
index 7796a5ea..6fc17d59 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java
@@ -48,38 +48,53 @@ public final class ProvisionalContentTracker {
/** Where the supersede happened — metric tag, one value per surface. */
private final String surface;
+ /** Tool observations completed so far this turn (caller-reported). */
+ private int observations;
+ /** Observation count at the time of the most recent staging. */
+ private int lastStageMark;
+
private String pendingText;
private boolean pendingProvisional;
+ /** Observation count when the pending narration was staged. */
+ private int pendingMark;
public ProvisionalContentTracker(String surface) {
this.surface = surface;
}
+ /** Report a completed tool observation (a {@code tool_call_completed} event). */
+ public void onToolObservation() {
+ observations++;
+ }
+
/**
* Stage a per-round narration. Returns the previous staged
* narration if the new arrival makes it publishable, or {@code null} when
* there is nothing to publish (no previous, or the previous was
- * provisional and is now superseded by this later content).
+ * provisional and tool observations since its staging mean this later
+ * content supersedes it).
*
- * @param kind producer-assigned kind; {@code null} for
- * pre-tag producers
- * @param observedSinceLast structural fallback used only when {@code kind}
- * is null: whether a tool observation completed
- * since the previous narration (the pre-tag
- * online rule)
+ * @param kind producer-assigned kind; {@code null} for pre-tag producers,
+ * in which case a narration counts as provisional when no
+ * observation completed since the previous staging (the
+ * pre-tag online rule)
*/
- public String stageNarration(String text, ContentKind kind, boolean observedSinceLast) {
+ public String stageNarration(String text, ContentKind kind) {
+ boolean observedSinceLast = observations > lastStageMark;
boolean provisional = kind != null
? kind == ContentKind.PRE_TOOL_NARRATION
: !observedSinceLast;
String previous = pendingText;
boolean previousProvisional = pendingProvisional;
+ int previousMark = pendingMark;
pendingText = text;
pendingProvisional = provisional;
+ pendingMark = observations;
+ lastStageMark = observations;
if (previous == null) {
return null;
}
- if (previousProvisional) {
+ if (previousProvisional && observations > previousMark) {
recordSuperseded(previous);
return null;
}
@@ -89,7 +104,8 @@ public final class ProvisionalContentTracker {
/**
* Resolve the staged narration at turn end. Returns the text to publish,
* or {@code null} when nothing remains (no staged narration, or it was
- * provisional and the turn produced final content that replaces it).
+ * provisional, tools ran after it, and the turn produced final content
+ * that replaces it).
*
* @param hasFinalContent whether the turn produced a final answer — with
* one, a provisional narration is superseded; with
@@ -99,12 +115,14 @@ public final class ProvisionalContentTracker {
public String settle(boolean hasFinalContent) {
String text = pendingText;
boolean provisional = pendingProvisional;
+ int mark = pendingMark;
pendingText = null;
pendingProvisional = false;
+ pendingMark = 0;
if (text == null) {
return null;
}
- if (provisional && hasFinalContent) {
+ if (provisional && hasFinalContent && observations > mark) {
recordSuperseded(text);
return null;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
index f2089e8f..028f243c 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
@@ -1532,14 +1532,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
// the final answer always lands in the newest bubble.
AtomicReference Per-iteration narration deltas ({@code segmentOnly}) must be relayed as
- * standalone messages the moment they arrive and stay out of the accumulated
- * final reply — otherwise multiple iterations glue into a wall of text and
- * the persisted assistant message pollutes the next turn's LLM history with
- * unanswered stated intents (issue #120).
+ * Per-iteration narration deltas ({@code segmentOnly}) are relayed as
+ * standalone messages and stay out of the accumulated final reply — otherwise
+ * multiple iterations glue into a wall of text and the persisted assistant
+ * message pollutes the next turn's LLM history with unanswered stated intents
+ * (issue #120). Publishing lags one narration behind through the shared
+ * provisional-content tracker: messages on this path are permanent, so a
+ * pre-tool rehearsal (possibly a fabricated result table) must be dropped
+ * once later content supersedes it instead of reaching the user verbatim.
*/
class ChannelMessageRouterNarrationTest {
@@ -100,6 +103,61 @@ class ChannelMessageRouterNarrationTest {
f.verifyNoProcessingError();
}
+ @Test
+ @DisplayName("kind-tagged pre-tool rehearsal is dropped once a grounded answer exists")
+ void preToolRehearsalDroppedForGroundedAnswer() throws Exception {
+ Fixture f = new Fixture();
+ String rehearsal = "环境监测结果:温度 29.0°C,湿度 63.0%。";
+ f.streamReturns(
+ StreamDelta.segmentOnly(rehearsal, null, vip.mate.agent.ContentKind.PRE_TOOL_NARRATION),
+ StreamDelta.event("tool_call_completed",
+ java.util.Map.of("toolCallId", "t1", "toolName", "envQuery",
+ "result", "data={}", "success", true)),
+ StreamDelta.persistOnly("接口返回为空,所有会议室均无环境数据。", null));
+
+ f.process("各会议室的环境情况");
+
+ verify(f.adapter, never()).renderAndSend("reply-1", rehearsal);
+ verify(f.adapter).renderAndSend("reply-1", "接口返回为空,所有会议室均无环境数据。");
+ f.verifyPersistedAssistantContent("接口返回为空,所有会议室均无环境数据。");
+ f.verifyNoProcessingError();
+ }
+
+ @Test
+ @DisplayName("kind-tagged pre-tool narration still goes out when the turn produced no answer")
+ void preToolNarrationKeptWhenNoAnswer() throws Exception {
+ Fixture f = new Fixture();
+ f.streamReturns(
+ StreamDelta.segmentOnly("我先调用工具查询状态:", null,
+ vip.mate.agent.ContentKind.PRE_TOOL_NARRATION),
+ StreamDelta.event("tool_call_completed",
+ java.util.Map.of("toolCallId", "t1", "toolName", "q",
+ "result", "x", "success", true)));
+
+ f.process("查一下状态");
+
+ verify(f.adapter).renderAndSend("reply-1", "我先调用工具查询状态:");
+ }
+
+ @Test
+ @DisplayName("untagged narration is dropped when a tool ran after it and later content followed")
+ void untaggedNarrationDroppedAfterObservation() throws Exception {
+ Fixture f = new Fixture();
+ f.streamReturns(
+ StreamDelta.segmentOnly("我先查一下天气", null),
+ StreamDelta.event("tool_call_completed",
+ java.util.Map.of("toolCallId", "t1", "toolName", "weather",
+ "result", "晴", "success", true)),
+ StreamDelta.segmentOnly("查到了,整理结果", null),
+ StreamDelta.persistOnly("今天晴。", null));
+
+ f.process("帮我查天气");
+
+ verify(f.adapter, never()).renderAndSend("reply-1", "我先查一下天气");
+ verify(f.adapter).renderAndSend("reply-1", "查到了,整理结果");
+ verify(f.adapter).renderAndSend("reply-1", "今天晴。");
+ }
+
@Test
@DisplayName("persistOnly and plain content deltas still accumulate into one reply")
void nonNarrationDeltasStillAccumulate() throws Exception {
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ProvisionalContentTrackerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ProvisionalContentTrackerTest.java
index 09c14831..7197ce95 100644
--- a/mateclaw-server/src/test/java/vip/mate/channel/ProvisionalContentTrackerTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ProvisionalContentTrackerTest.java
@@ -29,9 +29,10 @@ class ProvisionalContentTrackerTest {
void preToolNarrationSupersededByNextNarration() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- assertNull(t.stageNarration("先查询会议室数据。", ContentKind.PRE_TOOL_NARRATION, false),
+ assertNull(t.stageNarration("先查询会议室数据。", ContentKind.PRE_TOOL_NARRATION),
"first narration has no predecessor to publish");
- String publishable = t.stageNarration("第一间空闲,继续。", ContentKind.GROUNDED_NARRATION, true);
+ t.onToolObservation();
+ String publishable = t.stageNarration("第一间空闲,继续。", ContentKind.GROUNDED_NARRATION);
assertNull(publishable, "provisional predecessor is superseded, not published");
assertEquals("第一间空闲,继续。", t.settle(true),
@@ -43,8 +44,10 @@ class ProvisionalContentTrackerTest {
void groundedNarrationPublishesOnNext() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- t.stageNarration("时间拿到了,再查会议室:", ContentKind.GROUNDED_NARRATION, true);
- String publishable = t.stageNarration("会议室也查完了。", ContentKind.GROUNDED_NARRATION, true);
+ t.onToolObservation();
+ t.stageNarration("时间拿到了,再查会议室:", ContentKind.GROUNDED_NARRATION);
+ t.onToolObservation();
+ String publishable = t.stageNarration("会议室也查完了。", ContentKind.GROUNDED_NARRATION);
assertEquals("时间拿到了,再查会议室:", publishable);
}
@@ -53,7 +56,8 @@ class ProvisionalContentTrackerTest {
void preToolNarrationSupersededByFinalAnswer() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- t.stageNarration("环境监测结果:温度 29.0°C。", ContentKind.PRE_TOOL_NARRATION, false);
+ t.stageNarration("环境监测结果:温度 29.0°C。", ContentKind.PRE_TOOL_NARRATION);
+ t.onToolObservation();
assertNull(t.settle(true), "fabricated rehearsal must not survive once a grounded answer exists");
}
@@ -62,16 +66,28 @@ class ProvisionalContentTrackerTest {
void preToolNarrationCommitsWithoutAnswer() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- t.stageNarration("我先调用工具查询状态:", ContentKind.PRE_TOOL_NARRATION, false);
+ t.stageNarration("我先调用工具查询状态:", ContentKind.PRE_TOOL_NARRATION);
+ t.onToolObservation();
assertEquals("我先调用工具查询状态:", t.settle(false),
"with no replacement content the narration is everything the user gets");
}
+ @Test
+ @DisplayName("pre-tool narration with no tool run after it survives settle — nothing replaced it")
+ void preToolNarrationWithoutToolsAfterSurvives() {
+ ProvisionalContentTracker t = new ProvisionalContentTracker("test");
+
+ t.stageNarration("我先调用工具:", ContentKind.PRE_TOOL_NARRATION);
+ assertEquals("我先调用工具:", t.settle(true),
+ "supersede requires an observation after staging — e.g. all tools denied leaves nothing to defer to");
+ }
+
@Test
@DisplayName("grounded narration always settles publishable")
void groundedNarrationSettles() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- t.stageNarration("查完了,结果如下。", ContentKind.GROUNDED_NARRATION, true);
+ t.onToolObservation();
+ t.stageNarration("查完了,结果如下。", ContentKind.GROUNDED_NARRATION);
assertEquals("查完了,结果如下。", t.settle(true));
}
@@ -79,7 +95,7 @@ class ProvisionalContentTrackerTest {
@DisplayName("settle clears state — second settle finds nothing")
void settleClearsState() {
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- t.stageNarration("x", ContentKind.GROUNDED_NARRATION, true);
+ t.stageNarration("x", ContentKind.GROUNDED_NARRATION);
t.settle(true);
assertNull(t.settle(true));
}
@@ -87,26 +103,40 @@ class ProvisionalContentTrackerTest {
@Test
@DisplayName("null kind falls back to the observation-counter rule")
void nullKindFallsBackToCounter() {
+ // No observation before staging → provisional; a tool ran after → superseded.
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
-
- // No observation since turn start → provisional (pre-tag online rule).
- t.stageNarration("我先查一下:", null, false);
+ t.stageNarration("我先查一下:", null);
+ t.onToolObservation();
assertNull(t.settle(true), "untagged pre-tool narration still dropped for a grounded answer");
- // Observation completed since last narration → grounded.
+ // Observation completed before staging → grounded, publishes.
ProvisionalContentTracker t2 = new ProvisionalContentTracker("test");
- t2.stageNarration("拿到结果了。", null, true);
+ t2.onToolObservation();
+ t2.stageNarration("拿到结果了。", null);
assertEquals("拿到结果了。", t2.settle(true));
}
+ @Test
+ @DisplayName("untagged narrations with no tool activity between them all publish — legacy relay preserved")
+ void untaggedNoToolStreamKeepsRelay() {
+ ProvisionalContentTracker t = new ProvisionalContentTracker("test");
+ t.stageNarration("我先查一下天气", null);
+ assertEquals("我先查一下天气", t.stageNarration("再帮你汇总结果", null),
+ "no observation between the two — the predecessor was not replaced by anything grounded");
+ assertEquals("再帮你汇总结果", t.settle(true));
+ }
+
@Test
@DisplayName("producer kind outranks the counter signal when both are present")
void kindOutranksCounter() {
- // The counter says an observation happened, but the producer knows the
- // text was emitted before any observation of this turn (the two can
- // disagree when multiple narrations land between two completions).
+ // The counter says an observation preceded the narration, but the
+ // producer knows the text was emitted before any observation of this
+ // turn (the two can disagree when multiple narrations land between
+ // two completions). Kind wins.
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
- t.stageNarration("预演内容", ContentKind.PRE_TOOL_NARRATION, true);
+ t.onToolObservation();
+ t.stageNarration("预演内容", ContentKind.PRE_TOOL_NARRATION);
+ t.onToolObservation();
assertNull(t.settle(true), "kind is authoritative — counter signal ignored when tagged");
}