From 34175ab94dcc12c2cb2ad5c2d28b373d72d698b0 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 22:27:25 +0800 Subject: [PATCH] fix(goal,ui): guard evaluating flag against the message_complete race --- mateclaw-ui/src/composables/chat/useChat.ts | 11 +++++-- mateclaw-ui/src/stores/useGoalStore.ts | 32 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 60503ebc..97cc5dd0 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -546,12 +546,19 @@ export function useChat(options: UseChatOptions): UseChatReturn { // Goal-evaluator breathing halo: when an assistant message finishes // and this conversation has an active goal, the backend's evaluation // node runs next. Flip the per-conv flag so GoalAvatarRing paints the - // breathing halo until `goal_evaluated` resets it. Skip when no goal - // is active — the halo should be quiet for ordinary turns. + // breathing halo until `goal_evaluated` resets it. + // + // Skip when: + // - the conversation has no active goal (ordinary turn, no halo) + // - the evaluator already fired in this turn (SSE order under the + // structured stream is goal_evaluated → done → message_complete, + // so re-arming here would leave the halo stuck on after the + // evaluator already cleared it) if ( data.status === 'completed' && streamConversationId && goalStore.activeGoal(streamConversationId) + && !goalStore.recentlyEvaluated(streamConversationId) ) { goalStore.markEvaluating(streamConversationId, true) } diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts index d0650548..137adc5e 100644 --- a/mateclaw-ui/src/stores/useGoalStore.ts +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -54,6 +54,19 @@ export const useGoalStore = defineStore('goal', () => { // the message's `metadata.fromFollowup` flag (handled by ChatHistory). const followupMessageIdsByConv = ref>>({}) + // Timestamp (epoch ms) of the most recent terminal evaluator event for + // each conversation — used by useChat's message_complete handler to + // avoid re-setting the breathing-halo flag on the trailing edge of a + // turn whose evaluator already finished. Backend SSE order under the + // structured stream is goal_evaluated → done → message_complete, so + // without this guard the halo would re-light right after it cleared. + const lastTerminalEventAtByConv = ref>({}) + /** Window during which message_complete suppresses re-setting evaluating + * after a goal_evaluated / goal_completed / goal_exhausted just landed. + * 4s leaves headroom for SSE jitter without blocking a genuine next + * turn from re-arming the halo. */ + const TERMINAL_EVENT_SUPPRESSION_MS = 4000 + const loading = ref(false) async function loadActiveForConversation(conversationId: string) { @@ -156,6 +169,7 @@ export const useGoalStore = defineStore('goal', () => { switch (eventType) { case 'goal_evaluated': { evaluatingByConv.value[conversationId] = false + lastTerminalEventAtByConv.value[conversationId] = Date.now() if (goal && data?.score != null) { goal.completionScore = Number(data.score) } @@ -175,6 +189,7 @@ export const useGoalStore = defineStore('goal', () => { } case 'goal_completed': { evaluatingByConv.value[conversationId] = false + lastTerminalEventAtByConv.value[conversationId] = Date.now() // Capture the terminal snapshot BEFORE we null out the cache so // the GoalSystemLine has a title + score to render. recentTerminalByConv.value[conversationId] = { @@ -192,6 +207,7 @@ export const useGoalStore = defineStore('goal', () => { } case 'goal_exhausted': { evaluatingByConv.value[conversationId] = false + lastTerminalEventAtByConv.value[conversationId] = Date.now() recentTerminalByConv.value[conversationId] = { status: 'exhausted', title: goal?.title || '目标', @@ -228,6 +244,21 @@ export const useGoalStore = defineStore('goal', () => { evaluatingByConv.value[conversationId] = flag } + /** + * Returns true if a terminal evaluator event (goal_evaluated, + * goal_completed, goal_exhausted) arrived for this conversation + * within the suppression window — i.e. the evaluator has already + * finished this turn and the halo shouldn't be re-armed. + * Callers (useChat's message_complete handler) consult this before + * setting evaluating=true so the SSE order goal_evaluated → done → + * message_complete doesn't leave a permanently-lit ring. + */ + function recentlyEvaluated(conversationId: string): boolean { + const at = lastTerminalEventAtByConv.value[conversationId] + if (!at) return false + return Date.now() - at < TERMINAL_EVENT_SUPPRESSION_MS + } + function isEvaluating(conversationId: string): boolean { return Boolean(evaluatingByConv.value[conversationId]) } @@ -314,6 +345,7 @@ export const useGoalStore = defineStore('goal', () => { loadEvents, handleSseEvent, markEvaluating, + recentlyEvaluated, isEvaluating, activeGoal, progressFraction,