mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
sync: inject sender context into agent prompt + Feishu DONE ack hook
This commit is contained in:
parent
35f010d7a1
commit
3554da8dbc
@ -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 ----------------
|
||||
|
||||
@ -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).
|
||||
*
|
||||
* <p>The sender block is suppressed when:
|
||||
* <ul>
|
||||
* <li>{@code origin} is null or {@link ChatOrigin#EMPTY}</li>
|
||||
* <li>the origin carries no IM context (web / cron) — both produce
|
||||
* a null {@code channelType} or {@code "web"}</li>
|
||||
* </ul>
|
||||
* 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg);
|
||||
List<Message> 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<Message> buildNonHistoryPrefix(String systemPrompt,
|
||||
String workspaceBasePath,
|
||||
String agentIdStr,
|
||||
String userMsg) {
|
||||
String userMsg,
|
||||
vip.mate.agent.context.ChatOrigin chatOrigin) {
|
||||
List<Message> 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);
|
||||
|
||||
@ -148,7 +148,11 @@ public class PlanGenerationNode implements NodeAction {
|
||||
List<Message> 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.<vip.mate.agent.context.ChatOrigin>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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||
}
|
||||
|
||||
// ===== 会话消息(复用 MateClawStateKeys.MESSAGES)=====
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>Called only on the happy path — error replies, approval-pending
|
||||
* branches, and stream exceptions skip this hook.
|
||||
*
|
||||
* <p>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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 不影响主流程
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@ -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<Message> a = node.buildNonHistoryPrefix(
|
||||
"sys", "/workspace", "42", "goal");
|
||||
"sys", "/workspace", "42", "goal",
|
||||
vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||
List<Message> 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<Message> 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<Message> prefix = node.buildNonHistoryPrefix(
|
||||
"sys", "/workspace", "42", "goal");
|
||||
"sys", "/workspace", "42", "goal",
|
||||
vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||
|
||||
assertThat(prefix).hasSize(2);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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:
|
||||
* <ul>
|
||||
* <li>{@code onAgentCompleted} reacts with "DONE" on the inbound message id</li>
|
||||
* <li>missing message id → no-op (defensive against weird payload shapes)</li>
|
||||
* <li>{@code enable_done_reaction=false} → no-op (operator opt-out)</li>
|
||||
* <li>null inbound message → no-op (defensive against router bugs)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<ReactionCall> 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");
|
||||
}
|
||||
}
|
||||
@ -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("[定时任务执行说明]"));
|
||||
|
||||
@ -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<String, Object> map = new HashMap<>();
|
||||
map.put(ChatOrigin.CTX_KEY, origin);
|
||||
return new ToolContext(map);
|
||||
|
||||
@ -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<String, Object> map = new HashMap<>();
|
||||
map.put(ChatOrigin.CTX_KEY, origin);
|
||||
return new ToolContext(map);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user