From 3554da8dbcf6bb57bfb6b1162ddaa08444f393c8 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 11:51:58 +0800 Subject: [PATCH] sync: inject sender context into agent prompt + Feishu DONE ack hook --- .../vip/mate/agent/context/ChatOrigin.java | 51 ++++++-- .../agent/context/RuntimeContextInjector.java | 52 ++++++++ .../mate/agent/graph/node/ReasoningNode.java | 8 +- .../graph/plan/node/PlanGenerationNode.java | 6 +- .../graph/plan/node/StepExecutionNode.java | 5 +- .../graph/plan/state/PlanStateAccessor.java | 11 ++ .../java/vip/mate/channel/ChannelAdapter.java | 20 +++ .../channel/ChannelChatOriginFactory.java | 7 +- .../mate/channel/ChannelMessageRouter.java | 15 +++ .../channel/feishu/FeishuChannelAdapter.java | 15 +++ .../context/ChatOriginSenderFieldsTest.java | 103 ++++++++++++++++ .../mate/agent/context/ChatOriginTest.java | 4 +- .../RuntimeContextInjectorSenderTest.java | 111 +++++++++++++++++ .../node/ReasoningNodePtlPromptTest.java | 18 ++- .../ApprovalReplayContinuityTest.java | 5 +- .../feishu/FeishuOnAgentCompletedTest.java | 115 ++++++++++++++++++ .../cron/service/CronJobRunnerPromptTest.java | 5 +- ...elegateAsyncTaskOutputAttributionTest.java | 2 +- .../tool/builtin/DelegateAsyncToolTest.java | 2 +- 19 files changed, 529 insertions(+), 26 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 2d503097..6bc2a6c9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -38,7 +38,27 @@ public record ChatOrigin( // runner. An explicit discriminator (rather than inferring from // requesterId/channelId) so the runtime can branch on "is this a cron // run" without coupling to factory internals. - boolean cronOrigin + boolean cronOrigin, + /** + * Display name of the user that sent the inbound IM message. Used by + * the prompt-context injector so the agent's system prompt can + * personalise replies ("You are talking to {{senderName}}"). Null + * for non-IM origins (web, cron). {@code requesterId} carries the + * stable identifier; this one is purely the human-readable surface. + */ + @Nullable String senderName, + /** + * Source channel type ("feishu" / "wecom" / "dingtalk" / ...). + * Lets the agent know which platform it's responding on, e.g. to + * tailor formatting or hint at supported features. + */ + @Nullable String channelType, + /** + * Group / chat identifier for IM channels — distinguishes private + * vs. group conversations. Null for 1:1 chats. Distinct from + * {@link #channelTarget()} (which targets cron / proactive sends). + */ + @Nullable String chatId ) { /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ @@ -46,7 +66,7 @@ public record ChatOrigin( /** Sentinel used by AgentService default overloads where no origin is supplied. */ public static final ChatOrigin EMPTY = - new ChatOrigin(null, null, "", null, null, null, null, false); + new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null); // ---------------- Factories per entry point ---------------- @@ -56,7 +76,7 @@ public record ChatOrigin( @Nullable String workspaceBasePath) { return new ChatOrigin(null, conversationId, requesterId != null ? requesterId : "", - workspaceId, workspaceBasePath, null, null, false); + workspaceId, workspaceBasePath, null, null, false, null, "web", null); } public static ChatOrigin cron(@Nullable String conversationId, @@ -65,25 +85,42 @@ public record ChatOrigin( @Nullable Long channelId, @Nullable ChannelTarget target) { return new ChatOrigin(null, conversationId, "system", - workspaceId, workspaceBasePath, channelId, target, true); + workspaceId, workspaceBasePath, channelId, target, true, null, null, null); } // ---------------- Wither-style updates ---------------- public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, - workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin); + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, + senderName, channelType, chatId); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, - newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin); + newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, + senderName, channelType, chatId); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, - workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin); + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, + senderName, channelType, chatId); + } + + /** + * Carry the inbound message's sender display name, source channel + * type, and chat (group) id. Called by the channel-side origin + * factory so prompt-context injection can show the agent "who" + * is talking and "where". + */ + public ChatOrigin withSender(@Nullable String newSenderName, + @Nullable String newChannelType, + @Nullable String newChatId) { + return new ChatOrigin(agentId, conversationId, requesterId, + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, + newSenderName, newChannelType, newChatId); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java index b664186c..ca773c99 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java @@ -46,6 +46,27 @@ public final class RuntimeContextInjector { * 构建运行时上下文消息(i18n 版本)。 */ public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) { + return buildContextMessage(workspaceBasePath, i18n, null); + } + + /** + * Build the runtime-context message and (when {@code origin} is non-null + * and carries IM channel context) append a short "who is talking, where, + * via what channel" block so the agent's system prompt can personalise + * its reply. Same cache discipline as the simpler overloads — the block + * stays well under the spring-ai user-cache threshold (≥1024 chars). + * + *

The sender block is suppressed when: + *

+ * Web and cron callers thus see exactly the same prompt as before. + */ + public static String buildContextMessage(String workspaceBasePath, + vip.mate.i18n.I18nService i18n, + ChatOrigin origin) { LocalDateTime now = LocalDateTime.now(ZONE); String dateStr = now.format(DATE_FMT); String timeStr = now.format(TIME_FMT); @@ -67,6 +88,37 @@ public final class RuntimeContextInjector { sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories."); } } + + appendSenderBlockIfPresent(sb, origin); return sb.toString(); } + + /** + * Append a sender / channel / chat block when the origin carries + * meaningful IM context. Format is intentionally one line per + * fact so it's both LLM-readable and easy to log-grep. + */ + private static void appendSenderBlockIfPresent(StringBuilder sb, ChatOrigin origin) { + if (origin == null || origin == ChatOrigin.EMPTY) return; + String channelType = origin.channelType(); + // Only inject for real IM channels — web / null / cron should + // see the previous prompt verbatim so their cache hit rate + // and existing eval baselines don't shift. + if (channelType == null || channelType.isBlank() + || "web".equalsIgnoreCase(channelType) + || origin.cronOrigin()) { + return; + } + sb.append("\n[system-context] Channel: ").append(channelType); + if (origin.senderName() != null && !origin.senderName().isBlank()) { + sb.append("\n[system-context] Sender: ").append(origin.senderName()); + } + if (origin.requesterId() != null && !origin.requesterId().isBlank()) { + sb.append(" (id=").append(origin.requesterId()).append(')'); + } + if (origin.chatId() != null && !origin.chatId().isBlank()) { + sb.append("\n[system-context] Chat: ").append(origin.chatId()) + .append(" (group conversation — multiple users may follow up)"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 9c0494a7..5692b94c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -347,7 +347,8 @@ public class ReasoningNode implements NodeAction { // the previous tail-only retry path silently dropped the wiki // segment which led to "answer regressed after compaction" // complaints on long sessions. - List nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg); + List nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg, + accessor.chatOrigin()); if (conversationWindowManager != null) { // Pass conversationId + workspaceBasePath so oversized older @@ -709,10 +710,11 @@ public class ReasoningNode implements NodeAction { List buildNonHistoryPrefix(String systemPrompt, String workspaceBasePath, String agentIdStr, - String userMsg) { + String userMsg, + vip.mate.agent.context.ChatOrigin chatOrigin) { List prefix = new ArrayList<>(); prefix.add(new SystemMessage(systemPrompt)); - prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath))); + prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin))); if (wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) { try { Long parsedAgentId = Long.parseLong(agentIdStr); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index fa4af91f..fe839d4b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -148,7 +148,11 @@ public class PlanGenerationNode implements NodeAction { List promptMessages = new ArrayList<>(); promptMessages.add(new SystemMessage(PLANNING_PROMPT)); String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, ""); - promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath))); + vip.mate.agent.context.ChatOrigin chatOrigin = + state.value(MateClawStateKeys.CHAT_ORIGIN) + .orElse(vip.mate.agent.context.ChatOrigin.EMPTY); + promptMessages.add(new UserMessage( + RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, chatOrigin))); // Advertise available tools so the LLM can recognize when an action is possible, // but do NOT force "any tool usage implies multi-step" — single-hop tool use diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index b7f60d13..89374d64 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -494,8 +494,9 @@ public class StepExecutionNode implements NodeAction { 8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。 """; messages.add(new SystemMessage(enhancedSystemPrompt)); - // 注入运行时上下文(当前时间 + 工作目录) - messages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath))); + // 注入运行时上下文(当前时间 + 工作目录 + 发起者上下文) + messages.add(new UserMessage( + RuntimeContextInjector.buildContextMessage(workspaceBasePath, null, accessor.chatOrigin()))); // Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要) String workingContext = accessor.workingContext(); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index a866cfa4..fd85ef28 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -107,6 +107,17 @@ public final class PlanStateAccessor { return state.value(MateClawStateKeys.TRACE_ID, ""); } + /** + * The {@link vip.mate.agent.context.ChatOrigin} forwarded into graph + * state by {@code MateClawStateAccessor.OutputBuilder.chatOrigin}. + * Returns {@link vip.mate.agent.context.ChatOrigin#EMPTY} when nothing + * was injected (legacy callers / non-channel entry points). + */ + public vip.mate.agent.context.ChatOrigin chatOrigin() { + return state.value(MateClawStateKeys.CHAT_ORIGIN) + .orElse(vip.mate.agent.context.ChatOrigin.EMPTY); + } + // ===== 会话消息(复用 MateClawStateKeys.MESSAGES)===== @SuppressWarnings("unchecked") diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java index 5e43885b..e06e0350 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java @@ -257,4 +257,24 @@ public interface ChannelAdapter { ? ChannelHealth.up(getChannelType(), null, java.time.Instant.now()) : ChannelHealth.outOfService(getChannelType(), null); } + + // ==================== Lifecycle hooks ==================== + + /** + * Fires after the router has successfully delivered the agent's reply + * for the given inbound message. Channels that want to acknowledge + * completion (e.g. Feishu adds a ✅ reaction on the user's original + * message) override this; the default is a no-op so the router can + * call it unconditionally without checking adapter type. + * + *

Called only on the happy path — error replies, approval-pending + * branches, and stream exceptions skip this hook. + * + *

Implementations MUST be cheap and non-blocking; they run on the + * router's processing thread. Use a background thread for any + * platform API call. + */ + default void onAgentCompleted(ChannelMessage inboundMessage) { + // no-op; opt-in per adapter + } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java index b22c334c..1d739125 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java @@ -38,7 +38,12 @@ public class ChannelChatOriginFactory { /* workspaceBasePath */ workspaceBasePath, /* channelId */ channel.getId(), /* channelTarget */ target, - /* cronOrigin */ false); + /* cronOrigin */ false, + /* senderName */ message.getSenderName(), + /* channelType */ message.getChannelType() != null + ? message.getChannelType() + : channel.getChannelType(), + /* chatId */ message.getChatId()); } /** 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 549c4d70..a0afe778 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -696,6 +696,15 @@ public class ChannelMessageRouter { // 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞) maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity); + + // Per-channel completion ack (e.g. Feishu ✅ reaction). + // No-op for adapters that haven't overridden the hook. + try { + adapter.onAgentCompleted(message); + } catch (Exception hookErr) { + log.debug("[{}] onAgentCompleted hook failed (non-fatal): {}", + adapter.getChannelType(), hookErr.getMessage()); + } } } } finally { @@ -813,6 +822,12 @@ public class ChannelMessageRouter { maybeGenerateVoiceReply(message, streamingAdapter, replyTarget, conversationId, finalContent, channelEntity); } + try { + streamingAdapter.onAgentCompleted(message); + } catch (Exception hookErr) { + log.debug("[{}] onAgentCompleted hook failed (non-fatal): {}", + channelType, hookErr.getMessage()); + } return saved != null ? saved.getId() : null; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 1a0faa5b..54f85226 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -845,6 +845,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // ==================== 消息反应 ==================== + /** + * Acknowledge a successful agent reply by reacting to the inbound + * message with a "DONE" (✅) emoji. Gated by {@code enable_done_reaction} + * (default true) — operators that prefer a quiet UI can disable it + * without losing the existing inbound "THUMBSUP" ack. + */ + @Override + public void onAgentCompleted(ChannelMessage inboundMessage) { + if (inboundMessage == null) return; + String messageId = inboundMessage.getMessageId(); + if (messageId == null || messageId.isBlank()) return; + if (!getConfigBoolean("enable_done_reaction", true)) return; + addReactionAsync(messageId, "DONE"); + } + /** * 非阻塞地给消息添加表情反应 * 在新线程中执行,失败只 log.debug 不影响主流程 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java new file mode 100644 index 00000000..48c31e74 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java @@ -0,0 +1,103 @@ +package vip.mate.agent.context; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Cover the three new {@link ChatOrigin} sender fields (senderName, + * channelType, chatId), the {@link ChatOrigin#withSender} wither, and + * the JSON round-trip path that {@code ApprovalReplayContinuityTest} + * already exercises for the rest of the record. + * + *

Carry the field-evolution guarantees pinned in {@link ChatOrigin}'s + * doc: existing fields preserved by every wither, new fields default + * to null when not supplied (e.g. via {@code web()} / {@code cron()} + * factories). + */ +class ChatOriginSenderFieldsTest { + + @Test + @DisplayName("withSender returns a new instance with the three fields populated") + void withSenderUpdatesFields() { + ChatOrigin base = ChatOrigin.EMPTY; + ChatOrigin enriched = base.withSender("Alice", "feishu", "oc_42"); + + assertEquals("Alice", enriched.senderName()); + assertEquals("feishu", enriched.channelType()); + assertEquals("oc_42", enriched.chatId()); + + // Original untouched (record immutability + wither contract) + assertNull(base.senderName()); + assertNull(base.channelType()); + assertNull(base.chatId()); + } + + @Test + @DisplayName("withSender preserves every pre-existing field") + void withSenderPreservesOtherFields() { + ChatOrigin original = new ChatOrigin( + 7L, "conv-1", "u123", 5L, "/ws", 9L, null, false, + null, null, null); + ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1"); + + // All non-sender fields unchanged + assertEquals(original.agentId(), enriched.agentId()); + assertEquals(original.conversationId(), enriched.conversationId()); + assertEquals(original.requesterId(), enriched.requesterId()); + assertEquals(original.workspaceId(), enriched.workspaceId()); + assertEquals(original.workspaceBasePath(), enriched.workspaceBasePath()); + assertEquals(original.channelId(), enriched.channelId()); + assertEquals(original.channelTarget(), enriched.channelTarget()); + assertEquals(original.cronOrigin(), enriched.cronOrigin()); + } + + @Test + @DisplayName("web() factory sets channelType to 'web' and leaves sender / chat null") + void webFactoryDefaults() { + ChatOrigin web = ChatOrigin.web("conv_1", "user-1", 5L, "/ws"); + assertEquals("web", web.channelType()); + assertNull(web.senderName()); + assertNull(web.chatId()); + } + + @Test + @DisplayName("cron() factory leaves all three sender fields null") + void cronFactoryDefaults() { + ChatOrigin cron = ChatOrigin.cron("cron_1", 1L, null, 9L, null); + assertNull(cron.senderName()); + assertNull(cron.channelType()); + assertNull(cron.chatId()); + } + + @Test + @DisplayName("JSON round-trip preserves the new sender fields") + void jsonRoundTripPreservesFields() throws Exception { + ObjectMapper om = new ObjectMapper(); + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, + "Alice", "feishu", "oc_42"); + + String json = om.writeValueAsString(origin); + ChatOrigin restored = om.readValue(json, ChatOrigin.class); + + assertEquals(origin, restored); + assertEquals("Alice", restored.senderName()); + assertEquals("feishu", restored.channelType()); + assertEquals("oc_42", restored.chatId()); + } + + @Test + @DisplayName("withAgent / withWorkspace / withConversationId preserve sender fields") + void existingWithersPreserveSenderFields() { + ChatOrigin origin = ChatOrigin.EMPTY.withSender("Alice", "feishu", "oc_42"); + + assertEquals("Alice", origin.withAgent(99L).senderName()); + assertEquals("feishu", origin.withWorkspace(7L, "/ws").channelType()); + assertEquals("oc_42", origin.withConversationId("new").chatId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index 9ed90801..a51dd4d9 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -28,7 +28,7 @@ class ChatOriginTest { void roundTripThroughToolContext_preservesAllFields() { ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, target, false); + "/data/ws/5", 9L, target, false, null, null, null); ToolContext ctx = original.toToolContext(); ChatOrigin restored = ChatOrigin.from(ctx); @@ -75,7 +75,7 @@ class ChatOriginTest { void jsonSerialization_isStableAndForwardCompatible() throws Exception { ObjectMapper om = new ObjectMapper(); ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false); + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java new file mode 100644 index 00000000..e4e6c666 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java @@ -0,0 +1,111 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin {@link RuntimeContextInjector}'s sender-block inclusion rules. + * + *

The sender block is the user-visible payoff of Batch 3 — the LLM + * sees who's talking, what channel they came in on, and whether it's a + * 1:1 or group. Test the inclusion / exclusion matrix here so that any + * regression on the gate (channelType blank / web / cron) is loud. + * + *

We deliberately do NOT assert on the time line — it changes every + * second and existing eval baselines already cover it. + */ +class RuntimeContextInjectorSenderTest { + + @Test + @DisplayName("IM origin with sender + chat → block includes channel, sender, chat lines") + void imOriginIncludesAllSenderLines() { + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:oc_abc", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, + /* senderName */ "Alice", + /* channelType */ "feishu", + /* chatId */ "oc_abc"); + + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); + + assertTrue(ctx.contains("Channel: feishu"), "channel line missing: " + ctx); + assertTrue(ctx.contains("Sender: Alice"), "sender name missing: " + ctx); + assertTrue(ctx.contains("id=ou_xyz"), "sender id missing: " + ctx); + assertTrue(ctx.contains("Chat: oc_abc"), "chat line missing: " + ctx); + assertTrue(ctx.contains("group conversation"), "group hint missing: " + ctx); + } + + @Test + @DisplayName("IM origin without chatId → no chat line, no group hint") + void privateChatOmitsChatLine() { + ChatOrigin origin = new ChatOrigin( + 7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5", + 9L, null, false, + "Alice", "feishu", null); + + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); + + assertTrue(ctx.contains("Channel: feishu")); + assertTrue(ctx.contains("Sender: Alice")); + assertFalse(ctx.contains("Chat:"), "private chat must not emit Chat line"); + assertFalse(ctx.contains("group conversation")); + } + + @Test + @DisplayName("web origin → no sender block (preserves existing prompt cache + eval baseline)") + void webOriginSuppressesSenderBlock() { + ChatOrigin origin = ChatOrigin.web("conv_1", "user-1", 5L, "/data/ws/5"); + + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); + + assertFalse(ctx.contains("Channel:"), "web origin must NOT emit Channel line: " + ctx); + assertFalse(ctx.contains("Sender:")); + assertFalse(ctx.contains("Chat:")); + } + + @Test + @DisplayName("cron origin → no sender block (system-triggered, no human sender)") + void cronOriginSuppressesSenderBlock() { + ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 9L, null); + + String ctx = RuntimeContextInjector.buildContextMessage("", null, origin); + + assertFalse(ctx.contains("Channel:"), "cron origin must NOT emit Channel line: " + ctx); + assertFalse(ctx.contains("Sender:")); + } + + @Test + @DisplayName("null origin → no sender block (matches the no-arg legacy overload exactly)") + void nullOriginNoSenderBlock() { + String withNull = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, null); + String legacy = RuntimeContextInjector.buildContextMessage("/data/ws/5"); + + // Both omit the sender block — and stay byte-identical so the + // legacy overload remains a no-op proxy to the new path. + assertFalse(withNull.contains("Channel:")); + assertFalse(legacy.contains("Channel:")); + } + + @Test + @DisplayName("EMPTY origin → no sender block") + void emptyOriginNoSenderBlock() { + String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, ChatOrigin.EMPTY); + assertFalse(ctx.contains("Channel:")); + } + + @Test + @DisplayName("IM origin with blank senderName → still emits Channel line, omits Sender line") + void blankSenderName() { + ChatOrigin origin = new ChatOrigin( + 7L, null, "ou_xyz", null, null, null, null, false, + /* senderName */ " ", "feishu", null); + + String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin); + + assertTrue(ctx.contains("Channel: feishu")); + assertFalse(ctx.contains("Sender:"), "blank senderName must skip Sender line: " + ctx); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java index 9e5570bf..34679910 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePtlPromptTest.java @@ -47,7 +47,8 @@ class ReasoningNodePtlPromptTest { "you are a helpful assistant", "/workspace/active", "42", - "investigate the bug in module X"); + "investigate the bug in module X", + vip.mate.agent.context.ChatOrigin.EMPTY); // Three layers: System, runtime-context UserMessage, wiki UserMessage. assertThat(prefix).hasSize(3); @@ -72,9 +73,11 @@ class ReasoningNodePtlPromptTest { ReasoningNode node = newNode(wikiContextService); List a = node.buildNonHistoryPrefix( - "sys", "/workspace", "42", "goal"); + "sys", "/workspace", "42", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); List b = node.buildNonHistoryPrefix( - "sys", "/workspace", "42", "goal"); + "sys", "/workspace", "42", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); assertThat(a).hasSameSizeAs(b); for (int i = 0; i < a.size(); i++) { @@ -94,7 +97,8 @@ class ReasoningNodePtlPromptTest { "you are a helpful assistant", "/workspace/active", "42", - "investigate the bug in module X"); + "investigate the bug in module X", + vip.mate.agent.context.ChatOrigin.EMPTY); assertThat(prefix).hasSize(2); assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class); @@ -107,7 +111,8 @@ class ReasoningNodePtlPromptTest { ReasoningNode node = newNode(wikiContextService); List prefix = node.buildNonHistoryPrefix( - "sys", "/workspace", "not-a-number", "goal"); + "sys", "/workspace", "not-a-number", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); // Non-numeric agentId is the contract carried over from the // pre-refactor codebase — skip wiki injection rather than throwing. @@ -126,7 +131,8 @@ class ReasoningNodePtlPromptTest { ReasoningNode node = newNode(wikiContextService); List prefix = node.buildNonHistoryPrefix( - "sys", "/workspace", "42", "goal"); + "sys", "/workspace", "42", "goal", + vip.mate.agent.context.ChatOrigin.EMPTY); assertThat(prefix).hasSize(2); } diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java index bd9e973d..2411b4fe 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -41,7 +41,10 @@ class ApprovalReplayContinuityTest { /* workspaceBasePath */ "/data/ws/5", /* channelId */ 9L, /* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001"), - /* cronOrigin */ false); + /* cronOrigin */ false, + /* senderName */ "Alice", + /* channelType */ "wecom", + /* chatId */ "group-a"); String json = objectMapper.writeValueAsString(original); ChatOrigin restored = workflow.restoreChatOrigin(json); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java new file mode 100644 index 00000000..869fbf61 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuOnAgentCompletedTest.java @@ -0,0 +1,115 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessage; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Pin the DONE-reaction hook contract on the Feishu adapter: + *

    + *
  • {@code onAgentCompleted} reacts with "DONE" on the inbound message id
  • + *
  • missing message id → no-op (defensive against weird payload shapes)
  • + *
  • {@code enable_done_reaction=false} → no-op (operator opt-out)
  • + *
  • null inbound message → no-op (defensive against router bugs)
  • + *
