From 08c2d8cf5b36857cca7bb0ce85d25b4971c8fad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 23 Jun 2026 19:20:03 +0800 Subject: [PATCH] fix(desktop): separate goal continuation turns Emit a visible Goal continuing local-command marker before managed /goal continuations, then preserve it through server events, session history, and desktop history restore. Render the marker as a compact divider so a new assistant continuation after end_turn is visually separated from the previous assistant output. Related: #901 Tested: bun test src/query/stopHooks.test.ts src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/sessions.test.ts && cd desktop && bun run test MessageList.test.tsx chatStore.test.ts Tested: bun run check:server Tested: bun run check:desktop Not-tested: live release build and Windows v0.4.3 repro path; keep issue open until release validation. Scope-risk: moderate Confidence: high --- .../src/components/chat/MessageList.test.tsx | 38 +++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 29 +++++++++++++- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/jp.ts | 1 + desktop/src/i18n/locales/kr.ts | 1 + desktop/src/i18n/locales/zh-TW.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/stores/chatStore.test.ts | 21 ++++++++++ desktop/src/stores/chatStore.ts | 7 ++++ src/goals/goalState.ts | 1 + src/query/stopHooks.test.ts | 13 ++++++- src/query/stopHooks.ts | 23 +++++++++++ src/server/__tests__/sessions.test.ts | 15 ++++++++ src/server/__tests__/ws-memory-events.test.ts | 17 +++++++++ src/server/services/sessionService.ts | 1 + src/server/ws/handler.ts | 8 ++++ 16 files changed, 176 insertions(+), 2 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 7c471d60..2d626414 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -535,6 +535,44 @@ describe('MessageList nested tool calls', () => { expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy() }) + it('renders goal continuation status as a divider between assistant turns', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'assistant-1', + type: 'assistant_text', + content: '上一轮回答到这里。', + timestamp: 1, + }, + { + id: 'goal-continue', + type: 'goal_event', + action: 'status', + status: 'continuing', + message: 'Goal continuing: 还需要补充验证', + timestamp: 2, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: '后续轮次从这里开始。', + timestamp: 3, + }, + ], + }), + }, + }) + + render() + + expect(screen.getByTestId('goal-continuation-divider')).toBeTruthy() + expect(screen.getByText('Goal continuing')).toBeTruthy() + expect(screen.getByText('还需要补充验证')).toBeTruthy() + expect(screen.queryByText('Goal status')).toBeNull() + }) + it('renders non-agent background progress inline in the transcript', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index fca217cd..031e6fa3 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -300,6 +300,31 @@ function GoalEventCard({ message }: { message: GoalEvent }) { ) } +function GoalContinuationDivider({ message }: { message: GoalEvent }) { + const t = useTranslation() + const reason = message.message?.replace(/^Goal continuing:\s*/i, '').trim() + + return ( +
+
+
+ ) +} + function formatBackgroundTaskDuration(durationMs?: number) { if (typeof durationMs !== 'number' || durationMs < 0) return null const seconds = Math.round(durationMs / 1000) @@ -2210,7 +2235,9 @@ export const MessageBlock = memo(function MessageBlock({ case 'compact_summary': return case 'goal_event': - return + return message.action === 'status' && message.status === 'continuing' + ? + : case 'background_task': return case 'system': diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index f4c8644b..7a0b0710 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1190,6 +1190,7 @@ export const en = { 'chat.goalEvent.resumed': 'Goal resumed', 'chat.goalEvent.completed': 'Goal completed', 'chat.goalEvent.cleared': 'Goal cleared', + 'chat.goalEvent.continuing': 'Goal continuing', 'chat.goalEvent.message': 'Goal update', 'chat.goalEvent.objective': 'Objective: {value}', 'chat.goalEvent.statusValue': 'Status: {value}', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 87ba9495..4b8e5b01 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1192,6 +1192,7 @@ export const jp: Record = { 'chat.goalEvent.resumed': 'ゴールを再開しました', 'chat.goalEvent.completed': 'ゴールを完了しました', 'chat.goalEvent.cleared': 'ゴールをクリアしました', + 'chat.goalEvent.continuing': 'ゴールを継続中', 'chat.goalEvent.message': 'ゴールの更新', 'chat.goalEvent.objective': '目標: {value}', 'chat.goalEvent.statusValue': 'ステータス: {value}', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 365dcad6..27d2146d 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1192,6 +1192,7 @@ export const kr: Record = { 'chat.goalEvent.resumed': '목표 재개됨', 'chat.goalEvent.completed': '목표 완료됨', 'chat.goalEvent.cleared': '목표 지워짐', + 'chat.goalEvent.continuing': '목표 계속 진행 중', 'chat.goalEvent.message': '목표 업데이트', 'chat.goalEvent.objective': '목표: {value}', 'chat.goalEvent.statusValue': '상태: {value}', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 5cecd5ed..0c39a506 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1192,6 +1192,7 @@ export const zh: Record = { 'chat.goalEvent.resumed': '目標已恢復', 'chat.goalEvent.completed': '目標已完成', 'chat.goalEvent.cleared': '目標已清除', + 'chat.goalEvent.continuing': '目標繼續執行', 'chat.goalEvent.message': '目標更新', 'chat.goalEvent.objective': '目標:{value}', 'chat.goalEvent.statusValue': '狀態:{value}', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a4ceb905..4fdf736f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1192,6 +1192,7 @@ export const zh: Record = { 'chat.goalEvent.resumed': '目标已恢复', 'chat.goalEvent.completed': '目标已完成', 'chat.goalEvent.cleared': '目标已清除', + 'chat.goalEvent.continuing': '目标继续执行', 'chat.goalEvent.message': '目标更新', 'chat.goalEvent.objective': '目标:{value}', 'chat.goalEvent.statusValue': '状态:{value}', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index cf8671f0..c454255d 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -855,6 +855,27 @@ describe('chatStore history mapping', () => { ]) }) + it('restores /goal continuation markers from transcript history', () => { + const messages: MessageEntry[] = [ + { + id: 'goal-continuing', + type: 'system', + timestamp: '2026-04-06T00:00:02.000Z', + content: 'Goal continuing: verify the release path', + }, + ] + + expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'goal-continuing', + type: 'goal_event', + action: 'status', + status: 'continuing', + message: 'Goal continuing: verify the release path', + }, + ]) + }) + it('restores completed /goal state from transcript history after app restart', async () => { vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [ diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index deffc934..6ff059de 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -2719,6 +2719,13 @@ function parseGoalEventFromLocalCommandOutput( if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) return { action: 'cleared', message: trimmed } if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed } if (trimmed === 'No active goal.') return { action: 'message', message: trimmed } + if (trimmed.startsWith('Goal continuing:')) { + return { + action: 'status', + status: 'continuing', + message: trimmed, + } + } if (trimmed.startsWith('Goal set:')) { const objective = trimmed.slice('Goal set:'.length).trim() return { diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts index 07066640..0a95e239 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -223,6 +223,7 @@ function looksLikeGoalStatusOutput(output: string): boolean { const trimmed = output.trim() return ( trimmed.startsWith('Goal set:') || + trimmed.startsWith('Goal continuing:') || trimmed.startsWith('Goal cleared:') || trimmed === 'Goal cleared.' || trimmed === 'Goal marked complete.' || diff --git a/src/query/stopHooks.test.ts b/src/query/stopHooks.test.ts index d06b918e..a562af31 100644 --- a/src/query/stopHooks.test.ts +++ b/src/query/stopHooks.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { shouldLetGoalPromptHookContinue } from './stopHooks.js' +import { + formatGoalContinuationStatusOutput, + shouldLetGoalPromptHookContinue, +} from './stopHooks.js' describe('stop hook goal continuation', () => { test('converts unmet managed /goal prompt hooks into normal blocking continuation', () => { @@ -35,4 +38,12 @@ describe('stop hook goal continuation', () => { }), ).toBe(false) }) + + test('formats goal continuation status output for visible transcript separators', () => { + expect( + formatGoalContinuationStatusOutput( + 'Prompt hook condition was not met: finish & verify', + ), + ).toBe('Goal continuing: finish release verify') + }) }) diff --git a/src/query/stopHooks.ts b/src/query/stopHooks.ts index 8f102aa4..010fe633 100644 --- a/src/query/stopHooks.ts +++ b/src/query/stopHooks.ts @@ -78,6 +78,19 @@ export function shouldLetGoalPromptHookContinue( ) } +export function formatGoalContinuationStatusOutput(reason: string): string { + const normalizedReason = reason + .replace(/^Prompt hook condition was not met:\s*/i, '') + .replace(/[<>&]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 240) + + return normalizedReason + ? `Goal continuing: ${normalizedReason}` + : 'Goal continuing: more work is required' +} + export async function* handleStopHooks( messagesForQuery: Message[], assistantMessages: AssistantMessage[], @@ -221,6 +234,7 @@ export async function* handleStopHooks( const hookErrors: string[] = [] const hookInfos: StopHookInfo[] = [] let goalCompleted = false + let goalContinuationReason: string | null = null for await (const result of generator) { if (result.message) { @@ -283,6 +297,9 @@ export async function* handleStopHooks( } } if (result.blockingError) { + if (isGoalPromptHookCommand(result.blockingError.command)) { + goalContinuationReason ??= result.blockingError.blockingError + } const userMessage = createUserMessage({ content: getStopHookMessage(result.blockingError), isMeta: true, // Hide from UI (shown in summary message instead) @@ -358,6 +375,12 @@ export async function* handleStopHooks( ) } + if (goalContinuationReason) { + yield createCommandInputMessage( + `${formatGoalContinuationStatusOutput(goalContinuationReason)}`, + ) + } + if (preventedContinuation) { return { blockingErrors: [], preventContinuation: true } } diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 2c135a76..c214d5e1 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -972,6 +972,16 @@ describe('SessionService', () => { timestamp: '2026-01-01T00:00:02.000Z', uuid: 'goal-output', }, + { + parentUuid: 'goal-output', + isSidechain: false, + type: 'system', + subtype: 'local_command', + content: 'Goal continuing: verify persisted follow-up', + level: 'info', + timestamp: '2026-01-01T00:00:03.000Z', + uuid: 'goal-continuing', + }, makeAssistantEntry('正常助手消息', crypto.randomUUID()), ]) @@ -988,6 +998,11 @@ describe('SessionService', () => { type: 'system', content: expect.stringContaining('Goal set: ship persisted goal'), }, + { + id: 'goal-continuing', + type: 'system', + content: expect.stringContaining('Goal continuing: verify persisted follow-up'), + }, { type: 'assistant', }, diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index ed52e52f..9494334b 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -483,6 +483,23 @@ describe('WebSocket goal command events', () => { ]) }) + it('classifies /goal continuation output as a visible goal status event', () => { + const output = 'Goal continuing: finish release validation' + + expect(runGoalCommand(`goal-continue-${crypto.randomUUID()}`, 'ship docs', output)).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + message: output, + data: { + action: 'status', + status: 'continuing', + message: output, + }, + }), + ]) + }) + it('allows direct /goal local command output through the pre-turn mute gate', () => { const shouldForward = createCurrentTurnLocalCommandForwarder( parseSlashCommand('/goal ship the smoke test'), diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 3a46c109..cc20ccf4 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -928,6 +928,7 @@ export class SessionService { const trimmed = output.trim() return ( trimmed.startsWith('Goal set:') || + trimmed.startsWith('Goal continuing:') || trimmed.startsWith('Goal cleared:') || trimmed === 'Goal cleared.' || trimmed === 'Goal marked complete.' || diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index f4bf3fc8..e5774c35 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -2287,6 +2287,13 @@ function extractGoalEvent( if (trimmed === 'No active goal.') { return { action: 'message', message: trimmed } } + if (trimmed.startsWith('Goal continuing:')) { + return { + action: 'status', + status: 'continuing', + message: trimmed, + } + } if (trimmed.startsWith('Goal set:')) { const objective = trimmed.slice('Goal set:'.length).trim() @@ -2305,6 +2312,7 @@ function looksLikeGoalCommandOutput(output: string): boolean { const trimmed = output.trim() return ( trimmed.startsWith('Goal set:') || + trimmed.startsWith('Goal continuing:') || trimmed.startsWith('Goal cleared:') || trimmed === 'Goal cleared.' || trimmed === 'Goal marked complete.' ||