fix(cron): isolate scheduled-job runs from shared conversation (#142)

This commit is contained in:
matevip 2026-05-17 07:58:10 +08:00
parent b4add8b139
commit e647f1d6cf
15 changed files with 372 additions and 92 deletions

View File

@ -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<Message> 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_<wsId> log, or a per-job cron_<id>
// 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<MessageEntity> history = conversationService.listMessages(conversationId);
// 倒序取最后一条 user 消息buildInitialState saveMessage 后调用所以最后一条就是当前消息

View File

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

View File

@ -88,7 +88,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
Optional<OverAllState> 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<OverAllState> result = compiledGraph.invoke(inputs, config);
return result
.flatMap(s -> s.<String>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<OverAllState> result = compiledGraph.invoke(inputs);
// Fresh thread per invocation see chat() for rationale.
RunnableConfig config = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString()).build();
Optional<OverAllState> result = compiledGraph.invoke(inputs, config);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))

View File

@ -37,7 +37,8 @@ public class ChannelChatOriginFactory {
/* workspaceId */ channel.getWorkspaceId(),
/* workspaceBasePath */ workspaceBasePath,
/* channelId */ channel.getId(),
/* channelTarget */ target);
/* channelTarget */ target,
/* cronOrigin */ false);
}
/**

View File

@ -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_<wsId>
// 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

View File

@ -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:
* <ol>
@ -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).
*
* <p>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:
* <ul>
* <li>the task is self-contained and runs in isolation no prior
* conversation history is in scope, so the model must not assume
* earlier context;</li>
* <li>(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;</li>
* <li>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.</li>
* </ul>
*/
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();
}
}

View File

@ -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=图片识别功能未启用

View File

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

View File

@ -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_<wsId>} (web-origin) or {@code cron_<id>} (per-job) conversation.
*
* <p>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<MessageEntity> 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<Message> 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<MessageEntity> 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<Message> 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<Message> 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<String> chatStream(String userMessage, String conversationId) {
throw new UnsupportedOperationException();
}
@Override public String execute(String goal, String conversationId) {
throw new UnsupportedOperationException();
}
}
}

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);
"/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);

View File

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

View File

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

View File

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

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);
1L, conversationId, requester, null, null, null, null, false);
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);
1L, conversationId, requester, null, null, null, null, false);
Map<String, Object> map = new HashMap<>();
map.put(ChatOrigin.CTX_KEY, origin);
return new ToolContext(map);