From 20d3b65befbedb963418a28c0a07aed1e507a26a Mon Sep 17 00:00:00 2001 From: ytahdn Date: Sun, 14 Jun 2026 16:01:18 +0800 Subject: [PATCH] feat(cli,web-shell): persist goal status in daemon transcript events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously /goal state lived only in frontend memory — page refresh or multi-device sessions lost the active goal. Now the CLI emits goal status updates as structured daemon events (_meta.goalStatus), which flow through the transcript as status blocks (source: 'goal', data: {...}). The web-shell rebuilds goal state from transcript blocks on connect, making goal status survivable across page refreshes and syncable across devices. - CLI: emitGoalStatus on goal set/clear, pass outputHistoryItems through nonInteractiveCliCommands, add setAt to goalCommand output - SDK: widen DaemonUiStatusEvent source/data types, preserve them in transcript blocks - webui: normalize _meta.goalStatus in DaemonSessionProvider, replace sentinel-prefix text encoding with structured data - web-shell: derive activeGoal from transcript blocks (getLatestActiveGoalFromBlocks), remove optimistic client-side goal dispatch, parse structured goal data in GoalStatusMessage/SystemMessage - Tests: cover emitGoalStatus, outputHistoryItems passthrough, transcript block serialization, DaemonSessionProvider event conversion - Also: harden McpDialog restart result type check with isRestartEntriesResult --- .../cli/src/acp-integration/acpAgent.test.ts | 8 ++ packages/cli/src/acp-integration/acpAgent.ts | 8 ++ .../src/acp-integration/session/Session.ts | 35 +++++++++ .../session/emitters/MessageEmitter.test.ts | 21 ++++++ .../session/emitters/MessageEmitter.ts | 13 ++++ .../cli/src/nonInteractiveCliCommands.test.ts | 18 +++++ packages/cli/src/nonInteractiveCliCommands.ts | 22 +++++- packages/cli/src/ui/commands/goalCommand.ts | 1 + packages/cli/src/ui/types.ts | 3 +- .../src/daemon/ui/transcript.ts | 7 ++ .../sdk-typescript/src/daemon/ui/types.ts | 5 +- packages/web-shell/client/App.tsx | 75 +++++++++++++++---- .../web-shell/client/adapters/messageTypes.ts | 2 + .../adapters/transcriptToMessages.test.ts | 29 +++++++ .../client/adapters/transcriptToMessages.ts | 14 +++- .../client/components/MessageItem.tsx | 6 +- .../client/components/dialogs/McpDialog.tsx | 25 ++++++- .../components/messages/GoalStatusMessage.tsx | 39 ++++++++-- .../components/messages/SystemMessage.tsx | 10 ++- .../session/DaemonSessionProvider.test.tsx | 53 +++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 38 +++++++++- 21 files changed, 401 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 9992e3b6efd..569e613821c 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1015,6 +1015,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { let lastSessionMock: | { captureHistorySnapshot: ReturnType; + emitGoalStatus: ReturnType; restoreHistory: ReturnType; rewindToTurn: ReturnType; } @@ -1318,6 +1319,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { installRewriter: vi.fn(), startCronScheduler: vi.fn(), dispose: vi.fn(), + emitGoalStatus: vi.fn(), captureHistorySnapshot: vi .fn() .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), @@ -2299,6 +2301,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }), ).resolves.toEqual({ cleared: true, condition: 'ship it' }); expect(unregisterGoalHook).toHaveBeenCalledWith(innerConfig, sessionId); + expect(lastSessionMock?.emitGoalStatus).toHaveBeenCalledWith({ + kind: 'cleared', + condition: 'ship it', + iterations: 1, + durationMs: expect.any(Number), + }); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9bd8823a070..d7258451946 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -5799,6 +5799,14 @@ class QwenAgent implements Agent { const session = this.sessionOrThrow(sessionId); const config = session.getConfig(); const cleared = unregisterGoalHook(config, sessionId); + if (cleared) { + session.emitGoalStatus({ + kind: 'cleared', + condition: cleared.condition, + iterations: cleared.iterations, + durationMs: Date.now() - cleared.setAt, + }); + } debugLogger.info( `sessionGoalClear sessionId=${sessionId} cleared=${!!cleared} condition=${cleared?.condition ?? '(none)'}`, ); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 173d02ba3c1..5b1e78ff867 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -126,6 +126,7 @@ import { } from '../../nonInteractiveCliCommands.js'; import { isSlashCommand } from '../../ui/utils/commandUtils.js'; import { CommandKind } from '../../ui/commands/types.js'; +import { MessageType, type HistoryItemGoalStatus } from '../../ui/types.js'; import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; import { classifyApiError } from '../../ui/hooks/useGeminiStream.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; @@ -589,6 +590,14 @@ export class Session implements SessionContext { }); } + emitGoalStatus(status: Omit): void { + void this.messageEmitter.emitGoalStatus(status).catch((error) => { + debugLogger.warn( + `Failed to emit goal status update: ${this.#formatError(error)}`, + ); + }); + } + /** * Replays conversation history to the client using modular components. * Delegates to HistoryReplayer for consistent event emission. @@ -3701,6 +3710,30 @@ export class Session implements SessionContext { } } + #emitGoalStatusItems(result: NonInteractiveSlashCommandResult): void { + if (!('outputHistoryItems' in result)) { + return; + } + for (const item of result.outputHistoryItems ?? []) { + if (item.type === MessageType.GOAL_STATUS) { + this.emitGoalStatus({ + kind: item.kind, + condition: item.condition, + ...(item.iterations !== undefined + ? { iterations: item.iterations } + : {}), + ...(item.setAt !== undefined ? { setAt: item.setAt } : {}), + ...(item.durationMs !== undefined + ? { durationMs: item.durationMs } + : {}), + ...(item.lastReason !== undefined + ? { lastReason: item.lastReason } + : {}), + }); + } + } + } + /** * Processes the result of a slash command execution. * @@ -3721,6 +3754,8 @@ export class Session implements SessionContext { result: NonInteractiveSlashCommandResult, originalPrompt: ContentBlock[], ): Promise { + this.#emitGoalStatusItems(result); + switch (result.type) { case 'submit_prompt': // Command wants to submit a prompt to the model diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index 3f6bdfa0799..ea19d6d8378 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -106,6 +106,27 @@ describe('MessageEmitter', () => { }); }); + describe('emitGoalStatus', () => { + it('should send a goal status update in metadata', async () => { + const status = { + kind: 'set' as const, + condition: 'ship goal support', + setAt: 1234, + }; + + await emitter.emitGoalStatus(status); + + expect(sendUpdateSpy).toHaveBeenCalledTimes(1); + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + goalStatus: status, + }, + }); + }); + }); + describe('emitAgentThought', () => { it('should send agent_thought_chunk update with text content', async () => { await emitter.emitAgentThought('Let me think about this...'); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 36750b03346..2e822b96486 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -12,6 +12,7 @@ import { type GoalTerminalEvent, } from '@qwen-code/qwen-code-core'; import { BaseEmitter } from './BaseEmitter.js'; +import type { HistoryItemGoalStatus } from '../../../ui/types.js'; /** * Handles emission of text message chunks (user, agent, thought). @@ -68,6 +69,18 @@ export class MessageEmitter extends BaseEmitter { }); } + async emitGoalStatus( + status: Omit, + ): Promise { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + goalStatus: status, + }, + }); + } + /** * Emits a user message chunk. * diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 378aabbb760..1a3f0099389 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -235,6 +235,14 @@ describe('handleSlashCommand', () => { text: expect.stringContaining('write a hello world script'), }), ]); + expect(result.outputHistoryItems).toEqual([ + expect.objectContaining({ + type: 'goal_status', + kind: 'set', + condition: 'write a hello world script', + setAt: expect.any(Number), + }), + ]); } }); @@ -304,6 +312,16 @@ describe('handleSlashCommand', () => { messageType: 'info', content: 'Goal cleared: write a hello world script', }); + if (result.type === 'message') { + expect(result.outputHistoryItems).toEqual([ + expect.objectContaining({ + type: 'goal_status', + kind: 'cleared', + condition: 'write a hello world script', + durationMs: expect.any(Number), + }), + ]); + } }); it('should report cleared goal for ACP /goal clear', async () => { diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index dcda0576b54..75abf9943c0 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -25,6 +25,7 @@ import { type ExecutionMode, } from './ui/commands/types.js'; import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; +import type { HistoryItemWithoutId } from './ui/types.js'; import type { LoadedSettings } from './config/settings.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js'; import { t } from './i18n/index.js'; @@ -50,11 +51,13 @@ export type NonInteractiveSlashCommandResult = | { type: 'submit_prompt'; content: PartListUnion; + outputHistoryItems?: HistoryItemWithoutId[]; } | { type: 'message'; messageType: 'info' | 'warning' | 'error'; content: string; + outputHistoryItems?: HistoryItemWithoutId[]; } | { type: 'stream_messages'; @@ -88,12 +91,14 @@ export type NonInteractiveSlashCommandResult = */ function handleCommandResult( result: SlashCommandActionReturn, + outputHistoryItems?: HistoryItemWithoutId[], ): NonInteractiveSlashCommandResult { switch (result.type) { case 'submit_prompt': return { type: 'submit_prompt', content: result.content, + ...(outputHistoryItems?.length ? { outputHistoryItems } : {}), }; case 'message': @@ -101,6 +106,7 @@ function handleCommandResult( type: 'message', messageType: result.messageType, content: result.content, + ...(outputHistoryItems?.length ? { outputHistoryItems } : {}), }; case 'stream_messages': @@ -399,6 +405,13 @@ export const handleSlashCommand = async ( const logger = new Logger(config?.getSessionId() || '', config?.storage); + const outputHistoryItems: HistoryItemWithoutId[] = []; + const ui = createNonInteractiveUI(); + ui.addItem = (item) => { + outputHistoryItems.push(item); + return 0; + }; + const context: CommandContext = { executionMode, services: { @@ -406,7 +419,7 @@ export const handleSlashCommand = async ( settings, logger, }, - ui: createNonInteractiveUI(), + ui, session: { stats: sessionStats, sessionShellAllowlist: new Set(), @@ -440,11 +453,14 @@ export const handleSlashCommand = async ( if (hookResult.blockedResult) { return hookResult.blockedResult; } - return handleCommandResult({ ...result, content: hookResult.content }); + return handleCommandResult( + { ...result, content: hookResult.content }, + outputHistoryItems, + ); } // Handle different result types - return handleCommandResult(result); + return handleCommandResult(result, outputHistoryItems); }; /** diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 76f6d74ca77..0ae7ed654f2 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -208,6 +208,7 @@ export const goalCommand: SlashCommand = { type: MessageType.GOAL_STATUS, kind: 'set', condition: registered.condition, + setAt: registered.setAt, }; context.ui.addItem(setItem, Date.now()); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 0f5fe8a772e..1508afe005f 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -562,8 +562,9 @@ export type HistoryItemGoalStatus = HistoryItemBase & { type: 'goal_status'; kind: GoalStatusKind; condition: string; - /** Set for progress and terminal goal states. */ + /** Set for active, progress, and terminal goal states. */ iterations?: number; + setAt?: number; durationMs?: number; lastReason?: string; }; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 422bcfbcdd2..588d768a6bd 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -928,6 +928,13 @@ function appendStatusBlock( ...(event?.type === 'error' && event.source ? { source: event.source } : {}), + ...((event?.type === 'status' || event?.type === 'debug') && event.source + ? { source: event.source } + : {}), + ...((event?.type === 'status' || event?.type === 'debug') && + event.data !== undefined + ? { data: event.data } + : {}), }; appendBlock(state, block); if (opts.clearActiveText !== false) clearActiveText(state); diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 22a3b5e4023..bdc49c8a075 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -211,6 +211,8 @@ export interface DaemonUiModelChangedEvent extends DaemonUiEventBase { export interface DaemonUiStatusEvent extends DaemonUiEventBase { type: 'status' | 'debug'; text: string; + source?: string; + data?: unknown; } export interface DaemonUiErrorEvent extends DaemonUiEventBase { @@ -744,7 +746,8 @@ export interface DaemonStatusTranscriptBlock extends DaemonTranscriptBlockBase { text: string; code?: string; promptId?: string; - source?: 'turn_error'; + source?: string; + data?: unknown; } export interface DaemonPromptCancelledTranscriptBlock diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6064f1ecf94..cd587f02660 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -20,6 +20,7 @@ import { type DaemonStreamingState, } from '@qwen-code/webui/daemon-react-sdk'; import { isDaemonTurnError } from '@qwen-code/sdk/daemon'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { Editor, type EditorHandle } from './components/Editor'; @@ -113,6 +114,7 @@ import { } from './components/messages/McpStatusMessage'; import { GOAL_STATUS_ACTIVE_EVENT, + parseGoalStatusMessage, serializeGoalStatusMessage, } from './components/messages/GoalStatusMessage'; import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage'; @@ -179,6 +181,41 @@ interface ActiveGoalStatus { setAt: number; } +interface SendPromptOptionsWithRetry { + optimisticUserMessage?: boolean; + images?: PromptImage[]; + retry?: boolean; +} + +type GoalStatusTranscriptBlock = DaemonTranscriptBlock & { + text: string; + source?: string; + data?: unknown; +}; + +function getLatestActiveGoalFromBlocks( + blocks: readonly DaemonTranscriptBlock[], +): ActiveGoalStatus | null { + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.kind !== 'status') continue; + const statusBlock = block as GoalStatusTranscriptBlock; + const status = + statusBlock.source === 'goal' + ? parseGoalStatusMessage(statusBlock.data) + : parseGoalStatusMessage(statusBlock.text); + if (!status) continue; + if (status.kind === 'set' || status.kind === 'checking') { + return { + condition: status.condition, + setAt: status.setAt ?? block.serverTimestamp ?? block.createdAt, + }; + } + return null; + } + return null; +} + interface LocalAnchoredMessage { anchorAfterId?: string; anchorIndex: number; @@ -701,11 +738,17 @@ export function App({ retriedTurnErrorIdRef.current = null; } setShowRetryHint(false); - return sessionActions.sendPrompt(text, { + const promptOptions: SendPromptOptionsWithRetry = { images, optimisticUserMessage: opts?.optimisticUserMessage, retry: opts?.retry, - }); + }; + return ( + sessionActions.sendPrompt as ( + promptText: string, + options?: SendPromptOptionsWithRetry, + ) => ReturnType + )(text, promptOptions); }, [clearFollowup, sessionActions], ); @@ -1286,6 +1329,20 @@ export function App({ } }, [connection.sessionId, onSessionIdChange]); + useEffect(() => { + const nextGoal = getLatestActiveGoalFromBlocks(blocks); + setActiveGoal((current) => { + if (!nextGoal) return current ? null : current; + if ( + current?.condition === nextGoal.condition && + current.setAt === nextGoal.setAt + ) { + return current; + } + return nextGoal; + }); + }, [blocks]); + useEffect(() => { const onGoalStatusActive = (event: Event) => { const detail = ( @@ -1439,18 +1496,13 @@ export function App({ const handleBusyGoalClear = useCallback( (text: string) => { - const goalToClear = activeGoalRef.current; store.appendLocalUserMessage(text); - dispatchGoalCleared(goalToClear); sessionActions.clearGoal().catch((error: unknown) => { - if (goalToClear) { - dispatchGoalSet(goalToClear.condition, goalToClear.setAt); - } reportError(error, 'Failed to clear /goal'); }); return true; }, - [dispatchGoalCleared, dispatchGoalSet, reportError, sessionActions, store], + [reportError, sessionActions, store], ); const handleGoalSlashCommand = useCallback( @@ -1471,10 +1523,9 @@ export function App({ } return handleBusyGoalClear(text); } else if (goalArg) { - const optimisticGoal = { condition: goalArg, setAt: Date.now() }; store.appendLocalUserMessage(text); - dispatchGoalSet(optimisticGoal.condition, optimisticGoal.setAt); if (!sendToDaemon) { + dispatchGoalSet(goalArg, Date.now()); return true; } sendPrompt(text, images, { optimisticUserMessage: false }).catch( @@ -1539,10 +1590,6 @@ export function App({ if (isGoalClearCommand(text)) { return handleBusyGoalClear(text); } - const goalArg = text.replace(/^\/goal\b/i, '').trim(); - if (goalArg) { - setActiveGoal({ condition: goalArg, setAt: Date.now() }); - } return enqueuePrompt(text, images); } return handleGoalSlashCommand(text, images); diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 6aa88558322..28bc72e446b 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -107,6 +107,8 @@ export interface DaemonSystemMessage extends DaemonMessageMeta { content: string; variant: 'info' | 'error' | 'warning'; retryable?: boolean; + source?: string; + data?: unknown; } export interface DaemonUserShellMessage extends DaemonMessageMeta { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index 635a3ce8492..69b616b229b 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -33,6 +33,7 @@ function statusBlock( id: string, text: string, createdAt: number, + overrides: Partial = {}, ): DaemonStatusTranscriptBlock { return { id, @@ -41,6 +42,7 @@ function statusBlock( clientReceivedAt: createdAt, createdAt, updatedAt: createdAt, + ...overrides, }; } @@ -869,6 +871,32 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('preserves structured status source and data', () => { + const data = { + kind: 'set', + condition: 'ship goal sync', + setAt: 1234, + }; + const messages = transcriptBlocksToDaemonMessages([ + statusBlock('goal-1', '', 1, { + source: 'goal', + data, + }), + ]); + + expect(messages).toEqual([ + { + id: 'goal-1', + role: 'system', + content: '', + variant: 'info', + source: 'goal', + data, + timestamp: 1, + }, + ]); + }); + it('appends shell output to preceding tool_group', () => { const messages = transcriptBlocksToDaemonMessages([ toolBlock('t1', 'tc1', 'completed', 1, { @@ -1932,6 +1960,7 @@ describe('transcriptBlocksToDaemonMessages', () => { content: 'Request failed', variant: 'error', retryable: true, + source: 'turn_error', timestamp: 1, }, ]); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 4fd7033a94e..5df8795355b 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -31,6 +31,11 @@ type DaemonPermissionTranscriptBlock = Extract< { kind: 'permission' } >; +type ExtendedDaemonStatusTranscriptBlock = DaemonStatusTranscriptBlock & { + source?: string; + data?: unknown; +}; + interface TranscriptMessageLabels { promptCancelled?: string; } @@ -408,7 +413,8 @@ export function transcriptBlocksToDaemonMessages( case 'status': case 'debug': { - const text = (block as DaemonStatusTranscriptBlock).text; + const statusBlock = block as ExtendedDaemonStatusTranscriptBlock; + const text = statusBlock.text; const todos = parsePlanTodos(text); if (todos) { messages.push({ @@ -430,13 +436,15 @@ export function transcriptBlocksToDaemonMessages( content: text, variant: 'info', timestamp: blockTime, + ...(statusBlock.source ? { source: statusBlock.source } : {}), + ...(statusBlock.data !== undefined ? { data: statusBlock.data } : {}), }); needsNewContentMessage = true; break; } case 'error': { - const errorBlock = block as DaemonStatusTranscriptBlock; + const errorBlock = block as ExtendedDaemonStatusTranscriptBlock; messages.push({ id: block.id, role: 'system', @@ -444,6 +452,8 @@ export function transcriptBlocksToDaemonMessages( variant: 'error', retryable: errorBlock.source === 'turn_error', timestamp: blockTime, + ...(errorBlock.source ? { source: errorBlock.source } : {}), + ...(errorBlock.data !== undefined ? { data: errorBlock.data } : {}), }); needsNewContentMessage = true; break; diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index ac3d369ba5c..a0a9762ab2d 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -75,6 +75,8 @@ export const MessageItem = memo(function MessageItem({ void; } +interface RestartEntry { + entryIndex: number; + restarted: boolean; + durationMs?: number; + reason?: string; +} + +interface RestartEntriesResult { + serverName: string; + entries: RestartEntry[]; +} + +function isRestartEntriesResult( + result: unknown, +): result is RestartEntriesResult { + return ( + typeof result === 'object' && + result !== null && + 'entries' in result && + Array.isArray((result as { entries?: unknown }).entries) + ); +} + function getServerStatus(server: DaemonWorkspaceMcpServerStatus): string { if (server.disabled) { return server.disabledReason @@ -225,7 +248,7 @@ export function McpDialog({ onClose }: McpDialogProps) { setMessage(null); restartServer(serverName) .then((result) => { - if ('entries' in result) { + if (isRestartEntriesResult(result)) { const restartedCount = result.entries.filter( (entry) => entry.restarted, ).length; diff --git a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx index e3e2e9a1c75..596eb069876 100644 --- a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx @@ -41,16 +41,45 @@ const VALID_GOAL_KINDS = new Set([ ]); function parseGoalStatusMessage( - content: string, + content: unknown, ): SerializedGoalStatusMessage | null { - const parsed = parseRawGoalStatusMessage(content); - if (!parsed || !parsed.kind || !parsed.condition) return null; - if (!VALID_GOAL_KINDS.has(parsed.kind)) return null; - return parsed; + const parsed = + typeof content === 'string' ? parseRawGoalStatusMessage(content) : content; + return normalizeGoalStatus(parsed); } export { serializeGoalStatusMessage, parseGoalStatusMessage }; +function normalizeGoalStatus( + value: unknown, +): SerializedGoalStatusMessage | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const kind = record.kind; + const condition = record.condition; + if (typeof kind !== 'string' || !VALID_GOAL_KINDS.has(kind)) return null; + if (typeof condition !== 'string') return null; + const iterations = getNumber(record.iterations); + const durationMs = getNumber(record.durationMs); + const setAt = getNumber(record.setAt); + const lastReason = + typeof record.lastReason === 'string' ? record.lastReason : undefined; + return { + kind: kind as GoalStatusKind, + condition, + ...(iterations !== undefined ? { iterations } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(setAt !== undefined ? { setAt } : {}), + ...(lastReason !== undefined ? { lastReason } : {}), + }; +} + +function getNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + function pluralTurns(n: number, t: ReturnType['t']): string { return t(n === 1 ? 'goal.turn' : 'goal.turns', { count: n }); } diff --git a/packages/web-shell/client/components/messages/SystemMessage.tsx b/packages/web-shell/client/components/messages/SystemMessage.tsx index 1cac734f88d..5d4f3ca60b0 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.tsx +++ b/packages/web-shell/client/components/messages/SystemMessage.tsx @@ -18,6 +18,8 @@ import styles from './SystemMessage.module.css'; interface SystemMessageProps { content: string; variant: 'info' | 'error' | 'warning'; + source?: string; + data?: unknown; /** Run /context detail, exactly like typing it (context-usage panels). */ onShowContextDetail?: () => void; isLatest?: boolean; @@ -28,6 +30,8 @@ interface SystemMessageProps { export const SystemMessage = memo(function SystemMessage({ content, variant, + source, + data, onShowContextDetail, isLatest = false, showRetryHint = false, @@ -85,7 +89,11 @@ export const SystemMessage = memo(function SystemMessage({ } const goalStatus = - variant === 'info' ? parseGoalStatusMessage(content) : null; + variant === 'info' + ? source === 'goal' + ? parseGoalStatusMessage(data) + : parseGoalStatusMessage(content) + : null; if (goalStatus) { return (
diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index bedf60a0f1b..30b2c419a75 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -369,6 +369,59 @@ describe('DaemonSessionProvider', () => { expect(sdkMocks.capabilities).toHaveBeenCalledTimes(1); }); + it('adds daemon goal status metadata to the transcript', async () => { + const session = createMockSession({ + events: async function* goalStatusEvents() { + yield { + id: 11, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + goalStatus: { + kind: 'set', + condition: 'ship goal sync', + setAt: 1234, + }, + }, + }, + }, + }; + }, + }); + sdkMocks.sessions.push(session); + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + await act(async () => { + await flushPromises(); + }); + + expect(blocks).toEqual([ + expect.objectContaining({ + kind: 'status', + text: '', + source: 'goal', + data: { + kind: 'set', + condition: 'ship goal sync', + setAt: 1234, + }, + }), + ]); + }); + it('publishes action error notices when no session is connected', async () => { let actions: DaemonUiSessionActions | undefined; let blocks: readonly DaemonTranscriptBlock[] = []; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index f8e4af6a08c..1d31485468d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -18,7 +18,6 @@ import { } from 'react'; import { DaemonClient, - DAEMON_GOAL_STATUS_SENTINEL_PREFIX, DaemonHttpError, DaemonSessionClient, createDaemonTranscriptStore, @@ -1441,6 +1440,11 @@ function normalizeGoalStatusEvent(event: DaemonEvent): DaemonUiEvent | null { } const meta = update['_meta']; if (!isRecord(meta)) return null; + const status = normalizeGoalStatus(meta['goalStatus']); + if (status) { + return createGoalStatusUiEvent(event, status); + } + const terminal = normalizeGoalTerminal(meta['goalTerminal']); if (terminal) { return createGoalStatusUiEvent(event, terminal); @@ -1470,7 +1474,37 @@ function createGoalStatusUiEvent( ...(event.originatorClientId ? { originatorClientId: event.originatorClientId } : {}), - text: DAEMON_GOAL_STATUS_SENTINEL_PREFIX + JSON.stringify(status), + text: '', + source: 'goal', + data: status, + }; +} + +function normalizeGoalStatus(value: unknown): Record | null { + if (!isRecord(value)) return null; + const kind = getString(value, 'kind'); + if ( + kind !== 'set' && + kind !== 'cleared' && + kind !== 'achieved' && + kind !== 'failed' && + kind !== 'aborted' + ) { + return null; + } + const condition = getString(value, 'condition'); + if (!condition) return null; + const iterations = getNumber(value, 'iterations'); + const durationMs = getNumber(value, 'durationMs'); + const setAt = getNumber(value, 'setAt'); + const lastReason = getString(value, 'lastReason'); + return { + kind, + condition, + ...(iterations !== undefined ? { iterations } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(setAt !== undefined ? { setAt } : {}), + ...(lastReason ? { lastReason } : {}), }; }