mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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
|
// runner. An explicit discriminator (rather than inferring from
|
||||||
// requesterId/channelId) so the runtime can branch on "is this a cron
|
// requesterId/channelId) so the runtime can branch on "is this a cron
|
||||||
// run" without coupling to factory internals.
|
// 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}. */
|
/** 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. */
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
public static final ChatOrigin EMPTY =
|
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 ----------------
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
@ -56,7 +76,7 @@ public record ChatOrigin(
|
|||||||
@Nullable String workspaceBasePath) {
|
@Nullable String workspaceBasePath) {
|
||||||
return new ChatOrigin(null, conversationId,
|
return new ChatOrigin(null, conversationId,
|
||||||
requesterId != null ? requesterId : "",
|
requesterId != null ? requesterId : "",
|
||||||
workspaceId, workspaceBasePath, null, null, false);
|
workspaceId, workspaceBasePath, null, null, false, null, "web", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ChatOrigin cron(@Nullable String conversationId,
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
@ -65,25 +85,42 @@ public record ChatOrigin(
|
|||||||
@Nullable Long channelId,
|
@Nullable Long channelId,
|
||||||
@Nullable ChannelTarget target) {
|
@Nullable ChannelTarget target) {
|
||||||
return new ChatOrigin(null, conversationId, "system",
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
workspaceId, workspaceBasePath, channelId, target, true);
|
workspaceId, workspaceBasePath, channelId, target, true, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Wither-style updates ----------------
|
// ---------------- Wither-style updates ----------------
|
||||||
|
|
||||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
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,
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
@Nullable String newWorkspaceBasePath) {
|
@Nullable String newWorkspaceBasePath) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
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) {
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
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 ----------------
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|||||||
@ -46,6 +46,27 @@ public final class RuntimeContextInjector {
|
|||||||
* 构建运行时上下文消息(i18n 版本)。
|
* 构建运行时上下文消息(i18n 版本)。
|
||||||
*/
|
*/
|
||||||
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService 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);
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
String dateStr = now.format(DATE_FMT);
|
String dateStr = now.format(DATE_FMT);
|
||||||
String timeStr = now.format(TIME_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.");
|
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendSenderBlockIfPresent(sb, origin);
|
||||||
return sb.toString();
|
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
|
// the previous tail-only retry path silently dropped the wiki
|
||||||
// segment which led to "answer regressed after compaction"
|
// segment which led to "answer regressed after compaction"
|
||||||
// complaints on long sessions.
|
// 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) {
|
if (conversationWindowManager != null) {
|
||||||
// Pass conversationId + workspaceBasePath so oversized older
|
// Pass conversationId + workspaceBasePath so oversized older
|
||||||
@ -709,10 +710,11 @@ public class ReasoningNode implements NodeAction {
|
|||||||
List<Message> buildNonHistoryPrefix(String systemPrompt,
|
List<Message> buildNonHistoryPrefix(String systemPrompt,
|
||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
String agentIdStr,
|
String agentIdStr,
|
||||||
String userMsg) {
|
String userMsg,
|
||||||
|
vip.mate.agent.context.ChatOrigin chatOrigin) {
|
||||||
List<Message> prefix = new ArrayList<>();
|
List<Message> prefix = new ArrayList<>();
|
||||||
prefix.add(new SystemMessage(systemPrompt));
|
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()) {
|
if (wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
Long parsedAgentId = Long.parseLong(agentIdStr);
|
Long parsedAgentId = Long.parseLong(agentIdStr);
|
||||||
|
|||||||
@ -148,7 +148,11 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
List<Message> promptMessages = new ArrayList<>();
|
List<Message> promptMessages = new ArrayList<>();
|
||||||
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
|
promptMessages.add(new SystemMessage(PLANNING_PROMPT));
|
||||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
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,
|
// 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
|
// but do NOT force "any tool usage implies multi-step" — single-hop tool use
|
||||||
|
|||||||
@ -494,8 +494,9 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。
|
8. 每一步最多做一个必要的检查和一个必要的执行,不要无意义循环。
|
||||||
""";
|
""";
|
||||||
messages.add(new SystemMessage(enhancedSystemPrompt));
|
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(对话历史 + 步骤结果的受控长度摘要)
|
// Layer 2: Working context(对话历史 + 步骤结果的受控长度摘要)
|
||||||
String workingContext = accessor.workingContext();
|
String workingContext = accessor.workingContext();
|
||||||
|
|||||||
@ -107,6 +107,17 @@ public final class PlanStateAccessor {
|
|||||||
return state.value(MateClawStateKeys.TRACE_ID, "");
|
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)=====
|
// ===== 会话消息(复用 MateClawStateKeys.MESSAGES)=====
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@ -257,4 +257,24 @@ public interface ChannelAdapter {
|
|||||||
? ChannelHealth.up(getChannelType(), null, java.time.Instant.now())
|
? ChannelHealth.up(getChannelType(), null, java.time.Instant.now())
|
||||||
: ChannelHealth.outOfService(getChannelType(), null);
|
: 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,
|
/* workspaceBasePath */ workspaceBasePath,
|
||||||
/* channelId */ channel.getId(),
|
/* channelId */ channel.getId(),
|
||||||
/* channelTarget */ target,
|
/* 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 合成并追加发送(先文本后语音,不阻塞)
|
// 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞)
|
||||||
maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity);
|
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 {
|
} finally {
|
||||||
@ -813,6 +822,12 @@ public class ChannelMessageRouter {
|
|||||||
maybeGenerateVoiceReply(message, streamingAdapter, replyTarget,
|
maybeGenerateVoiceReply(message, streamingAdapter, replyTarget,
|
||||||
conversationId, finalContent, channelEntity);
|
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;
|
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 不影响主流程
|
* 在新线程中执行,失败只 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() {
|
void roundTripThroughToolContext_preservesAllFields() {
|
||||||
ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001");
|
ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001");
|
||||||
ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L,
|
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();
|
ToolContext ctx = original.toToolContext();
|
||||||
ChatOrigin restored = ChatOrigin.from(ctx);
|
ChatOrigin restored = ChatOrigin.from(ctx);
|
||||||
@ -75,7 +75,7 @@ class ChatOriginTest {
|
|||||||
void jsonSerialization_isStableAndForwardCompatible() throws Exception {
|
void jsonSerialization_isStableAndForwardCompatible() throws Exception {
|
||||||
ObjectMapper om = new ObjectMapper();
|
ObjectMapper om = new ObjectMapper();
|
||||||
ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L,
|
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);
|
String json = om.writeValueAsString(origin);
|
||||||
ChatOrigin restored = om.readValue(json, ChatOrigin.class);
|
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",
|
"you are a helpful assistant",
|
||||||
"/workspace/active",
|
"/workspace/active",
|
||||||
"42",
|
"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.
|
// Three layers: System, runtime-context UserMessage, wiki UserMessage.
|
||||||
assertThat(prefix).hasSize(3);
|
assertThat(prefix).hasSize(3);
|
||||||
@ -72,9 +73,11 @@ class ReasoningNodePtlPromptTest {
|
|||||||
ReasoningNode node = newNode(wikiContextService);
|
ReasoningNode node = newNode(wikiContextService);
|
||||||
|
|
||||||
List<Message> a = node.buildNonHistoryPrefix(
|
List<Message> a = node.buildNonHistoryPrefix(
|
||||||
"sys", "/workspace", "42", "goal");
|
"sys", "/workspace", "42", "goal",
|
||||||
|
vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
List<Message> b = node.buildNonHistoryPrefix(
|
List<Message> b = node.buildNonHistoryPrefix(
|
||||||
"sys", "/workspace", "42", "goal");
|
"sys", "/workspace", "42", "goal",
|
||||||
|
vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
|
||||||
assertThat(a).hasSameSizeAs(b);
|
assertThat(a).hasSameSizeAs(b);
|
||||||
for (int i = 0; i < a.size(); i++) {
|
for (int i = 0; i < a.size(); i++) {
|
||||||
@ -94,7 +97,8 @@ class ReasoningNodePtlPromptTest {
|
|||||||
"you are a helpful assistant",
|
"you are a helpful assistant",
|
||||||
"/workspace/active",
|
"/workspace/active",
|
||||||
"42",
|
"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).hasSize(2);
|
||||||
assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class);
|
assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class);
|
||||||
@ -107,7 +111,8 @@ class ReasoningNodePtlPromptTest {
|
|||||||
ReasoningNode node = newNode(wikiContextService);
|
ReasoningNode node = newNode(wikiContextService);
|
||||||
|
|
||||||
List<Message> prefix = node.buildNonHistoryPrefix(
|
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
|
// Non-numeric agentId is the contract carried over from the
|
||||||
// pre-refactor codebase — skip wiki injection rather than throwing.
|
// pre-refactor codebase — skip wiki injection rather than throwing.
|
||||||
@ -126,7 +131,8 @@ class ReasoningNodePtlPromptTest {
|
|||||||
ReasoningNode node = newNode(wikiContextService);
|
ReasoningNode node = newNode(wikiContextService);
|
||||||
|
|
||||||
List<Message> prefix = node.buildNonHistoryPrefix(
|
List<Message> prefix = node.buildNonHistoryPrefix(
|
||||||
"sys", "/workspace", "42", "goal");
|
"sys", "/workspace", "42", "goal",
|
||||||
|
vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
|
||||||
assertThat(prefix).hasSize(2);
|
assertThat(prefix).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,10 @@ class ApprovalReplayContinuityTest {
|
|||||||
/* workspaceBasePath */ "/data/ws/5",
|
/* workspaceBasePath */ "/data/ws/5",
|
||||||
/* channelId */ 9L,
|
/* channelId */ 9L,
|
||||||
/* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001"),
|
/* 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);
|
String json = objectMapper.writeValueAsString(original);
|
||||||
ChatOrigin restored = workflow.restoreChatOrigin(json);
|
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(
|
ChatOrigin channelOrigin = new ChatOrigin(
|
||||||
7L, "cron_7", "system", 1L, null,
|
7L, "cron_7", "system", 1L, null,
|
||||||
/* channelId */ 9L, new ChannelTarget("group-a", null, 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);
|
String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin);
|
||||||
|
|
||||||
assertTrue(prompt.contains("[定时任务执行说明]"));
|
assertTrue(prompt.contains("[定时任务执行说明]"));
|
||||||
|
|||||||
@ -195,7 +195,7 @@ class DelegateAsyncTaskOutputAttributionTest {
|
|||||||
|
|
||||||
private ToolContext makeCtx(String requester, String conversationId) {
|
private ToolContext makeCtx(String requester, String conversationId) {
|
||||||
ChatOrigin origin = new ChatOrigin(
|
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<String, Object> map = new HashMap<>();
|
||||||
map.put(ChatOrigin.CTX_KEY, origin);
|
map.put(ChatOrigin.CTX_KEY, origin);
|
||||||
return new ToolContext(map);
|
return new ToolContext(map);
|
||||||
|
|||||||
@ -339,7 +339,7 @@ class DelegateAsyncToolTest {
|
|||||||
|
|
||||||
private ToolContext makeCtx(String requester, String conversationId) {
|
private ToolContext makeCtx(String requester, String conversationId) {
|
||||||
ChatOrigin origin = new ChatOrigin(
|
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<String, Object> map = new HashMap<>();
|
||||||
map.put(ChatOrigin.CTX_KEY, origin);
|
map.put(ChatOrigin.CTX_KEY, origin);
|
||||||
return new ToolContext(map);
|
return new ToolContext(map);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user