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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-23 19:20:03 +08:00
parent 96ce12d84b
commit 08c2d8cf5b
16 changed files with 176 additions and 2 deletions

View File

@ -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(<MessageList />)
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: {

View File

@ -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 (
<section data-testid="goal-continuation-divider" className="my-4 w-full px-1">
<div className="flex w-full items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
<div className="inline-flex min-h-8 max-w-[min(78vw,620px)] items-center gap-2 rounded-md px-2.5 py-1 text-[13px] font-medium text-[var(--color-text-secondary)]">
<Target size={16} strokeWidth={2.1} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
{t('chat.goalEvent.continuing')}
</span>
{reason ? (
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]" title={reason}>
{reason}
</span>
) : null}
</div>
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
</div>
</section>
)
}
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 <CompactStatusDivider message={message} state={message.phase === 'compacting' ? 'compacting' : 'complete'} />
case 'goal_event':
return <GoalEventCard message={message} />
return message.action === 'status' && message.status === 'continuing'
? <GoalContinuationDivider message={message} />
: <GoalEventCard message={message} />
case 'background_task':
return <BackgroundTaskEventCard message={message} />
case 'system':

View File

@ -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}',

View File

@ -1192,6 +1192,7 @@ export const jp: Record<TranslationKey, string> = {
'chat.goalEvent.resumed': 'ゴールを再開しました',
'chat.goalEvent.completed': 'ゴールを完了しました',
'chat.goalEvent.cleared': 'ゴールをクリアしました',
'chat.goalEvent.continuing': 'ゴールを継続中',
'chat.goalEvent.message': 'ゴールの更新',
'chat.goalEvent.objective': '目標: {value}',
'chat.goalEvent.statusValue': 'ステータス: {value}',

View File

@ -1192,6 +1192,7 @@ export const kr: Record<TranslationKey, string> = {
'chat.goalEvent.resumed': '목표 재개됨',
'chat.goalEvent.completed': '목표 완료됨',
'chat.goalEvent.cleared': '목표 지워짐',
'chat.goalEvent.continuing': '목표 계속 진행 중',
'chat.goalEvent.message': '목표 업데이트',
'chat.goalEvent.objective': '목표: {value}',
'chat.goalEvent.statusValue': '상태: {value}',

View File

@ -1192,6 +1192,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.goalEvent.resumed': '目標已恢復',
'chat.goalEvent.completed': '目標已完成',
'chat.goalEvent.cleared': '目標已清除',
'chat.goalEvent.continuing': '目標繼續執行',
'chat.goalEvent.message': '目標更新',
'chat.goalEvent.objective': '目標:{value}',
'chat.goalEvent.statusValue': '狀態:{value}',

View File

@ -1192,6 +1192,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.goalEvent.resumed': '目标已恢复',
'chat.goalEvent.completed': '目标已完成',
'chat.goalEvent.cleared': '目标已清除',
'chat.goalEvent.continuing': '目标继续执行',
'chat.goalEvent.message': '目标更新',
'chat.goalEvent.objective': '目标:{value}',
'chat.goalEvent.statusValue': '状态:{value}',

View File

@ -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: '<local-command-stdout>Goal continuing: verify the release path</local-command-stdout>',
},
]
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: [

View File

@ -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 {

View File

@ -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.' ||

View File

@ -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 <release> & verify',
),
).toBe('Goal continuing: finish release verify')
})
})

View File

@ -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(
`<local-command-stdout>${formatGoalContinuationStatusOutput(goalContinuationReason)}</local-command-stdout>`,
)
}
if (preventedContinuation) {
return { blockingErrors: [], preventContinuation: true }
}

View File

@ -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: '<local-command-stdout>Goal continuing: verify persisted follow-up</local-command-stdout>',
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',
},

View File

@ -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'),

View File

@ -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.' ||

View File

@ -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.' ||