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 5dbb483e..6d6ff3b8 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 @@ -2,9 +2,12 @@ package vip.mate.channel.wecom; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService.StreamDelta; import vip.mate.channel.AbstractChannelAdapter; import vip.mate.channel.ChannelMessage; import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.StreamingChannelAdapter; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.channel.ExponentialBackoff; import vip.mate.channel.media.InboundMediaDownloader; @@ -55,12 +58,17 @@ import java.util.concurrent.atomic.AtomicInteger; *
  • welcome_text: 欢迎消息(可选)
  • *
  • media_download_enabled: 是否下载媒体文件(默认 true)
  • *
  • media_dir: 媒体文件保存目录(默认 data/media)
  • + *
  • stream_progress: 处理期间是否在气泡内展示实时进度(默认 true; + * false 退化为"累积后一次性发送")
  • + *
  • progress_interval_ms: 进度覆写最小间隔(默认 500ms)
  • + *
  • filter_thinking: false 时思考内容流式进入进度气泡(默认 true)
  • + *
  • filter_tool_messages: false 时每次工具调用发独立留痕消息(默认 true)
  • * * * @author MateClaw Team */ @Slf4j -public class WeComChannelAdapter extends AbstractChannelAdapter { +public class WeComChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter { public static final String CHANNEL_TYPE = "wecom"; @@ -1345,6 +1353,162 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } } + // ==================== StreamingChannelAdapter ==================== + + /** Minimum interval between progress overwrites of the stream bubble. */ + private static final long PROGRESS_MIN_INTERVAL_MS = 500; + + /** Max chars of a tool-argument summary in a standalone tool message. */ + private static final int TOOL_ARGS_SUMMARY_MAX = 120; + + /** + * 流式处理 Agent 事件并渲染到企业微信 + *

    + * 渲染策略:复用入站时发出的 "🤔 思考中..." reply_stream 气泡,随 + * agent 事件(思考、工具调用、计划步骤、内容产出)持续覆写为实时进度, + * 流结束后原地渐变为最终答案(走 renderAndSend 的分段逻辑)。 + *

    + */ + @Override + public String processStream(Flux stream, ChannelMessage message, String conversationId) { + String replyTarget = message.getReplyToken() != null ? message.getReplyToken() + : (message.getChatId() != null ? message.getChatId() : message.getSenderId()); + WeComReplyContext ctx = replyContexts.get(replyTarget); + boolean progressEnabled = getConfigBoolean("stream_progress", true); + boolean bubbleUsable = ctx != null && ctx.processingStreamId() != null + && !ctx.processingStreamId().isBlank() + && ctx.frameReqId() != null && !ctx.frameReqId().isBlank(); + + StringBuilder contentAccumulator = new StringBuilder(); + if (!progressEnabled || !bubbleUsable) { + // Degraded path — identical to the pre-streaming sync behavior: + // accumulate content only (persistOnly deltas included, matching + // the router's legacy collector) and send once at the end. + stream.doOnNext(delta -> { + if (!delta.isEvent() && delta.content() != null) { + contentAccumulator.append(delta.content()); + } + }) + .blockLast(Duration.ofMinutes(10)); + } else { + consumeWithProgress(stream, message, replyTarget, ctx, contentAccumulator); + } + + String finalContent = contentAccumulator.toString(); + if (!finalContent.isBlank()) { + renderAndSend(replyTarget, finalContent); + } + return finalContent; + } + + /** Event-driven progress rendering into the existing processing-stream bubble. */ + private void consumeWithProgress(Flux stream, ChannelMessage message, + String replyTarget, WeComReplyContext ctx, + StringBuilder contentAccumulator) { + boolean showThinking = !getConfigBoolean("filter_thinking", true); + boolean standaloneToolMessages = !getConfigBoolean("filter_tool_messages", true); + long minIntervalMs = getConfigLong("progress_interval_ms", PROGRESS_MIN_INTERVAL_MS); + + WeComProgressRenderer progress = new WeComProgressRenderer( + System.currentTimeMillis(), showThinking); + if (keepaliveScheduler != null) { + // Silent stretches (long LLM calls with no events) keep showing a + // fresh elapsed-time snapshot instead of the static placeholder. + keepaliveScheduler.attachTextSupplier(ctx.processingStreamId(), progress::snapshot); + } + + final long[] lastFlushAt = {0L}; + stream.doOnNext(delta -> { + boolean flushNow = false; + if (delta.isEvent()) { + flushNow = progress.onEvent(delta.eventType(), delta.eventData()); + if (standaloneToolMessages) { + maybeSendToolEventMessage(replyTarget, delta.eventType(), delta.eventData()); + } + } else { + if (delta.thinking() != null) { + progress.onThinkingDelta(delta.thinking()); + } + if (delta.content() != null) { + contentAccumulator.append(delta.content()); + progress.onContentDelta(delta.content()); + } + } + long now = System.currentTimeMillis(); + if (!flushNow && now - lastFlushAt[0] < minIntervalMs) { + return; + } + // The keepalive force-finish (180s ceiling) evicts the reply + // context; once that happens the stream slot is closed and + // further overwrites would be silently rejected — stop pushing. + if (replyContexts.get(replyTarget) != ctx) { + return; + } + lastFlushAt[0] = now; + try { + replyStream(ctx.frameReqId(), ctx.processingStreamId(), progress.snapshot(), false); + } catch (Exception e) { + log.debug("[wecom] progress overwrite failed: {}", e.getMessage()); + } + }).blockLast(Duration.ofMinutes(10)); + } + + /** + * Standalone tool-call trace messages, sent only when the channel's + * {@code filter_tool_messages} toggle is off: the user opted into seeing + * the tool trail as persistent bubbles (the progress bubble alone is + * transient — the final answer overwrites it). + */ + private void maybeSendToolEventMessage(String replyTarget, String eventType, + Map data) { + if (data == null || eventType == null) { + return; + } + Object toolName = data.get("toolName"); + if (toolName == null) { + return; + } + try { + switch (eventType) { + case "tool_call_started" -> { + String args = summarizeToolArgs(data.get("arguments")); + sendMessage(replyTarget, "🔧 调用工具 `" + toolName + "`" + + (args.isEmpty() ? "" : "\n> " + args)); + } + case "tool_call_completed" -> { + boolean success = !Boolean.FALSE.equals(data.get("success")); + sendMessage(replyTarget, (success ? "✅ `" : "❌ `") + toolName + + (success ? "` 完成" : "` 失败")); + } + default -> { + // Other events carry no standalone tool trace. + } + } + } catch (Exception e) { + log.debug("[wecom] tool trace message failed: {}", e.getMessage()); + } + } + + private static String summarizeToolArgs(Object arguments) { + if (arguments == null) { + return ""; + } + String text = arguments.toString().replaceAll("\\s+", " ").trim(); + if (text.isEmpty() || "{}".equals(text)) { + return ""; + } + return text.length() > TOOL_ARGS_SUMMARY_MAX + ? text.substring(0, TOOL_ARGS_SUMMARY_MAX) + "…" + : text; + } + @Override public void sendMessage(String targetId, String content) { if (webSocket == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java index b53e6db6..600b7c65 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java @@ -9,6 +9,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; /** * Periodically refreshes a WeCom AI Bot {@code stream} reply with the @@ -58,6 +59,10 @@ public class WeComKeepaliveScheduler { final String replyToken; final long startedAt; volatile ScheduledFuture future; + /** Optional live progress text source; when set, refresh ticks write + * its current snapshot instead of the static placeholder so the + * bubble keeps showing elapsed time / tool state between events. */ + volatile Supplier textSupplier; StreamState(WeComChannelAdapter a, String r, String s, String t) { this.adapter = a; this.reqId = r; this.streamId = s; this.replyToken = t; this.startedAt = System.currentTimeMillis(); @@ -105,6 +110,22 @@ public class WeComKeepaliveScheduler { log.debug("[wecom-keepalive] started for stream={} reqId={}", streamId, reqId); } + /** + * Attach a live progress text source to an already-tracked stream. + * Subsequent refresh ticks write the supplier's snapshot instead of the + * static placeholder. No-op when the stream is not tracked (already + * stopped or force-finished). + */ + public void attachTextSupplier(String streamId, Supplier supplier) { + if (streamId == null || streamId.isBlank()) { + return; + } + StreamState st = states.get(streamId); + if (st != null) { + st.textSupplier = supplier; + } + } + /** * Stop keepalive for a stream — call this immediately before sending * the real reply so the next refresh tick doesn't race the @@ -155,12 +176,29 @@ public class WeComKeepaliveScheduler { return; } try { - st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT); + st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, refreshText(st)); } catch (Exception e) { log.debug("[wecom-keepalive] refresh failed for {}: {}", st.streamId, e.getMessage()); } } + /** Current refresh text: live progress snapshot when attached, static placeholder otherwise. */ + private String refreshText(StreamState st) { + Supplier supplier = st.textSupplier; + if (supplier != null) { + try { + String text = supplier.get(); + if (text != null && !text.isBlank()) { + return text; + } + } catch (Exception e) { + log.debug("[wecom-keepalive] progress supplier failed for {}: {}", + st.streamId, e.getMessage()); + } + } + return PROCESSING_TEXT; + } + // ---- Test hooks ---- int activeStreamCount() { return states.size(); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java new file mode 100644 index 00000000..a02f3f81 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java @@ -0,0 +1,241 @@ +package vip.mate.channel.wecom; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; + +/** + * Builds the live progress text shown in the WeCom stream bubble while an + * agent turn is running: a status header (thinking / tool call / replying, + * with elapsed time), the most recent tool-call lines, an optional rolling + * window of reasoning text, and the tail of the answer produced so far. + *

    + * Mutations arrive from the stream-consuming thread; {@link #snapshot()} may + * also be called from the keepalive scheduler thread, so all state access is + * synchronized on this instance. + */ +final class WeComProgressRenderer { + + /** Max completed/running tool lines rendered before collapsing to a counter. */ + private static final int MAX_TOOL_LINES = 3; + + /** Rolling window (chars) of reasoning text when thinking display is on. */ + private static final int THINKING_WINDOW = 500; + + /** Tail window (chars) of the streamed answer kept inside the bubble. + * The full answer is delivered by the final render pass; the bubble only + * needs enough to show live progress while staying under the 2048 limit. */ + private static final int ANSWER_WINDOW = 1200; + + private record ToolLine(String callId, String name, long startedAt, + Long finishedAt, boolean success) { + } + + private final long startedAtMillis; + private final boolean showThinking; + + private final Deque toolLines = new ArrayDeque<>(); + private int collapsedToolCount; + private final StringBuilder thinkingTail = new StringBuilder(); + private final StringBuilder answerTail = new StringBuilder(); + private boolean thinkingSeen; + private boolean contentSeen; + private boolean approvalPending; + private String planStepLine; + + WeComProgressRenderer(long startedAtMillis, boolean showThinking) { + this.startedAtMillis = startedAtMillis; + this.showThinking = showThinking; + } + + synchronized void onThinkingDelta(String delta) { + thinkingSeen = true; + if (showThinking && delta != null && !delta.isEmpty()) { + thinkingTail.append(delta); + trimLeading(thinkingTail, THINKING_WINDOW); + } + } + + synchronized void onContentDelta(String delta) { + contentSeen = true; + if (delta != null && !delta.isEmpty()) { + answerTail.append(delta); + trimLeading(answerTail, ANSWER_WINDOW); + } + } + + /** + * Consume a graph event. Returns true when the event changes what the + * bubble shows in a way worth flushing immediately (tool transitions, + * plan steps, approval waits) rather than waiting for the throttle tick. + */ + synchronized boolean onEvent(String eventType, Map data) { + if (eventType == null) { + return false; + } + switch (eventType) { + case "tool_call_started" -> { + toolLines.addLast(new ToolLine( + stringField(data, "toolCallId"), + stringField(data, "toolName"), + System.currentTimeMillis(), null, false)); + compactToolLines(); + return true; + } + case "tool_call_completed" -> { + String callId = stringField(data, "toolCallId"); + boolean success = data == null || !Boolean.FALSE.equals(data.get("success")); + markToolCompleted(callId, stringField(data, "toolName"), success); + return true; + } + case "plan_step_started" -> { + Object index = data != null ? data.get("index") : null; + String title = stringField(data, "title"); + planStepLine = "📋 步骤" + (index != null ? " " + index : "") + + (title != null && !title.isBlank() ? ": " + title : ""); + return true; + } + case "tool_approval_requested" -> { + approvalPending = true; + return true; + } + default -> { + return false; + } + } + } + + /** Render the current progress text for the stream bubble. */ + synchronized String snapshot() { + StringBuilder sb = new StringBuilder(); + sb.append(statusLine()); + if (planStepLine != null) { + sb.append('\n').append(planStepLine); + } + appendToolLines(sb); + if (showThinking && !contentSeen && thinkingTail.length() > 0) { + sb.append("\n\n> 💭 ").append(thinkingTail.toString().replace("\n", "\n> ")); + } + if (answerTail.length() > 0) { + sb.append("\n\n").append(answerTail); + } + return sb.toString(); + } + + private String statusLine() { + if (approvalPending) { + return "⏸️ 等待工具审批…(" + elapsed() + ")"; + } + if (contentSeen) { + return "✍️ 正在回复…(" + elapsed() + ")"; + } + ToolLine running = lastRunningTool(); + if (running != null) { + return "🔧 正在调用 " + displayName(running) + "…(" + elapsed() + ")"; + } + if (thinkingSeen) { + return "💭 思考中…(" + elapsed() + ")"; + } + return "🤔 思考中…(" + elapsed() + ")"; + } + + private void appendToolLines(StringBuilder sb) { + if (collapsedToolCount > 0) { + sb.append("\n…等 ").append(collapsedToolCount).append(" 项已完成"); + } + for (ToolLine line : toolLines) { + if (line.finishedAt() == null) { + // The running tool is already the status header — skip here + // unless content started (header shows "正在回复" instead). + if (contentSeen || approvalPending) { + sb.append("\n🔧 ").append(displayName(line)).append(" 运行中…"); + } + } else { + long seconds = Math.max(0, (line.finishedAt() - line.startedAt()) / 1000); + sb.append('\n').append(line.success() ? "✅ " : "❌ ") + .append(displayName(line)) + .append(line.success() ? " 完成" : " 失败") + .append("(").append(seconds).append(" 秒)"); + } + } + } + + private ToolLine lastRunningTool() { + ToolLine running = null; + for (ToolLine line : toolLines) { + if (line.finishedAt() == null) { + running = line; + } + } + return running; + } + + private void markToolCompleted(String callId, String toolName, boolean success) { + ToolLine match = null; + for (ToolLine line : toolLines) { + if (line.finishedAt() != null) { + continue; + } + boolean idMatch = callId != null && callId.equals(line.callId()); + boolean nameMatch = callId == null && toolName != null && toolName.equals(line.name()); + if (idMatch || nameMatch) { + match = line; + } + } + if (match == null) { + toolLines.addLast(new ToolLine(callId, toolName, + System.currentTimeMillis(), System.currentTimeMillis(), success)); + } else { + ToolLine done = new ToolLine(match.callId(), match.name(), + match.startedAt(), System.currentTimeMillis(), success); + replaceLine(match, done); + } + compactToolLines(); + } + + private void replaceLine(ToolLine oldLine, ToolLine newLine) { + Deque rebuilt = new ArrayDeque<>(toolLines.size()); + for (ToolLine line : toolLines) { + rebuilt.addLast(line == oldLine ? newLine : line); + } + toolLines.clear(); + toolLines.addAll(rebuilt); + } + + /** Keep at most {@link #MAX_TOOL_LINES}; older completed lines collapse into a counter. */ + private void compactToolLines() { + while (toolLines.size() > MAX_TOOL_LINES) { + ToolLine oldest = toolLines.peekFirst(); + if (oldest != null && oldest.finishedAt() == null) { + // Never collapse a still-running tool line. + break; + } + toolLines.pollFirst(); + collapsedToolCount++; + } + } + + private String elapsed() { + long seconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000); + if (seconds < 60) { + return "已 " + seconds + " 秒"; + } + return "已 " + (seconds / 60) + " 分 " + (seconds % 60) + " 秒"; + } + + private static String displayName(ToolLine line) { + return line.name() != null && !line.name().isBlank() ? line.name() : "工具"; + } + + private static String stringField(Map data, String key) { + Object value = data != null ? data.get(key) : null; + return value != null ? value.toString() : null; + } + + private static void trimLeading(StringBuilder sb, int maxLen) { + int excess = sb.length() - maxLen; + if (excess > 0) { + sb.delete(0, excess); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProcessStreamTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProcessStreamTest.java new file mode 100644 index 00000000..e04268c2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProcessStreamTest.java @@ -0,0 +1,279 @@ +package vip.mate.channel.wecom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService.StreamDelta; +import vip.mate.channel.ChannelMessage; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.wecom.cards.WeComCardDispatcher; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises {@link WeComChannelAdapter#processStream}: the progress-bubble + * path (event-driven reply_stream overwrites + final finish=true), the + * degraded accumulate-then-send path, and the standalone tool-trace messages + * gated by {@code filter_tool_messages=false}. + */ +class WeComProcessStreamTest { + + @Test + @DisplayName("progress path overwrites the bubble with tool progress, then finishes with the answer") + void progressPathStreamsAndFinishes() throws Exception { + TestableAdapter adapter = newAdapter("{\"progress_interval_ms\": 0}"); + seedReplyContext(adapter, "alice", "req-1", "stream-1"); + + Flux stream = Flux.just( + new StreamDelta(null, "先查时间"), + StreamDelta.event("tool_call_started", + Map.of("toolCallId", "c1", "toolName", "get_time")), + StreamDelta.event("tool_call_completed", + Map.of("toolCallId", "c1", "toolName", "get_time", "success", true)), + new StreamDelta("现在是", null), + new StreamDelta("下午三点。", null)); + + String result = adapter.processStream(stream, inbound("alice"), "wecom:alice"); + + assertEquals("现在是下午三点。", result); + List> frames = adapter.drainFrames(); + List> streamBodies = streamBodies(frames); + assertFalse(streamBodies.isEmpty(), "expected reply_stream progress frames"); + + boolean sawToolProgress = streamBodies.stream() + .filter(s -> Boolean.FALSE.equals(s.get("finish"))) + .anyMatch(s -> String.valueOf(s.get("content")).contains("get_time")); + assertTrue(sawToolProgress, "some non-final chunk should show the tool call"); + + Map finalChunk = streamBodies.get(streamBodies.size() - 1); + assertEquals(Boolean.TRUE, finalChunk.get("finish"), "last chunk must close the stream"); + assertTrue(String.valueOf(finalChunk.get("content")).contains("现在是下午三点。")); + } + + @Test + @DisplayName("stream_progress=false degrades to accumulate-then-send with no interim overwrites") + void progressDisabledDegrades() throws Exception { + TestableAdapter adapter = newAdapter("{\"stream_progress\": false}"); + seedReplyContext(adapter, "alice", "req-1", "stream-1"); + + Flux stream = Flux.just( + StreamDelta.event("tool_call_started", Map.of("toolCallId", "c1", "toolName", "t")), + new StreamDelta("答案", null)); + + String result = adapter.processStream(stream, inbound("alice"), "wecom:alice"); + + assertEquals("答案", result); + List> streamBodies = streamBodies(adapter.drainFrames()); + // Only the final renderAndSend overwrite — no interim progress chunks. + assertEquals(1, streamBodies.size(), "degraded path must not stream progress"); + assertEquals(Boolean.TRUE, streamBodies.get(0).get("finish")); + } + + @Test + @DisplayName("without a reply context the final answer goes out as a plain message") + void noContextFallsBackToPlainSend() throws Exception { + TestableAdapter adapter = newAdapter("{}"); + + Flux stream = Flux.just(new StreamDelta("答案", null)); + String result = adapter.processStream(stream, inbound("alice"), "wecom:alice"); + + assertEquals("答案", result); + List> frames = adapter.drainFrames(); + assertTrue(streamBodies(frames).isEmpty(), "no stream slot → no reply_stream frames"); + assertTrue(frames.stream().anyMatch(f -> "aibot_send_msg".equals(f.get("cmd"))), + "answer must fall back to the proactive send path"); + } + + @Test + @DisplayName("filter_tool_messages=false emits standalone tool trace messages") + void toolTraceMessagesWhenUnfiltered() throws Exception { + TestableAdapter adapter = newAdapter( + "{\"progress_interval_ms\": 0, \"filter_tool_messages\": false}"); + seedReplyContext(adapter, "alice", "req-1", "stream-1"); + + Flux stream = Flux.just( + StreamDelta.event("tool_call_started", + Map.of("toolCallId", "c1", "toolName", "get_time", "arguments", "{\"tz\":\"cn\"}")), + StreamDelta.event("tool_call_completed", + Map.of("toolCallId", "c1", "toolName", "get_time", "success", true)), + new StreamDelta("好了", null)); + + adapter.processStream(stream, inbound("alice"), "wecom:alice"); + + List markdowns = markdownContents(adapter.drainFrames()); + assertTrue(markdowns.stream().anyMatch(t -> t.contains("调用工具") && t.contains("get_time")), + "expected a standalone tool-start trace, got: " + markdowns); + assertTrue(markdowns.stream().anyMatch(t -> t.contains("get_time") && t.contains("完成")), + "expected a standalone tool-completion trace, got: " + markdowns); + } + + @Test + @DisplayName("default filters emit no standalone tool messages") + void noToolTraceByDefault() throws Exception { + TestableAdapter adapter = newAdapter("{\"progress_interval_ms\": 0}"); + seedReplyContext(adapter, "alice", "req-1", "stream-1"); + + Flux stream = Flux.just( + StreamDelta.event("tool_call_started", Map.of("toolCallId", "c1", "toolName", "t1")), + new StreamDelta("答案", null)); + + adapter.processStream(stream, inbound("alice"), "wecom:alice"); + + assertTrue(markdownContents(adapter.drainFrames()).stream().noneMatch(t -> t.contains("调用工具")), + "default config must not leave standalone tool messages"); + } + + // ==================== helpers ==================== + + private static ChannelMessage inbound(String sender) { + return ChannelMessage.builder() + .channelType("wecom") + .senderId(sender) + .replyToken(sender) + .content("hi") + .build(); + } + + private static TestableAdapter newAdapter(String configJson) throws Exception { + ChannelEntity entity = new ChannelEntity(); + entity.setId(1L); + entity.setChannelType("wecom"); + entity.setConfigJson(configJson); + TestableAdapter adapter = new TestableAdapter( + entity, + Mockito.mock(ChannelMessageRouter.class), + new ObjectMapper(), + Mockito.mock(ApprovalNotificationService.class), + Mockito.mock(WeComCardDispatcher.class), + Mockito.mock(WeComKeepaliveScheduler.class)); + + Field running = adapter.getClass().getSuperclass().getSuperclass() + .getDeclaredField("running"); + running.setAccessible(true); + ((AtomicBoolean) running.get(adapter)).set(true); + // sendMessage / sendOutboundFrame gate on a live WebSocket reference; + // sendFrame is overridden so the mock is never actually written to. + Field ws = WeComChannelAdapter.class.getDeclaredField("webSocket"); + ws.setAccessible(true); + ws.set(adapter, Mockito.mock(java.net.http.WebSocket.class)); + Method ensure = WeComChannelAdapter.class.getDeclaredMethod("ensureReplyExecutor"); + ensure.setAccessible(true); + ensure.invoke(adapter); + Method open = WeComChannelAdapter.class.getDeclaredMethod("openReplyQueue"); + open.setAccessible(true); + open.invoke(adapter); + adapter.workerIdleTimeoutMs = 60_000L; + return adapter; + } + + /** Insert a (frameReqId, processingStreamId) reply context for {@code replyToken}. */ + @SuppressWarnings("unchecked") + private static void seedReplyContext(WeComChannelAdapter adapter, String replyToken, + String reqId, String streamId) throws Exception { + Field ctxField = WeComChannelAdapter.class.getDeclaredField("replyContexts"); + ctxField.setAccessible(true); + Map contexts = (Map) ctxField.get(adapter); + Class ctxClass = Class.forName( + "vip.mate.channel.wecom.WeComChannelAdapter$WeComReplyContext"); + Constructor ctor = ctxClass.getDeclaredConstructor(String.class, String.class); + ctor.setAccessible(true); + contexts.put(replyToken, ctor.newInstance(reqId, streamId)); + } + + /** Extract every reply_stream body (in dispatch order) from the captured frames. */ + @SuppressWarnings("unchecked") + private static List> streamBodies(List> frames) { + List> out = new ArrayList<>(); + for (Map frame : frames) { + Map body = (Map) frame.get("body"); + if (body != null && "stream".equals(body.get("msgtype"))) { + out.add((Map) body.get("stream")); + } + } + return out; + } + + /** Extract every markdown message content from the captured frames. */ + @SuppressWarnings("unchecked") + private static List markdownContents(List> frames) { + List out = new ArrayList<>(); + for (Map frame : frames) { + Map body = (Map) frame.get("body"); + if (body != null && "markdown".equals(body.get("msgtype"))) { + Map md = (Map) body.get("markdown"); + if (md != null) { + out.add(String.valueOf(md.get("content"))); + } + } + } + return out; + } + + /** Frame-capturing adapter with auto-ACK, mirroring ReplyStreamDedupTest. */ + static class TestableAdapter extends WeComChannelAdapter { + final LinkedBlockingQueue> sentFrames = new LinkedBlockingQueue<>(); + private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "test-autoack-progress"); + t.setDaemon(true); + return t; + }); + + TestableAdapter(ChannelEntity entity, ChannelMessageRouter router, + ObjectMapper mapper, ApprovalNotificationService approvalSvc, + WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) { + super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive); + } + + /** Drain all frames dispatched so far, waiting briefly for the async worker. */ + List> drainFrames() throws InterruptedException { + List> out = new ArrayList<>(); + Map frame; + while ((frame = sentFrames.poll(500, TimeUnit.MILLISECONDS)) != null) { + out.add(frame); + } + return out; + } + + @Override + @SuppressWarnings("unchecked") + void sendFrame(Map frame) { + sentFrames.offer(frame); + Map headers = (Map) frame.get("headers"); + if (headers == null) return; + String reqId = (String) headers.get("req_id"); + if (reqId == null || reqId.isBlank()) return; + AUTOACK.submit(() -> completeAckSoon(reqId)); + } + + private void completeAckSoon(String reqId) { + try { + Thread.sleep(2); + Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks"); + f.setAccessible(true); + ConcurrentHashMap>> pending = + (ConcurrentHashMap>>) f.get(this); + CompletableFuture> fut = pending.get(reqId); + if (fut != null) fut.complete(Map.of("errcode", 0)); + } catch (Exception ignored) { + } + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProgressRendererTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProgressRendererTest.java new file mode 100644 index 00000000..88c92655 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComProgressRendererTest.java @@ -0,0 +1,122 @@ +package vip.mate.channel.wecom; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class WeComProgressRendererTest { + + @Test + @DisplayName("initial snapshot shows a thinking status with elapsed time") + void initialSnapshot() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + String s = r.snapshot(); + assertTrue(s.contains("思考中"), s); + assertTrue(s.contains("已 "), s); + } + + @Test + @DisplayName("tool start flips status to the running tool and requests an immediate flush") + void toolStartUpdatesStatus() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + boolean flush = r.onEvent("tool_call_started", + Map.of("toolCallId", "c1", "toolName", "get_weather")); + assertTrue(flush); + String s = r.snapshot(); + assertTrue(s.contains("正在调用 get_weather"), s); + } + + @Test + @DisplayName("tool completion renders a checked line with duration") + void toolCompletionRendersLine() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + r.onEvent("tool_call_started", Map.of("toolCallId", "c1", "toolName", "get_weather")); + r.onEvent("tool_call_completed", + Map.of("toolCallId", "c1", "toolName", "get_weather", "success", true)); + String s = r.snapshot(); + assertTrue(s.contains("✅ get_weather 完成"), s); + assertFalse(s.contains("正在调用"), s); + } + + @Test + @DisplayName("failed tool renders a cross line") + void toolFailureRendersCross() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + r.onEvent("tool_call_started", Map.of("toolCallId", "c1", "toolName", "search")); + r.onEvent("tool_call_completed", + Map.of("toolCallId", "c1", "toolName", "search", "success", false)); + String s = r.snapshot(); + assertTrue(s.contains("❌ search 失败"), s); + } + + @Test + @DisplayName("older completed tools collapse into a counter beyond the display cap") + void toolLinesCollapse() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + for (int i = 1; i <= 5; i++) { + String id = "c" + i; + r.onEvent("tool_call_started", Map.of("toolCallId", id, "toolName", "tool" + i)); + r.onEvent("tool_call_completed", Map.of("toolCallId", id, "toolName", "tool" + i)); + } + String s = r.snapshot(); + assertTrue(s.contains("…等 2 项已完成"), s); + assertTrue(s.contains("tool5"), s); + assertFalse(s.contains("tool1"), s); + } + + @Test + @DisplayName("thinking text appears as a quote block only when display is enabled") + void thinkingDisplayGate() { + WeComProgressRenderer shown = new WeComProgressRenderer(System.currentTimeMillis(), true); + shown.onThinkingDelta("先查当前时间"); + assertTrue(shown.snapshot().contains("> 💭 先查当前时间"), shown.snapshot()); + + WeComProgressRenderer hidden = new WeComProgressRenderer(System.currentTimeMillis(), false); + hidden.onThinkingDelta("先查当前时间"); + String s = hidden.snapshot(); + assertFalse(s.contains("先查当前时间"), s); + assertTrue(s.contains("💭 思考中"), s); + } + + @Test + @DisplayName("content deltas switch status to replying and show the answer tail") + void contentSwitchesToReplying() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), true); + r.onThinkingDelta("想一想"); + r.onContentDelta("今天天气晴。"); + String s = r.snapshot(); + assertTrue(s.contains("正在回复"), s); + assertTrue(s.contains("今天天气晴。"), s); + // Thinking quote is dropped once real content flows. + assertFalse(s.contains("> 💭"), s); + } + + @Test + @DisplayName("approval request switches status to waiting") + void approvalSwitchesStatus() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + boolean flush = r.onEvent("tool_approval_requested", Map.of("toolName", "rm")); + assertTrue(flush); + assertTrue(r.snapshot().contains("等待工具审批"), r.snapshot()); + } + + @Test + @DisplayName("answer tail stays bounded for very long streamed content") + void answerTailBounded() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + r.onContentDelta("x".repeat(5000)); + assertTrue(r.snapshot().length() < 2048, + "snapshot must stay under the WeCom message limit"); + } + + @Test + @DisplayName("unknown events are ignored without requesting a flush") + void unknownEventsIgnored() { + WeComProgressRenderer r = new WeComProgressRenderer(System.currentTimeMillis(), false); + assertFalse(r.onEvent("_usage_final", Map.of())); + assertFalse(r.onEvent(null, null)); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 47ad4ea0..00b397cf 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -3519,9 +3519,9 @@ export default { messageFilter: { title: 'Message Filter', filterThinking: 'Filter Thinking', - filterThinkingTooltip: 'Filter tag content before sending to users', + filterThinkingTooltip: 'Yes: strip content from the final answer only; No: show reasoning live (WeCom streams it in the progress bubble)', filterToolMessages: 'Filter Tool Messages', - filterToolMessagesTooltip: 'Filter tool_call / tool_result and ReAct Action/Observation lines', + filterToolMessagesTooltip: 'Yes: strip inline tool_call / Action tags from the final answer only; No: each tool call leaves a standalone trace message (supported on WeCom)', messageFormat: 'Message Format', formatAuto: 'Auto', formatMarkdown: 'Markdown', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index f31dd4bb..fbb6e9d4 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3619,9 +3619,9 @@ export default { messageFilter: { title: '消息过滤', filterThinking: '过滤思维链', - filterThinkingTooltip: '发送给用户前过滤 标签内容', + filterThinkingTooltip: '是:仅过滤最终答案中的 内容;否:思考过程实时展示(企业微信在进度气泡中流式显示)', filterToolMessages: '过滤工具消息', - filterToolMessagesTooltip: '过滤 tool_call / tool_result 和 ReAct Action/Observation 行', + filterToolMessagesTooltip: '是:仅过滤最终答案中的 tool_call / Action 等内联标签;否:每次工具调用以独立消息留痕(企业微信已支持)', messageFormat: '消息格式', formatAuto: '自动', formatMarkdown: 'Markdown',