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: + *
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 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
+ *
+ *
+ *