stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId);
- var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
+ var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
@@ -600,12 +649,12 @@ public class PlanGenerationNode implements NodeAction {
// answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks.
try {
- var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
- events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
+ var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
+ events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
- .planSteps(List.of(goal))
+ .planSteps(List.of(persistGoal))
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java
new file mode 100644
index 00000000..181561d7
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java
@@ -0,0 +1,76 @@
+package vip.mate.agent.graph.plan.node;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Pins {@link PlanGenerationNode#displayGoal} — the scrubber that recovers the
+ * user's actual request from the fully-assembled agent prompt before it is
+ * persisted as the plan goal.
+ *
+ * The graph receives the goal already enriched: a {@code }
+ * recall block is prepended every turn, scheduled runs wrap the instruction in a
+ * preamble whose payload follows {@code [任务指令]}, and a re-plan pass appends a
+ * {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan
+ * board showing "<memory-context> The following is what you…" instead of the
+ * task — these tests lock the clean-up.
+ */
+@DisplayName("PlanGeneration displayGoal scrubber")
+class PlanGenerationDisplayGoalTest {
+
+ private static final String MEMORY_WRAPPER =
+ "\n"
+ + "The following is what you already know about this user.\n"
+ + "## preferred_answer_style\n用户喜欢简洁、分点的回答方式。\n"
+ + "\n\n";
+
+ @Test
+ @DisplayName("strips the injected memory-context block")
+ void stripsMemoryContext() {
+ assertEquals("帮我读取 pom.xml 并总结依赖",
+ PlanGenerationNode.displayGoal(MEMORY_WRAPPER + "帮我读取 pom.xml 并总结依赖"));
+ }
+
+ @Test
+ @DisplayName("keeps only the instruction body of a scheduled-run wrapper")
+ void unwrapsScheduledRunPrompt() {
+ String cron = MEMORY_WRAPPER
+ + "[定时任务执行说明]\n本次对话由定时任务自动触发,不是用户实时发来的消息。\n"
+ + "- 请把下面的「任务指令」当作一个完整、独立的任务来执行。\n\n"
+ + "[任务指令]\nqwen3-max 重试测试";
+ assertEquals("qwen3-max 重试测试", PlanGenerationNode.displayGoal(cron));
+ }
+
+ @Test
+ @DisplayName("drops the trailing follow-up guidance block")
+ void dropsFollowupSuffix() {
+ String withFollowup = MEMORY_WRAPPER
+ + "整理本周的项目进展\n\n[Follow-up guidance]\n再补充一下风险项";
+ assertEquals("整理本周的项目进展", PlanGenerationNode.displayGoal(withFollowup));
+ }
+
+ @Test
+ @DisplayName("passes through a clean goal untouched")
+ void passesThroughCleanGoal() {
+ assertEquals("无包装直接问", PlanGenerationNode.displayGoal("无包装直接问"));
+ }
+
+ @Test
+ @DisplayName("falls back to the raw goal when scrubbing leaves nothing")
+ void fallsBackWhenEmpty() {
+ // A goal that is nothing but the recall block must not collapse to "".
+ String result = PlanGenerationNode.displayGoal(MEMORY_WRAPPER);
+ assertTrue(result.contains("memory-context"),
+ "scrubbing an all-wrapper goal should fall back to the raw text, not empty");
+ }
+
+ @Test
+ @DisplayName("null / blank safe")
+ void nullSafe() {
+ assertEquals("", PlanGenerationNode.displayGoal(null));
+ assertEquals(" ", PlanGenerationNode.displayGoal(" "));
+ }
+}
diff --git a/mateclaw-ui/src/components/agents/PlanBoard.vue b/mateclaw-ui/src/components/agents/PlanBoard.vue
index 515883dd..3a64e4ec 100644
--- a/mateclaw-ui/src/components/agents/PlanBoard.vue
+++ b/mateclaw-ui/src/components/agents/PlanBoard.vue
@@ -219,12 +219,23 @@ function planTs(p: Plan): number {
return p.createTime ? new Date(p.createTime).getTime() : 0
}
-// Drop the appended "[Follow-up guidance] ..." block so re-runs of one objective
-// share a title — and therefore a group.
+// Recover the user's actual request for display/grouping. Plans created before
+// the server-side scrub (and any not yet migrated) persisted the fully-assembled
+// prompt: a recall block, a scheduled-run wrapper whose payload
+// follows [任务指令], and a trailing [Follow-up guidance] block. Strip all three
+// so the card shows the task and re-runs of one objective share a group. Mirrors
+// the backend PlanGenerationNode.displayGoal scrubber; a no-op on clean goals.
function cleanGoal(goal?: string): string {
if (!goal) return ''
- const i = goal.indexOf('[Follow-up guidance]')
- return (i >= 0 ? goal.slice(0, i) : goal).trim()
+ let s = goal
+ .replace(/<\s*memory-context\s*>[\s\S]*?<\s*\/\s*memory-context\s*>/gi, '')
+ .replace(/<\/?\s*memory-context\s*>/gi, '')
+ const task = s.lastIndexOf('[任务指令]')
+ if (task >= 0) s = s.slice(task + '[任务指令]'.length)
+ const followup = s.indexOf('[Follow-up guidance]')
+ if (followup >= 0) s = s.slice(0, followup)
+ s = s.trim()
+ return s || goal.trim()
}
// Group a column's plans by cleaned goal, newest run first; groups ordered by
diff --git a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue
index fd075c77..8aad7c72 100644
--- a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue
+++ b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue
@@ -134,13 +134,22 @@ function statusLabel(status: string): string {
return t(`plans.col.${status}`, status)
}
-// The persisted goal can carry an appended "[Follow-up guidance] ..." block
-// (added when a goal follow-up re-enters planning). Strip it for display so the
-// title reads as the original task, not the internal re-prompt.
+// Recover the user's actual request for the title. Plans persisted before the
+// server-side scrub carry the fully-assembled prompt — a recall
+// block, a scheduled-run wrapper whose payload follows [任务指令], and a trailing
+// [Follow-up guidance] block. Strip all three so the title reads as the task.
+// Mirrors the backend PlanGenerationNode.displayGoal scrubber; a no-op on clean goals.
function cleanGoal(goal: string): string {
if (!goal) return ''
- const i = goal.indexOf('[Follow-up guidance]')
- return (i >= 0 ? goal.slice(0, i) : goal).trim()
+ let s = goal
+ .replace(/<\s*memory-context\s*>[\s\S]*?<\s*\/\s*memory-context\s*>/gi, '')
+ .replace(/<\/?\s*memory-context\s*>/gi, '')
+ const task = s.lastIndexOf('[任务指令]')
+ if (task >= 0) s = s.slice(task + '[任务指令]'.length)
+ const followup = s.indexOf('[Follow-up guidance]')
+ if (followup >= 0) s = s.slice(0, followup)
+ s = s.trim()
+ return s || goal.trim()
}
function letter(name?: string): string {