mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(cron): isolate scheduled-job runs from shared conversation (#142)
This commit is contained in:
parent
b4add8b139
commit
e647f1d6cf
@ -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 后调用,所以最后一条就是当前消息)
|
||||
|
||||
@ -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 ----------------
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -37,7 +37,8 @@ public class ChannelChatOriginFactory {
|
||||
/* workspaceId */ channel.getWorkspaceId(),
|
||||
/* workspaceBasePath */ workspaceBasePath,
|
||||
/* channelId */ channel.getId(),
|
||||
/* channelTarget */ target);
|
||||
/* channelTarget */ target,
|
||||
/* cronOrigin */ false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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=图片识别功能未启用
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user