sync: inject sender context into agent prompt + Feishu DONE ack hook

This commit is contained in:
matevip 2026-05-20 11:51:58 +08:00
parent 35f010d7a1
commit 3554da8dbc
19 changed files with 529 additions and 26 deletions

View File

@ -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 ----------------

View File

@ -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)");
}
}
}

View File

@ -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);

View File

@ -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

View File

@ -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();

View File

@ -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")

View File

@ -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
}
}

View File

@ -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());
}
/**

View File

@ -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;
}

View File

@ -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 不影响主流程

View File

@ -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());
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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");
}
}

View File

@ -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("[定时任务执行说明]"));

View File

@ -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);

View File

@ -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);