From e647f1d6cf36835642a5c84e2653cd590f8d4618 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 17 May 2026 07:58:10 +0800 Subject: [PATCH] fix(cron): isolate scheduled-job runs from shared conversation (#142) --- .../main/java/vip/mate/agent/BaseAgent.java | 40 ++++- .../vip/mate/agent/context/ChatOrigin.java | 19 ++- .../agent/graph/StateGraphReActAgent.java | 14 +- .../channel/ChannelChatOriginFactory.java | 3 +- .../cron/service/CronJobLifecycleService.java | 18 +- .../vip/mate/cron/service/CronJobRunner.java | 78 +++++---- .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_en.properties | 1 + .../agent/BaseAgentCronIsolationTest.java | 161 ++++++++++++++++++ .../mate/agent/context/ChatOriginTest.java | 19 ++- .../ApprovalReplayContinuityTest.java | 3 +- .../CronJobRunnerDeliveryGuardTest.java | 45 ----- .../cron/service/CronJobRunnerPromptTest.java | 58 +++++++ ...elegateAsyncTaskOutputAttributionTest.java | 2 +- .../tool/builtin/DelegateAsyncToolTest.java | 2 +- 15 files changed, 372 insertions(+), 92 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java delete mode 100644 mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index d7dfca00..61463018 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -11,6 +11,8 @@ import org.springframework.ai.content.Media; import org.springframework.core.io.FileSystemResource; import org.springframework.util.MimeType; import reactor.core.publisher.Flux; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; import vip.mate.approval.ApprovalPlaceholderUtil; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.routing.MediaCaptionService; @@ -119,6 +121,13 @@ public abstract class BaseAgent { /** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */ protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE; + /** + * Prefix of the system-role divider row a scheduled-job run writes into + * its conversation immediately before the run's user message. Used to + * (a) drop the divider when replaying history to the LLM and (b) locate + * the current run's start when isolating scheduled-job history. + */ + private static final String CRON_HEADER_PREFIX = "📋 "; protected BaseAgent(ChatClient chatClient, ConversationService conversationService) { this.chatClient = chatClient; @@ -221,6 +230,24 @@ public abstract class BaseAgent { } protected List buildConversationHistory(String conversationId, String currentUserMessage) { + // ===== Scheduled-job run isolation (issue #142) ===== + // A scheduled-job run is a one-shot task whose full instruction is + // passed explicitly via currentUserMessage. Its conversation — the + // shared per-workspace tasks_ log, or a per-job cron_ + // conversation — concatenates many independent runs; under concurrent + // runs their rows are not even adjacent (each startRun writes a header + // then a user row in its own transaction, and the inserts interleave). + // No positional reconstruction from that conversation is therefore + // safe. The LLM history is simply empty: the prompt is [system, task]. + // The gate is an explicit ChatOrigin signal, so a normal Web or + // channel turn can never take this path. + ChatOrigin chatOrigin = ChatOriginHolder.get(); + if (chatOrigin != null && chatOrigin.cronOrigin()) { + log.info("[{}] Scheduled-job run: LLM context isolated (no conversation history replayed)", + agentName); + return List.of(); + } + // ===== 两阶段加载:短对话全量,长对话分页(递进式) ===== long totalCount = conversationService.countMessages(conversationId); if (totalCount <= 0) { @@ -490,7 +517,7 @@ public abstract class BaseAgent { // and bloat the prompt with scheduler metadata. if ("system".equals(entity.getRole()) && entity.getContent() != null - && entity.getContent().startsWith("📋 ")) { + && entity.getContent().startsWith(CRON_HEADER_PREFIX)) { return null; } @@ -616,7 +643,7 @@ public abstract class BaseAgent { String role = entity.getRole(); if ("system".equals(role) && entity.getContent() != null - && entity.getContent().startsWith("📋 ")) return true; + && entity.getContent().startsWith(CRON_HEADER_PREFIX)) return true; if ("assistant".equals(role) && isApprovalPlaceholder(entity.getContent())) return true; if ("assistant".equals(role) @@ -1136,6 +1163,15 @@ public abstract class BaseAgent { * the primary model can't already handle. */ protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) { + // Scheduled-job run (issue #142): the task text is the explicit + // userMessageText argument. Never reconstruct it from the conversation + // — a shared cron conversation under concurrent runs has no reliable + // "last user message" (another run's row may be last). Scheduled jobs + // carry no attachments, so a plain text UserMessage is exact. + ChatOrigin chatOrigin = ChatOriginHolder.get(); + if (chatOrigin != null && chatOrigin.cronOrigin()) { + return new CurrentTurnUserMessage(new UserMessage(userMessageText), null); + } try { List history = conversationService.listMessages(conversationId); // 倒序取最后一条 user 消息(buildInitialState 在 saveMessage 后调用,所以最后一条就是当前消息) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 56c1a01e..2d503097 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -33,7 +33,12 @@ public record ChatOrigin( @Nullable Long workspaceId, @Nullable String workspaceBasePath, @Nullable Long channelId, - @Nullable ChannelTarget channelTarget + @Nullable ChannelTarget channelTarget, + // True only when the agent invocation was triggered by the scheduled-job + // 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 ) { /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ @@ -41,7 +46,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); + new ChatOrigin(null, null, "", null, null, null, null, false); // ---------------- Factories per entry point ---------------- @@ -51,7 +56,7 @@ public record ChatOrigin( @Nullable String workspaceBasePath) { return new ChatOrigin(null, conversationId, requesterId != null ? requesterId : "", - workspaceId, workspaceBasePath, null, null); + workspaceId, workspaceBasePath, null, null, false); } public static ChatOrigin cron(@Nullable String conversationId, @@ -60,25 +65,25 @@ public record ChatOrigin( @Nullable Long channelId, @Nullable ChannelTarget target) { return new ChatOrigin(null, conversationId, "system", - workspaceId, workspaceBasePath, channelId, target); + workspaceId, workspaceBasePath, channelId, target, true); } // ---------------- Wither-style updates ---------------- public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, - workspaceId, workspaceBasePath, channelId, channelTarget); + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, - newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget); + newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, - workspaceId, workspaceBasePath, channelId, channelTarget); + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 79012837..d6e22e00 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -88,7 +88,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId); Map inputs = buildInitialState(userMessage, conversationId); - Optional result = compiledGraph.invoke(inputs); + // Fresh thread per invocation so graph state never carries over + // between calls. The CompiledGraph is cached and shared; without a + // unique threadId, consecutive sync runs (e.g. back-to-back cron + // executions) inherit the prior run's accumulated messages and + // counters. Mirrors the streaming paths, which already do this. + RunnableConfig config = RunnableConfig.builder() + .threadId(UUID.randomUUID().toString()).build(); + Optional result = compiledGraph.invoke(inputs, config); return result .flatMap(s -> s.value(FINAL_ANSWER)) @@ -147,7 +154,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (toolCallPayload != null && !toolCallPayload.isEmpty()) { inputs.put(FORCED_TOOL_CALL, toolCallPayload); } - Optional result = compiledGraph.invoke(inputs); + // Fresh thread per invocation — see chat() for rationale. + RunnableConfig config = RunnableConfig.builder() + .threadId(UUID.randomUUID().toString()).build(); + Optional result = compiledGraph.invoke(inputs, config); return result .flatMap(s -> s.value(FINAL_ANSWER)) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java index 69877f52..b22c334c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java @@ -37,7 +37,8 @@ public class ChannelChatOriginFactory { /* workspaceId */ channel.getWorkspaceId(), /* workspaceBasePath */ workspaceBasePath, /* channelId */ channel.getId(), - /* channelTarget */ target); + /* channelTarget */ target, + /* cronOrigin */ false); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java index 5cc49899..fcb01f50 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java @@ -168,11 +168,16 @@ public class CronJobLifecycleService { * {@code @TransactionalEventListener(AFTER_COMMIT)} listeners only run * once this method's tx commits, so cross-connection reads in the * delivery / memory pipelines always see the final state. + * + * @param silent {@code true} when the agent returned the no-op sentinel — + * the run succeeded but produced nothing to report. A short + * marker message is persisted for conversation coherence, + * and both the delivery and memory pipelines are skipped. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run, String userMessage, AssistantMessage result, - String conversationId) { + String conversationId, boolean silent) { String convId = conversationId != null ? conversationId : run.getConversationId(); String text = result != null && result.getText() != null ? result.getText() : ""; @@ -181,6 +186,17 @@ public class CronJobLifecycleService { .set(CronJobRunEntity::getStatus, "succeeded") .set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())); + if (silent) { + // No-op run: persist a short marker so the tasks_ + // conversation keeps a coherent user -> assistant pairing, then + // return without delivery or memory extraction — there is no + // real content to deliver or to learn from. + String marker = i18n != null ? i18n.msg("cron.run.silent") + : "(本次定时任务无新内容,已跳过)"; + conversationService.saveMessage(convId, "assistant", marker); + return; + } + conversationService.saveMessage(convId, "assistant", text); // Memory pipeline (existing behavior preserved — was inline in the diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index bec4e049..9b3f453d 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -48,6 +48,15 @@ public class CronJobRunner { private final WikiProcessingService wikiProcessingService; private final ObjectMapper objectMapper; + /** + * Sentinel a scheduled-job run returns when it determines there is + * nothing to do or report. {@link #buildCronPrompt} instructs the model + * to reply with exactly this string; {@code executeJob} then finishes the + * run without delivering anything (an explicit, per-run no-op decision — + * distinct from the static per-job {@code suppressAgentReply} flag). + */ + static final String CRON_SILENT_MARKER = "[SILENT]"; + /** * Scheduler-facing entry. Runs three logical segments: *
    @@ -113,7 +122,7 @@ public class CronJobRunner { && !job.getTriggerMessage().isBlank()) { try { AssistantMessage direct = new AssistantMessage(job.getTriggerMessage()); - lifecycle.finishRunAndPublish(job, run, userMessage, direct, conversationId); + lifecycle.finishRunAndPublish(job, run, userMessage, direct, conversationId, false); } catch (Exception e) { log.error("[CronRunner] reminder direct-push failed for job {}: {}", job.getId(), e.getMessage(), e); @@ -145,9 +154,14 @@ public class CronJobRunner { return; } + // Explicit no-op: the agent answered with the silent sentinel, + // meaning there is nothing to deliver or report for this run. + boolean silent = result != null && result.getText() != null + && CRON_SILENT_MARKER.equals(result.getText().trim()); + // T2 — short tx try { - lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId); + lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId, silent); } catch (Exception e) { log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e); try { @@ -244,43 +258,49 @@ public class CronJobRunner { } /** - * Runs the agent with the cron-derived {@link ChatOrigin} and the - * RFC-063r §2.13 system-prompt guard prepended when the cron is bound to - * a channel — fixes the Issue #25 LLM hallucination ("install - * mateclaw cli to send to wechat") by telling the model that delivery is - * framework-handled. + * Runs the agent with the scheduled-job {@link ChatOrigin} and the + * execution-context prompt assembled by {@link #buildCronPrompt}. */ private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin, String conversationId) { - String guarded = wrapWithDeliveryGuard(userMessage, origin); + String prompt = buildCronPrompt(userMessage, origin); String text = "agent".equals(job.getTaskType()) - ? agentService.execute(job.getAgentId(), guarded, conversationId, origin) - : agentService.chat(job.getAgentId(), guarded, conversationId, origin); + ? agentService.execute(job.getAgentId(), prompt, conversationId, origin) + : agentService.chat(job.getAgentId(), prompt, conversationId, origin); return new AssistantMessage(text != null ? text : ""); } /** - * RFC-063r §2.13: when the cron is bound to a channel, prepend an - * explicit system note telling the LLM that delivery is handled by the - * framework. Without this, the model invents tools ("call CLI to send - * to wechat") and surfaces "command not found" style errors to users - * (Issue #25 second symptom). - * - *

    Web-origin crons (no channelId) bypass the wrapper so the - * pre-RFC behavior is preserved. + * Assemble the prompt for a scheduled-job run. An execution-context note + * is always prepended so the model behaves as a scheduled task rather + * than as a reply to a live user message: + *

      + *
    • the task is self-contained and runs in isolation — no prior + * conversation history is in scope, so the model must not assume + * earlier context;
    • + *
    • (channel-bound runs only) delivery back to the originating + * channel is framework-handled, so the model must not invent + * CLI / shell / "send to WeChat" tool calls to deliver the result;
    • + *
    • when there is genuinely nothing to do or report, the model + * should reply with exactly {@link #CRON_SILENT_MARKER} and nothing + * else, which suppresses delivery for this run.
    • + *
    */ - static String wrapWithDeliveryGuard(String userMessage, ChatOrigin origin) { + static String buildCronPrompt(String userMessage, ChatOrigin origin) { String body = userMessage != null ? userMessage : ""; - if (origin == null || origin.channelId() == null) { - return body; + boolean channelBound = origin != null && origin.channelId() != null; + StringBuilder sb = new StringBuilder(); + sb.append("[定时任务执行说明]\n"); + sb.append("本次对话由定时任务自动触发,不是用户实时发来的消息。\n"); + sb.append("- 请把下面的「任务指令」当作一个完整、独立的任务来执行;") + .append("本次为隔离执行,没有此前的对话历史,不要假设存在上下文。\n"); + if (channelBound) { + sb.append("- 执行结果会由系统自动投递回原渠道,你只需直接给出最终结果内容,") + .append("不要尝试调用 CLI / shell / \"发送到微信\"等工具自行投递。\n"); } - return """ - [系统说明] - 本次执行由定时任务触发,结果将由系统自动投递回原渠道, - 你只需直接给出最终回复内容,不要尝试调用 CLI / shell / - "发送到微信"等工具——这些操作由框架完成。 - - [用户原始消息] - """ + body; + sb.append("- 如果确认本次确实无需执行、也没有新内容可汇报,") + .append("请仅回复 \"").append(CRON_SILENT_MARKER).append("\",不要附加任何其它文字。\n\n"); + sb.append("[任务指令]\n").append(body); + return sb.toString(); } } diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 19047d27..68211c56 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -281,6 +281,7 @@ agent.limit_exceeded.empty_context=\uff08\u5c1a\u672a\u6536\u96c6\u5230\u5de5\u5 cron.tasks_conversation.title=📋 定时任务 cron.run_header.scheduled=定时触发 cron.run_header.manual=手动触发 +cron.run.silent=(本次定时任务检查后无新内容,已跳过投递) # --- Wiki vision-in pipeline --- err.wiki.vision.disabled=图片识别功能未启用 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 1a1a750a..cb496fcd 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -288,6 +288,7 @@ agent.limit_exceeded.empty_context=(No tool call results collected yet.) cron.tasks_conversation.title=📋 Scheduled Tasks cron.run_header.scheduled=scheduled cron.run_header.manual=manual +cron.run.silent=(Scheduled task found nothing new this run — delivery skipped) # --- Wiki vision-in pipeline --- err.wiki.vision.disabled=Image vision pipeline is currently disabled diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java new file mode 100644 index 00000000..bdc88a6a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java @@ -0,0 +1,161 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #142 regression: a scheduled-job run must see only its own task in + * the LLM prompt — never the prior or concurrent runs that share its + * {@code tasks_} (web-origin) or {@code cron_} (per-job) conversation. + * + *

    A cron run's instruction is passed explicitly through the call chain, so + * the runtime never reconstructs it from the shared conversation: history is + * empty and the current message is the explicit argument. This holds even + * when concurrent runs interleave their rows into that conversation. A normal + * Web / channel turn is unaffected — it still loads full history. + */ +class BaseAgentCronIsolationTest { + + @AfterEach + void clearOrigin() { + ChatOriginHolder.clear(); + } + + @Test + @DisplayName("cron run: empty LLM history even when the conversation holds interleaved concurrent-run rows") + void cronRun_emptyHistory_evenWithInterleavedRows() { + ConversationService conv = mock(ConversationService.class); + // The shared tasks_1 after three concurrent runs: headers, user rows + // and assistant rows each cluster together — NOT adjacent per run, + // because each startRun commits in its own interleaving transaction. + List contaminated = List.of( + sys("📋 job-A · 定时触发"), sys("📋 job-B · 定时触发"), sys("📋 job-C · 定时触发"), + user("job A task"), user("job B task"), user("job C task"), + assistant("A result"), assistant("B result"), assistant("C result")); + when(conv.countMessages(any())).thenReturn((long) contaminated.size()); + when(conv.listMessages(any())).thenReturn(contaminated); + stubRender(conv); + + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.cron("tasks_1", 1L, null, null, null)); + + List history = agent.history("tasks_1", "job A task"); + + assertTrue(history.isEmpty(), + "a cron run must replay no conversation history at all"); + } + + @Test + @DisplayName("cron run: current message is the explicit argument, never a conversation-guessed last user row") + void cronRun_currentMessage_usesExplicitArgument() { + ConversationService conv = mock(ConversationService.class); + // The conversation's LAST user row belongs to a DIFFERENT concurrent + // run — the pre-fix code reconstructed the current message from it. + when(conv.listMessages(any())).thenReturn(List.of( + sys("📋 job-A"), sys("📋 job-B"), + user("job A task"), user("job B task — WRONG for this run"))); + stubRender(conv); + + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.cron("tasks_1", 1L, null, null, null)); + + String current = agent.currentMessage("tasks_1", "job A task — the real one"); + + assertEquals("job A task — the real one", current, + "a cron run must use its own explicit task text, not the conversation's last user row"); + } + + @Test + @DisplayName("normal turn: full history kept even when the conversation holds scheduled-job records") + void nonCronTurn_keepsFullHistory() { + ConversationService conv = mock(ConversationService.class); + List stored = List.of( + user("早上好"), assistant("你好,有什么可以帮你"), user("现在几点")); + when(conv.countMessages("conv_x")).thenReturn((long) stored.size()); + when(conv.listMessages("conv_x")).thenReturn(stored); + stubRender(conv); + + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.web("conv_x", "u1", 1L, null)); + + List history = agent.history("conv_x", "现在几点"); + + assertEquals(2, history.size(), + "a normal turn keeps prior history (the trailing current user row is de-duplicated)"); + } + + // ---------- scaffold ---------- + + private static TestAgent newAgent(ConversationService conv) { + TestAgent agent = new TestAgent(conv); + agent.agentName = "test-agent"; + agent.modelName = "test-model"; + return agent; + } + + private static void stubRender(ConversationService conv) { + when(conv.renderMessageContent(any())).thenAnswer( + inv -> ((MessageEntity) inv.getArgument(0)).getContent()); + } + + private static MessageEntity row(String role, String content) { + MessageEntity m = new MessageEntity(); + m.setRole(role); + m.setContent(content); + return m; + } + + private static MessageEntity sys(String content) { + return row("system", content); + } + + private static MessageEntity user(String content) { + return row("user", content); + } + + private static MessageEntity assistant(String content) { + return row("assistant", content); + } + + /** Minimal concrete BaseAgent fixture exposing the protected builders. */ + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conv) { + super(null, conv); + } + + List history(String conversationId, String currentUserMessage) { + return buildConversationHistory(conversationId, currentUserMessage); + } + + String currentMessage(String conversationId, String userMessageText) { + return buildCurrentUserMessageWithRouting(conversationId, userMessageText) + .userMessage().getText(); + } + + @Override public String chat(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override public reactor.core.publisher.Flux chatStream(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override public String execute(String goal, String conversationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index d84fde7b..9ed90801 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -28,7 +28,7 @@ class ChatOriginTest { void roundTripThroughToolContext_preservesAllFields() { ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, target); + "/data/ws/5", 9L, target, false); ToolContext ctx = original.toToolContext(); ChatOrigin restored = ChatOrigin.from(ctx); @@ -56,11 +56,26 @@ class ChatOriginTest { assertNull(origin.agentId(), "agentId is enriched later by BaseAgent"); } + @Test + void cronOriginFlag_setByFactoryAndPreservedByWithers() { + ChatOrigin cron = ChatOrigin.cron("cron_7", 1L, null, 3L, null); + assertTrue(cron.cronOrigin(), "cron() factory must flag the origin as a cron run"); + assertTrue(cron.withAgent(9L).cronOrigin(), "withAgent must preserve cronOrigin"); + assertTrue(cron.withConversationId("tasks_1").cronOrigin(), + "withConversationId must preserve cronOrigin"); + assertTrue(cron.withWorkspace(2L, "/ws").cronOrigin(), + "withWorkspace must preserve cronOrigin"); + + assertFalse(ChatOrigin.web("conv_1", "u1", 1L, null).cronOrigin(), + "web() origin must not be flagged as a cron run"); + assertFalse(ChatOrigin.EMPTY.cronOrigin(), "EMPTY must not be flagged as a cron run"); + } + @Test 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")); + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java index 19d99525..bd9e973d 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -40,7 +40,8 @@ class ApprovalReplayContinuityTest { /* workspaceId */ 5L, /* workspaceBasePath */ "/data/ws/5", /* channelId */ 9L, - /* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001")); + /* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001"), + /* cronOrigin */ false); String json = objectMapper.writeValueAsString(original); ChatOrigin restored = workflow.restoreChatOrigin(json); diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java deleted file mode 100644 index 8d633db7..00000000 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package vip.mate.cron.service; - -import org.junit.jupiter.api.Test; -import vip.mate.agent.context.ChannelTarget; -import vip.mate.agent.context.ChatOrigin; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * RFC-063r §2.13 (Issue #25 — second symptom): - * {@link CronJobRunner#wrapWithDeliveryGuard} must prepend a system note - * for channel-bound cron runs and pass through web-origin runs unchanged. - */ -class CronJobRunnerDeliveryGuardTest { - - @Test - void channelBoundCron_prependsDeliveryGuard() { - ChatOrigin channelOrigin = new ChatOrigin( - /* agentId */ 7L, "cron_7", "system", 1L, null, - /* channelId */ 9L, new ChannelTarget("group-a", null, null)); - String input = "提醒我喝水并发到微信"; - String wrapped = CronJobRunner.wrapWithDeliveryGuard(input, channelOrigin); - - assertTrue(wrapped.contains("[系统说明]"), - "Channel-bound cron must include system note (RFC-063r §2.13)"); - assertTrue(wrapped.contains("不要尝试调用 CLI"), - "system note must explicitly forbid CLI hallucination"); - assertTrue(wrapped.endsWith(input), - "user message must be appended after the system note"); - } - - @Test - void webOriginCron_passesThroughUnchanged() { - ChatOrigin webOrigin = ChatOrigin.web("cron_1", "system", 1L, null); - String input = "Daily wiki update"; - assertEquals(input, CronJobRunner.wrapWithDeliveryGuard(input, webOrigin), - "web-origin cron must keep pre-RFC behavior"); - } - - @Test - void emptyOrigin_passesThroughUnchanged() { - assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", ChatOrigin.EMPTY)); - assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", null)); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java new file mode 100644 index 00000000..bcc9ee28 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -0,0 +1,58 @@ +package vip.mate.cron.service; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.context.ChannelTarget; +import vip.mate.agent.context.ChatOrigin; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link CronJobRunner#buildCronPrompt} assembles the scheduled-job prompt: + * the execution-context note is always prepended, the channel-delivery clause + * appears only for channel-bound runs, and the no-op sentinel instruction is + * always present so the agent can explicitly skip a run. + */ +class CronJobRunnerPromptTest { + + @Test + void webOriginCron_prependsContextNote_withoutDeliveryClause() { + ChatOrigin webOrigin = ChatOrigin.web("tasks_1", "system", 1L, null); + String input = "汇总今天的科技新闻"; + String prompt = CronJobRunner.buildCronPrompt(input, webOrigin); + + assertTrue(prompt.contains("[定时任务执行说明]"), + "every scheduled run must carry the execution-context note"); + assertTrue(prompt.contains("隔离执行"), + "the note must tell the model this run has no prior history"); + assertFalse(prompt.contains("投递回原渠道"), + "web-origin runs have no channel — the delivery clause must be omitted"); + assertTrue(prompt.contains(CronJobRunner.CRON_SILENT_MARKER), + "the no-op sentinel instruction must always be present"); + assertTrue(prompt.endsWith(input), + "the task instruction must be the tail of the prompt"); + } + + @Test + void channelBoundCron_addsDeliveryClause() { + ChatOrigin channelOrigin = new ChatOrigin( + 7L, "cron_7", "system", 1L, null, + /* channelId */ 9L, new ChannelTarget("group-a", null, null), + /* cronOrigin */ true); + String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); + + assertTrue(prompt.contains("[定时任务执行说明]")); + assertTrue(prompt.contains("投递回原渠道"), + "channel-bound runs must keep the framework-delivery clause"); + assertTrue(prompt.contains("不要尝试调用 CLI"), + "the channel clause must forbid CLI / send-tool hallucination"); + } + + @Test + void nullOrigin_stillProducesContextNote() { + String prompt = CronJobRunner.buildCronPrompt("hello", null); + assertTrue(prompt.contains("[定时任务执行说明]")); + assertFalse(prompt.contains("投递回原渠道")); + assertTrue(prompt.endsWith("hello")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java index ae60d63f..3f03d41c 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -195,7 +195,7 @@ class DelegateAsyncTaskOutputAttributionTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null); + 1L, conversationId, requester, null, null, null, null, false); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index 719928a7..090fa8e5 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -339,7 +339,7 @@ class DelegateAsyncToolTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null); + 1L, conversationId, requester, null, null, null, null, false); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map);