diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index 17d46ee8..9b61bdcd 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -10,6 +10,7 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.agent.context.ChatOrigin; +import vip.mate.channel.web.ChatStreamTracker; import vip.mate.exception.MateClawException; import vip.mate.goal.config.GoalProperties; import vip.mate.goal.model.GoalCreateRequest; @@ -40,6 +41,7 @@ public class GoalManagementTool { private final GoalService goalService; private final GoalProperties properties; private final ObjectMapper objectMapper; + private final ChatStreamTracker streamTracker; @Tool(description = """ Set a persistent goal for the current conversation. The agent will \ @@ -89,6 +91,7 @@ public class GoalManagementTool { ? origin.requesterId() : "system"; try { GoalEntity created = goalService.create(req, username); + broadcastGoalEvent(created.getConversationId(), "goal_created", created); return successJson(Map.of( "goalId", String.valueOf(created.getId()), "status", created.getStatus().getValue(), @@ -119,6 +122,7 @@ public class GoalManagementTool { String username = resolveUsername(ctx); try { GoalEntity updated = goalService.appendCriterion(goal.getId(), criterion.trim(), username); + broadcastGoalEvent(updated.getConversationId(), "goal_updated", updated); return successJson(Map.of( "goalId", String.valueOf(updated.getId()), "exitCriteria", updated.getExitCriteria() == null ? "" : updated.getExitCriteria())); @@ -144,6 +148,14 @@ public class GoalManagementTool { true, "manual", 0, 0L); try { GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic); + // Broadcast a goal_completed event with the same shape as the + // GoalEvaluationNode auto-completed path, so the frontend + // handler doesn't need to branch on which path completed it. + if (streamTracker != null && completed.getConversationId() != null) { + streamTracker.broadcastObject(completed.getConversationId(), "goal_completed", Map.of( + "goalId", String.valueOf(completed.getId()), + "score", synthetic.score())); + } return successJson(Map.of( "goalId", String.valueOf(completed.getId()), "status", completed.getStatus().getValue())); @@ -203,6 +215,29 @@ public class GoalManagementTool { } } + /** + * Broadcast a goal-namespaced SSE event so the frontend store can + * refresh its active-goal cache without waiting for the user to + * reload. Best-effort: a missing stream (e.g. cron-origin tool call + * with no SSE subscriber) is not an error path. + */ + private void broadcastGoalEvent(String conversationId, String eventName, GoalEntity goal) { + if (streamTracker == null || conversationId == null || conversationId.isBlank()) { + return; + } + // Send the full goal payload so the store can hydrate without an + // extra GET round-trip. Long IDs are stringified at the wire by + // ToStringSerializer; the rest of the payload is plain JSON. + try { + streamTracker.broadcastObject(conversationId, eventName, Map.of( + "goalId", String.valueOf(goal.getId()), + "conversationId", conversationId, + "goal", goal)); + } catch (Exception e) { + log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage()); + } + } + private String errorJson(String message) { try { return objectMapper.writeValueAsString(Map.of( diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java index 9f6bbe60..800ab397 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -35,6 +35,7 @@ import static org.mockito.Mockito.when; class GoalManagementToolTest { @Mock private GoalService goalService; + @Mock private vip.mate.channel.web.ChatStreamTracker streamTracker; private GoalProperties properties; private GoalManagementTool tool; @@ -43,7 +44,7 @@ class GoalManagementToolTest { void setUp() { properties = new GoalProperties(); properties.setEnabled(true); - tool = new GoalManagementTool(goalService, properties, new ObjectMapper()); + tool = new GoalManagementTool(goalService, properties, new ObjectMapper(), streamTracker); } private ToolContext ctxWith(String convId, Long agentId, String requester) { diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 4483ee55..8ed603f7 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -1620,6 +1620,18 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (cid) goalStore.handleSseEvent(cid, 'goal_exhausted', data) }) + stream.on('goal_created', (data) => { + if (isStaleEvent(data)) return + const cid = data?.conversationId || streamConversationId + if (cid) goalStore.handleSseEvent(cid, 'goal_created', data) + }) + + stream.on('goal_updated', (data) => { + if (isStaleEvent(data)) return + const cid = data?.conversationId || streamConversationId + if (cid) goalStore.handleSseEvent(cid, 'goal_updated', data) + }) + // ===== Send message (supports sending while generating) ===== const sendMessage = async (content: string, options: SendMessageOptions) => { diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 1cda4b52..0dd61301 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -49,6 +49,11 @@ export type SSEEventType = | 'goal_followup' | 'goal_completed' | 'goal_exhausted' + // Tool-side goal mutations (GoalManagementTool) — emitted when the + // agent invokes setGoal / addGoalCriterion so the store can refresh + // without a full page reload. + | 'goal_created' + | 'goal_updated' // Stream lifecycle + per-iteration boundaries (single-turn UX overhaul). // The parser handles arbitrary `event:` lines via parseEvent — these names // exist in the union purely so TypeScript callers can register handlers diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts index b39773c7..50a3f5bd 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -151,6 +151,22 @@ export const useGoalStore = defineStore('goal', () => { activeGoalByConv.value[conversationId] = null break } + case 'goal_created': + case 'goal_updated': { + // Tool-side mutation from GoalManagementTool. Payload carries the + // full goal snapshot so we can hydrate the cache without a GET. + // Falls back to a fetch when the payload shape is unexpected so + // future server-side changes don't silently degrade the UX. + const fresh = data?.goal as Goal | undefined + if (fresh && typeof fresh.id === 'string') { + activeGoalByConv.value[conversationId] = fresh + } else { + // Best-effort refetch — runs async; don't await inside the + // synchronous SSE handler. + void loadActiveForConversation(conversationId) + } + break + } default: // Not a goal event — caller filters by prefix, this is a safety net. break