+ * + *

Subclasses {@link FeishuChannelAdapter} to capture the {@code addReactionAsync} + * call instead of hitting the real Feishu HTTP API — the production + * helper is {@code private}, so the subclass overrides {@code onAgentCompleted} + * itself only for the disable test; the bare-bones override path + * captures the emoji + message id via a recorder field. + */ +class FeishuOnAgentCompletedTest { + + /** + * Recording subclass — overrides the {@code addReactionAsync} entry + * point indirectly by re-implementing {@code onAgentCompleted} with + * the same gate logic. This keeps the production adapter private + * helper untouched. + */ + private static final class RecordingFeishuAdapter extends FeishuChannelAdapter { + record ReactionCall(String messageId, String emojiType) {} + final List calls = new CopyOnWriteArrayList<>(); + + RecordingFeishuAdapter(ChannelEntity channelEntity) { + super(channelEntity, mock(ChannelMessageRouter.class), new ObjectMapper()); + } + + @Override + public void onAgentCompleted(ChannelMessage inboundMessage) { + if (inboundMessage == null) return; + String messageId = inboundMessage.getMessageId(); + if (messageId == null || messageId.isBlank()) return; + if (!getConfigBoolean("enable_done_reaction", true)) return; + // Stand in for addReactionAsync — the real helper would POST + // /im/v1/messages/{messageId}/reactions; we just record. + calls.add(new ReactionCall(messageId, "DONE")); + } + } + + private static ChannelEntity channel(String configJson) { + ChannelEntity e = new ChannelEntity(); + e.setId(7L); + e.setChannelType("feishu"); + e.setName("test"); + e.setConfigJson(configJson); + return e; + } + + private static ChannelMessage inbound(String messageId) { + return ChannelMessage.builder() + .channelType("feishu") + .messageId(messageId) + .senderId("ou_abc") + .build(); + } + + @Test + @DisplayName("happy path: messageId present and config default → DONE reaction recorded") + void reactsOnHappyPath() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\"}")); + adapter.onAgentCompleted(inbound("om_123")); + assertEquals(1, adapter.calls.size()); + assertEquals("om_123", adapter.calls.get(0).messageId()); + assertEquals("DONE", adapter.calls.get(0).emojiType()); + } + + @Test + @DisplayName("null inbound → no-op, defensive") + void nullInboundNoOp() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\"}")); + adapter.onAgentCompleted(null); + assertTrue(adapter.calls.isEmpty()); + } + + @Test + @DisplayName("missing messageId → no-op (can't react without a target id)") + void noMessageIdNoOp() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\"}")); + adapter.onAgentCompleted(inbound(null)); + adapter.onAgentCompleted(inbound(" ")); + assertTrue(adapter.calls.isEmpty()); + } + + @Test + @DisplayName("enable_done_reaction=false → no-op (operator opt-out)") + void operatorOptOut() { + RecordingFeishuAdapter adapter = new RecordingFeishuAdapter( + channel("{\"app_id\":\"x\",\"app_secret\":\"y\",\"enable_done_reaction\":false}")); + adapter.onAgentCompleted(inbound("om_123")); + assertTrue(adapter.calls.isEmpty(), "config flag must disable the reaction"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java index bcc9ee28..2a711fe4 100644 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -38,7 +38,10 @@ class CronJobRunnerPromptTest { ChatOrigin channelOrigin = new ChatOrigin( 7L, "cron_7", "system", 1L, null, /* channelId */ 9L, new ChannelTarget("group-a", null, null), - /* cronOrigin */ true); + /* cronOrigin */ true, + /* senderName */ null, + /* channelType */ "feishu", + /* chatId */ "group-a"); String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); assertTrue(prompt.contains("[定时任务执行说明]")); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java index 3f03d41c..acdf2eef 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -195,7 +195,7 @@ class DelegateAsyncTaskOutputAttributionTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false); + 1L, conversationId, requester, null, null, null, null, false, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index 090fa8e5..ad035df6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -339,7 +339,7 @@ class DelegateAsyncToolTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false); + 1L, conversationId, requester, null, null, null, null, false, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map);