mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
96ce12d84b
commit
08c2d8cf5b
@ -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: {
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.' ||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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.' ||
|
||||
|
||||
@ -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.' ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user