From 376fc6de62d8e90741a436caaade4c9f49f94664 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: Fri, 15 May 2026 23:26:54 +0800 Subject: [PATCH] Make /goal durable across CLI and desktop sessions The goal flow needs to drive the agent loop and remain visible after desktop reconnects, so this change treats /goal output as a structured lifecycle signal across CLI, server, and desktop history restoration. Constraint: The current TypeScript CLI does not share Codex's Rust app-server thread-goal database, so persistence is reconstructed from the existing session transcript. Rejected: UI-only goal badges | would leave the CLI self-loop unable to recover active or completed goals after process restart. Rejected: Add a new persisted store | larger migration surface than needed for the existing transcript-backed session model. Confidence: high Scope-risk: moderate Directive: Keep future goal state changes mirrored in transcript-visible local command output or introduce a real migration-backed store. Tested: bun test src/goals/goalEvaluator.test.ts src/goals/goalState.test.ts Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/pages/ActiveSession.test.tsx src/components/chat/MessageList.test.tsx Tested: bun run verify Not-tested: Live provider baseline with a real model-driven goal loop. --- desktop/src/__tests__/pages.test.tsx | 3 +- .../src/components/chat/MessageList.test.tsx | 50 ++++ desktop/src/components/chat/MessageList.tsx | 41 ++- desktop/src/i18n/locales/en.ts | 20 ++ desktop/src/i18n/locales/zh.ts | 20 ++ desktop/src/pages/ActiveSession.test.tsx | 72 +++++ desktop/src/pages/ActiveSession.tsx | 66 +++++ desktop/src/stores/chatStore.test.ts | 256 ++++++++++++++++++ desktop/src/stores/chatStore.ts | 212 ++++++++++++++- desktop/src/types/chat.ts | 25 ++ src/commands/goal/goal.test.tsx | 110 ++++++++ src/commands/goal/goal.tsx | 3 +- src/goals/goalEvaluator.test.ts | 45 +++ src/goals/goalEvaluator.ts | 5 +- src/goals/goalState.test.ts | 25 ++ src/goals/goalState.ts | 183 ++++++++++++- src/query.ts | 7 + src/server/__tests__/ws-memory-events.test.ts | 175 ++++++++++++ src/server/ws/handler.ts | 137 +++++++++- 19 files changed, 1439 insertions(+), 16 deletions(-) create mode 100644 src/commands/goal/goal.test.tsx diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index bddd4857..f1f02e4f 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1263,7 +1263,8 @@ describe('AppShell layout renders chrome', () => { expect(container.querySelector('aside')).toBeInTheDocument() expect(container.innerHTML).toContain('New session') expect(container.innerHTML).toContain('Scheduled') - expect(container.innerHTML).toContain('All projects') + expect(container.innerHTML).toContain('Search sessions') + expect(container.innerHTML).toContain('Settings') }) }) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 25a92cf7..c27f84f4 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -141,6 +141,56 @@ describe('MessageList nested tool calls', () => { expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(140) }) + it('renders goal events as visible status cards', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'goal-1', + type: 'goal_event', + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + timestamp: 1, + }], + }), + }, + }) + + render() + + expect(screen.getByText('Goal created')).toBeTruthy() + expect(screen.getByText('Objective: ship the smoke test')).toBeTruthy() + expect(screen.getByText('Status: active')).toBeTruthy() + expect(screen.getByText('Budget: 0 / 2,000 tokens')).toBeTruthy() + }) + + it('renders replacement goal events distinctly', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'goal-replaced', + type: 'goal_event', + action: 'replaced', + status: 'active', + objective: 'ship the replacement target', + budget: '0 / unlimited tokens', + timestamp: 1, + }], + }), + }, + }) + + render() + + expect(screen.getByText('Goal replaced')).toBeTruthy() + expect(screen.getByText('Objective: ship the replacement target')).toBeTruthy() + expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy() + }) + it('restores the full transcript when scrolling away from latest', async () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 5bb99da3..1c6395c4 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react' -import { ArrowDown, BookMarked, Settings } from 'lucide-react' +import { ArrowDown, BookMarked, Settings, Target } from 'lucide-react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' @@ -26,6 +26,7 @@ import { ConfirmDialog } from '../shared/ConfirmDialog' type ToolCall = Extract type ToolResult = Extract type MemoryEvent = Extract +type GoalEvent = Extract type RenderItem = | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } @@ -148,6 +149,42 @@ function ChatSelectionMenu({ ) } +function GoalEventCard({ message }: { message: GoalEvent }) { + const t = useTranslation() + const titleKey = `chat.goalEvent.${message.action === 'status' ? 'statusTitle' : message.action}` as TranslationKey + const title = t(titleKey) === titleKey ? t('chat.goalEvent.message') : t(titleKey) + const details = [ + message.objective ? t('chat.goalEvent.objective', { value: message.objective }) : null, + message.status ? t('chat.goalEvent.statusValue', { value: message.status }) : null, + message.budget ? t('chat.goalEvent.budget', { value: message.budget }) : null, + message.continuations ? t('chat.goalEvent.continuations', { value: message.continuations }) : null, + ].filter((detail): detail is string => detail !== null) + + return ( +
+
+
+
+
+
{title}
+ {details.length > 0 ? ( +
+ {details.map((detail) => ( +
{detail}
+ ))} +
+ ) : message.message ? ( +
+ {message.message} +
+ ) : null} +
+
+
+ ) +} + function SelectableChatMessage({ sessionId, messageId, @@ -1120,6 +1157,8 @@ export const MessageBlock = memo(function MessageBlock({ return case 'memory_event': return + case 'goal_event': + return case 'system': return (
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index fb5ce29e..11395f30 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -888,6 +888,26 @@ export const en = { 'chat.userMessageReference': 'User message', 'chat.assistantMessageReference': 'Assistant message', 'chat.slashCommands': 'Slash commands', + 'chat.goalEvent.created': 'Goal created', + 'chat.goalEvent.replaced': 'Goal replaced', + 'chat.goalEvent.statusTitle': 'Goal status', + 'chat.goalEvent.paused': 'Goal paused', + 'chat.goalEvent.resumed': 'Goal resumed', + 'chat.goalEvent.completed': 'Goal completed', + 'chat.goalEvent.cleared': 'Goal cleared', + 'chat.goalEvent.message': 'Goal update', + 'chat.goalEvent.objective': 'Objective: {value}', + 'chat.goalEvent.statusValue': 'Status: {value}', + 'chat.goalEvent.budget': 'Budget: {value}', + 'chat.goalEvent.continuations': 'Continuations: {value}', + 'chat.activeGoal.title': 'Active goal', + 'chat.activeGoal.running': 'Loop running', + 'chat.activeGoal.active': 'Active', + 'chat.activeGoal.paused': 'Paused', + 'chat.activeGoal.completed': 'Completed', + 'chat.activeGoal.budget': 'Budget {value}', + 'chat.activeGoal.continuations': 'Continuations {value}', + 'chat.activeGoal.elapsed': 'Elapsed {value}', 'slash.mcp.title': 'Available MCP tools', 'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.', 'slash.mcp.subtitleWithProject': 'Showing global plus project MCP for: {path}', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7a87d605..675d700d 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -890,6 +890,26 @@ export const zh: Record = { 'chat.userMessageReference': '用户消息', 'chat.assistantMessageReference': 'AI 回复', 'chat.slashCommands': '斜杠命令', + 'chat.goalEvent.created': '目标已创建', + 'chat.goalEvent.replaced': '目标已替换', + 'chat.goalEvent.statusTitle': '目标状态', + 'chat.goalEvent.paused': '目标已暂停', + 'chat.goalEvent.resumed': '目标已恢复', + 'chat.goalEvent.completed': '目标已完成', + 'chat.goalEvent.cleared': '目标已清除', + 'chat.goalEvent.message': '目标更新', + 'chat.goalEvent.objective': '目标:{value}', + 'chat.goalEvent.statusValue': '状态:{value}', + 'chat.goalEvent.budget': '预算:{value}', + 'chat.goalEvent.continuations': '续作次数:{value}', + 'chat.activeGoal.title': '当前目标', + 'chat.activeGoal.running': '自循环运行中', + 'chat.activeGoal.active': '进行中', + 'chat.activeGoal.paused': '已暂停', + 'chat.activeGoal.completed': '已完成', + 'chat.activeGoal.budget': '预算 {value}', + 'chat.activeGoal.continuations': '续作次数 {value}', + 'chat.activeGoal.elapsed': '已用时 {value}', 'slash.mcp.title': '可用 MCP 工具', 'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。', 'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP:{path}', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 5eeda9a2..a22c4648 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -135,6 +135,78 @@ describe('ActiveSession task polling', () => { expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default') }) + it('shows the current goal as a persistent session status panel', () => { + const sessionId = 'goal-visible-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Goal Visible Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Goal Visible Session', type: 'session', status: 'running' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ + id: 'goal-event', + type: 'goal_event', + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + timestamp: 1, + }], + activeGoal: { + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + updatedAt: 1, + }, + chatState: 'thinking', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + const panel = screen.getByTestId('active-goal-panel') + expect(panel).toHaveTextContent('当前目标') + expect(panel).toHaveTextContent('自循环运行中') + expect(panel).toHaveTextContent('ship the smoke test') + expect(panel).toHaveTextContent('预算 0 / 2,000 tokens') + expect(panel).toHaveTextContent('续作次数 0') + }) + it('refreshes CLI tasks repeatedly while a turn is active', async () => { vi.useFakeTimers() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 60198917..1bac497a 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -28,6 +28,7 @@ import { TerminalSettings } from './TerminalSettings' import type { SessionListItem } from '../types/session' import { useMobileViewport } from '../hooks/useMobileViewport' import { isTauriRuntime } from '../lib/desktopRuntime' +import type { ActiveGoalState } from '../types/chat' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 @@ -199,6 +200,62 @@ function TerminalResizeHandle() { ) } +function GoalStatusPanel({ + goal, + isRunning, + compact, +}: { + goal: ActiveGoalState + isRunning: boolean + compact: boolean +}) { + const t = useTranslation() + const stateLabel = goal.action === 'completed' + ? t('chat.activeGoal.completed') + : goal.action === 'paused' || goal.status === 'paused' + ? t('chat.activeGoal.paused') + : isRunning && goal.status !== 'complete' + ? t('chat.activeGoal.running') + : t('chat.activeGoal.active') + + return ( +
+
+ track_changes +
+
+ + {t('chat.activeGoal.title')} + + + {stateLabel} + + {goal.objective && ( + + {goal.objective} + + )} +
+ {(goal.budget || goal.continuations || goal.elapsed) && ( +
+ {goal.budget && {t('chat.activeGoal.budget', { value: goal.budget })}} + {goal.continuations && {t('chat.activeGoal.continuations', { value: goal.continuations })}} + {goal.elapsed && {t('chat.activeGoal.elapsed', { value: goal.elapsed })}} +
+ )} +
+
+
+ ) +} + export function ActiveSession() { const isMobileLayout = useMobileViewport() && !isTauriRuntime() const activeTabId = useTabStore((s) => s.activeTabId) @@ -211,6 +268,7 @@ export function ActiveSession() { const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId) const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed')) const chatState = sessionState?.chatState ?? 'idle' + const activeGoal = sessionState?.activeGoal ?? null const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } const session = sessions.find((s) => s.id === activeTabId) @@ -420,6 +478,14 @@ export function ActiveSession() {
)} + {activeGoal && ( + + )} + )} diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 2aa9d64a..d6128bcb 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -250,6 +250,143 @@ describe('chatStore history mapping', () => { ]) }) + it('restores /goal local command output from transcript history', () => { + const messages: MessageEntry[] = [ + { + id: 'goal-command', + type: 'system', + timestamp: '2026-04-06T00:00:00.000Z', + content: '/goal\n--tokens 2k ship the smoke test', + }, + { + id: 'goal-output', + type: 'system', + timestamp: '2026-04-06T00:00:01.000Z', + content: [ + '', + 'Goal created.', + 'Goal: active', + 'Objective: ship the smoke test', + 'Budget: 0 / 2,000 tokens', + 'Elapsed: 0s', + 'Continuations: 0', + '', + ].join('\n'), + }, + ] + + expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'goal-output', + type: 'goal_event', + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + }, + ]) + }) + + it('restores replacement /goal output as a replacement event', () => { + const messages: MessageEntry[] = [ + { + id: 'goal-command', + type: 'system', + timestamp: '2026-04-06T00:00:00.000Z', + content: '/goal\nship the replacement target', + }, + { + id: 'goal-output', + type: 'system', + timestamp: '2026-04-06T00:00:01.000Z', + content: [ + '', + 'Goal replaced.', + 'Goal: active', + 'Objective: ship the replacement target', + 'Budget: 0 / unlimited tokens', + 'Elapsed: 0s', + 'Continuations: 0', + '', + ].join('\n'), + }, + ] + + expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'goal-output', + type: 'goal_event', + action: 'replaced', + status: 'active', + objective: 'ship the replacement target', + budget: '0 / unlimited tokens', + }, + ]) + }) + + it('restores completed /goal state from transcript history after app restart', async () => { + vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ + messages: [ + { + id: 'goal-command', + type: 'system', + timestamp: '2026-04-06T00:00:00.000Z', + content: '/goal\nship the smoke test', + }, + { + id: 'goal-output', + type: 'system', + timestamp: '2026-04-06T00:00:01.000Z', + content: [ + '', + 'Goal created.', + 'Goal: active', + 'Objective: ship the smoke test', + 'Budget: 0 / 2,000 tokens', + 'Elapsed: 0s', + 'Continuations: 0', + '', + ].join('\n'), + }, + { + id: 'goal-complete', + type: 'system', + timestamp: '2026-04-06T00:00:02.000Z', + content: 'Goal marked complete.', + }, + ], + }) + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ messages: [] }), + }, + }) + + await useChatStore.getState().loadHistory(TEST_SESSION_ID) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + id: 'goal-output', + type: 'goal_event', + action: 'created', + objective: 'ship the smoke test', + }, + { + id: 'goal-complete', + type: 'goal_event', + action: 'completed', + message: 'Goal marked complete.', + }, + ]) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({ + action: 'completed', + status: 'complete', + objective: 'ship the smoke test', + }) + }) + it('merges consecutive assistant text blocks when restoring transcript history', () => { const messages: MessageEntry[] = [ { @@ -1152,6 +1289,125 @@ describe('chatStore history mapping', () => { ]) }) + it('renders live goal notifications as visible goal events', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [], + chatState: 'idle', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'goal_event', + message: 'Goal: active', + data: { + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'goal_event', + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + }, + ]) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({ + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'goal_event', + message: 'Goal replaced.', + data: { + action: 'replaced', + status: 'active', + objective: 'ship the replacement target', + budget: '0 / unlimited tokens', + continuations: '0', + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({ + action: 'replaced', + status: 'active', + objective: 'ship the replacement target', + budget: '0 / unlimited tokens', + continuations: '0', + }) + }) + + it('keeps the active goal panel state in sync with /goal lifecycle events', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [], + activeGoal: { + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + continuations: '0', + updatedAt: 1, + }, + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'goal_event', + data: { + action: 'paused', + status: 'paused', + }, + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({ + action: 'paused', + status: 'paused', + objective: 'ship the smoke test', + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'goal_event', + data: { + action: 'completed', + message: 'Goal marked complete.', + }, + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({ + action: 'completed', + status: 'complete', + objective: 'ship the smoke test', + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'goal_event', + data: { + action: 'cleared', + message: 'Goal cleared.', + }, + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toBeNull() + }) + it('flushes the previous assistant draft before starting a new user turn', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 2352f4d0..0d4560f8 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -14,11 +14,13 @@ import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' import type { RuntimeSelection } from '../types/runtime' import type { + ActiveGoalState, AgentTaskNotification, AttachmentRef, ChatState, ComputerUsePermissionRequest, ComputerUsePermissionResponse, + GoalEventAction, MemoryEventFile, UIAttachment, UIMessage, @@ -53,6 +55,7 @@ export type PerSessionState = { statusVerb: string slashCommands: Array<{ name: string; description: string; argumentHint?: string }> agentTaskNotifications: Record + activeGoal?: ActiveGoalState | null elapsedTimer: ReturnType | null composerPrefill?: { text: string @@ -77,6 +80,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, + activeGoal: null, elapsedTimer: null, composerPrefill: null, } @@ -269,9 +273,11 @@ function updateSessionIn( async function fetchAndMapSessionHistory(sessionId: string) { const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) + const uiMessages = mapHistoryMessagesToUiMessages(messages) return { rawMessages: messages, - uiMessages: mapHistoryMessagesToUiMessages(messages), + uiMessages, + activeGoal: deriveActiveGoalFromMessages(uiMessages), restoredNotifications: { ...reconstructAgentNotifications(messages), ...agentNotificationRecordFromList(taskNotifications ?? []), @@ -299,6 +305,7 @@ export const useChatStore = create((set, get) => ({ ...createDefaultSessionState(), connectionState: 'connecting', messages: existing?.messages ?? [], + activeGoal: existing?.activeGoal ?? null, }, }, })) @@ -527,6 +534,7 @@ export const useChatStore = create((set, get) => ({ try { const { uiMessages, + activeGoal, restoredNotifications, lastTodos, hasMessagesAfterTaskCompletion, @@ -536,6 +544,7 @@ export const useChatStore = create((set, get) => ({ if (!session || session.messages.length > 0) return state return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ messages: uiMessages, + activeGoal, agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, })) } }) @@ -557,6 +566,7 @@ export const useChatStore = create((set, get) => ({ try { const { uiMessages, + activeGoal, restoredNotifications, lastTodos, hasMessagesAfterTaskCompletion, @@ -569,6 +579,7 @@ export const useChatStore = create((set, get) => ({ return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages, + activeGoal, agentTaskNotifications: restoredNotifications, chatState: 'idle', activeThinkingId: null, @@ -611,7 +622,7 @@ export const useChatStore = create((set, get) => ({ clearMessages: (sessionId) => { clearPendingTaskToolUseIds(sessionId) - set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) })) + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle' })) })) }, handleServerMessage: (sessionId, msg) => { @@ -917,6 +928,7 @@ export const useChatStore = create((set, get) => ({ statusVerb: '', tokenUsage: { input_tokens: 0, output_tokens: 0 }, slashCommands: [], + activeGoal: null, })) clearPendingDelta(sessionId) clearPendingTaskToolUseIds(sessionId) @@ -959,6 +971,23 @@ export const useChatStore = create((set, get) => ({ })) } } + if (msg.subtype === 'goal_event') { + const goalEvent = normalizeGoalEventData(msg.data, msg.message) + if (goalEvent) { + update((session) => ({ + activeGoal: applyGoalEventToActiveGoal(session.activeGoal ?? null, goalEvent, Date.now()), + messages: [ + ...session.messages, + { + id: nextId(), + type: 'goal_event', + ...goalEvent, + timestamp: Date.now(), + }, + ], + })) + } + } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { const data = msg.data as Record const toolUseId = @@ -1019,6 +1048,17 @@ type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; n type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i +const GOAL_CLEAR_ALIASES = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel']) +const GOAL_EVENT_ACTIONS = new Set([ + 'created', + 'replaced', + 'status', + 'paused', + 'resumed', + 'completed', + 'cleared', + 'message', +]) /** * Check if text is a teammate-message (internal agent-to-agent communication). @@ -1070,6 +1110,151 @@ function readXmlTag(xml: string, tag: string): string | undefined { return match?.[1] ? decodeXmlText(match[1].trim()) : undefined } +function normalizeGoalEventData( + data: unknown, + fallbackMessage?: string, +): Omit, 'id' | 'type' | 'timestamp'> | null { + if (!data || typeof data !== 'object') { + const message = typeof fallbackMessage === 'string' ? fallbackMessage.trim() : '' + return message ? { action: 'message', message } : null + } + + const record = data as Record + const action = typeof record.action === 'string' && GOAL_EVENT_ACTIONS.has(record.action as GoalEventAction) + ? record.action as GoalEventAction + : 'message' + const read = (key: string) => + typeof record[key] === 'string' && record[key].trim() + ? record[key].trim() + : undefined + return { + action, + status: read('status'), + objective: read('objective'), + budget: read('budget'), + elapsed: read('elapsed'), + continuations: read('continuations'), + message: read('message') ?? (typeof fallbackMessage === 'string' ? fallbackMessage.trim() : undefined), + } +} + +function applyGoalEventToActiveGoal( + current: ActiveGoalState | null, + event: Omit, 'id' | 'type' | 'timestamp'>, + updatedAt: number, +): ActiveGoalState | null { + if (event.action === 'cleared') return null + if ( + event.action === 'message' && + event.message && + /no (active )?goal/i.test(event.message) + ) { + return null + } + if (event.action === 'message') return current + const baseGoal = event.action === 'created' || event.action === 'replaced' ? null : current + + return { + action: event.action, + status: event.status ?? (event.action === 'completed' ? 'complete' : baseGoal?.status), + objective: event.objective ?? baseGoal?.objective, + budget: event.budget ?? baseGoal?.budget, + elapsed: event.elapsed ?? baseGoal?.elapsed, + continuations: event.continuations ?? baseGoal?.continuations, + message: event.message ?? baseGoal?.message, + updatedAt, + } +} + +function deriveActiveGoalFromMessages(messages: UIMessage[]): ActiveGoalState | null { + return messages.reduce((activeGoal, message) => { + if (message.type !== 'goal_event') return activeGoal + return applyGoalEventToActiveGoal(activeGoal, message, message.timestamp) + }, null) +} + +function extractLocalCommandText(content: unknown): string | null { + if (typeof content !== 'string') return null + return content.trim() || null +} + +function parseGoalCommandFromLocalCommand(content: unknown): { name: string; args: string } | null { + const text = extractLocalCommandText(content) + if (!text) return null + const commandName = readXmlTag(text, 'command-name') + if (!commandName) return null + return { + name: commandName.replace(/^\//, ''), + args: readXmlTag(text, 'command-args') ?? '', + } +} + +function extractLocalCommandOutputText(content: unknown): string | null { + const text = extractLocalCommandText(content) + if (!text) return null + return readXmlTag(text, 'local-command-stdout') ?? readXmlTag(text, 'local-command-stderr') ?? null +} + +function parseGoalEventFromLocalCommandOutput( + output: string, + command: { name: string; args: string } | null, +): Omit, 'id' | 'type' | 'timestamp'> | null { + if (command && command.name !== 'goal') return null + const trimmed = output.trim() + if (!trimmed) return null + + if (trimmed === 'Goal cleared.') return { action: 'cleared', message: trimmed } + if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed } + if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return { action: 'message', message: trimmed } + + const actionPrefix = trimmed.startsWith('Goal replaced.\n') + ? 'replaced' + : trimmed.startsWith('Goal created.\n') + ? 'created' + : null + const body = actionPrefix + ? trimmed.split(/\r?\n/).slice(1).join('\n').trim() + : trimmed + + const fields = Object.fromEntries( + body + .split(/\r?\n/) + .map((line) => { + const index = line.indexOf(':') + if (index < 0) return null + return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()] + }) + .filter((entry): entry is [string, string] => entry !== null), + ) + if (!fields.goal) return command?.name === 'goal' ? { action: 'message', message: trimmed } : null + + return { + action: actionPrefix ?? resolveHistoryGoalAction(command, fields.goal), + status: fields.goal, + objective: fields.objective, + budget: fields.budget, + elapsed: fields.elapsed, + continuations: fields.continuations, + message: trimmed, + } +} + +function resolveHistoryGoalAction( + command: { name: string; args: string } | null, + status: string, +): GoalEventAction { + const args = command?.args.trim() ?? '' + if (args === 'pause') return 'paused' + if (args === 'resume') return 'resumed' + if (GOAL_CLEAR_ALIASES.has(args)) return 'cleared' + if (args === 'complete') return 'completed' + if (!args || args === 'status') return 'status' + if (status === 'active') return 'created' + if (status === 'paused') return 'paused' + if (status === 'complete') return 'completed' + return 'status' +} + function extractTaskNotification(content: unknown): AgentTaskNotification | null { const xml = extractHistoryTextBlocks(content) .map((text) => extractTaskNotificationXml(text)) @@ -1298,6 +1483,7 @@ export function mapHistoryMessagesToUiMessages( const includeTeammateMessages = options?.includeTeammateMessages === true const uiMessages: UIMessage[] = [] let suppressTaskNotificationResponse = false + let pendingGoalCommand: { name: string; args: string } | null = null for (const msg of messages) { if (msg.type === 'user' && isTaskNotificationContent(msg.content)) { @@ -1311,6 +1497,28 @@ export function mapHistoryMessagesToUiMessages( } const timestamp = new Date(msg.timestamp).getTime() + if (msg.type === 'system' && typeof msg.content === 'string') { + const localCommand = parseGoalCommandFromLocalCommand(msg.content) + if (localCommand) { + pendingGoalCommand = localCommand + continue + } + + const localCommandOutput = extractLocalCommandOutputText(msg.content) + if (localCommandOutput) { + const goalEvent = parseGoalEventFromLocalCommandOutput(localCommandOutput, pendingGoalCommand) + pendingGoalCommand = null + if (goalEvent) { + uiMessages.push({ + id: msg.id || nextId(), + type: 'goal_event', + ...goalEvent, + timestamp, + }) + } + continue + } + } if (msg.type === 'user' && typeof msg.content === 'string') { if (isTeammateMessage(msg.content)) { if (!includeTeammateMessages) continue diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index be0500d3..907a08ac 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -164,6 +164,19 @@ export type MemoryEventFile = { summary?: string } +export type GoalEventAction = 'created' | 'replaced' | 'status' | 'paused' | 'resumed' | 'completed' | 'cleared' | 'message' + +export type ActiveGoalState = { + action: Exclude + status?: string + objective?: string + budget?: string + elapsed?: string + continuations?: string + message?: string + updatedAt: number +} + // ─── UI Message model (rendered in MessageList) ─────────────────── export type TaskSummaryItem = { @@ -180,6 +193,18 @@ export type UIMessage = | { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string } | { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string } | { id: string; type: 'system'; content: string; timestamp: number } + | { + id: string + type: 'goal_event' + action: GoalEventAction + status?: string + objective?: string + budget?: string + elapsed?: string + continuations?: string + message?: string + timestamp: number + } | { id: string type: 'memory_event' diff --git a/src/commands/goal/goal.test.tsx b/src/commands/goal/goal.test.tsx new file mode 100644 index 00000000..db7ef697 --- /dev/null +++ b/src/commands/goal/goal.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'bun:test' +import { switchSession } from '../../bootstrap/state.js' +import type { SessionId } from '../../types/ids.js' +import { call } from './goal.js' + +async function runGoal(args: string) { + const calls: Array<{ + result?: string + options?: { + display?: string + shouldQuery?: boolean + metaMessages?: string[] + } + }> = [] + + await call( + (result, options) => { + calls.push({ result, options }) + }, + {} as never, + args, + ) + + expect(calls).toHaveLength(1) + return calls[0]! +} + +describe('/goal command', () => { + test('creates a goal and manages every subcommand in one CLI session', async () => { + switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) + + const created = await runGoal('--tokens 2k ship the smoke test') + expect(created.result).toContain('Goal created.') + expect(created.result).toContain('Goal: active') + expect(created.result).toContain('Objective: ship the smoke test') + expect(created.result).toContain('Budget: 0 / 2,000 tokens') + expect(created.options).toMatchObject({ + display: 'system', + shouldQuery: true, + }) + expect(created.options?.metaMessages?.[0]).toContain( + 'ship the smoke test', + ) + + const status = await runGoal('status') + expect(status.result).toContain('Goal: active') + expect(status.result).toContain('Objective: ship the smoke test') + expect(status.options).toMatchObject({ + display: 'system', + }) + + const replaced = await runGoal('ship the replacement target') + expect(replaced.result).toContain('Goal replaced.') + expect(replaced.result).toContain('Objective: ship the replacement target') + expect(replaced.result).toContain('Budget: 0 / unlimited tokens') + expect(replaced.options).toMatchObject({ + display: 'system', + shouldQuery: true, + }) + expect(replaced.options?.metaMessages?.[0]).toContain( + 'ship the replacement target', + ) + + const paused = await runGoal('pause') + expect(paused.result).toContain('Goal: paused') + expect(paused.options).toMatchObject({ + display: 'system', + }) + + const resumed = await runGoal('resume') + expect(resumed.result).toContain('Goal: active') + expect(resumed.options).toMatchObject({ + display: 'system', + shouldQuery: true, + }) + expect(resumed.options?.metaMessages?.[0]).toContain( + 'ship the replacement target', + ) + + const completed = await runGoal('complete') + expect(completed.result).toBe('Goal marked complete.') + expect(completed.options).toMatchObject({ + display: 'system', + }) + + const cleared = await runGoal('clear') + expect(cleared.result).toBe('Goal cleared.') + expect(cleared.options).toMatchObject({ + display: 'system', + }) + + const empty = await runGoal('') + expect(empty.result).toBe('No active goal.') + expect(empty.options).toMatchObject({ + display: 'system', + }) + }) + + test('reports usage errors without querying the model', async () => { + switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) + + const result = await runGoal('--tokens 2k') + + expect(result.result).toBe('Usage: /goal [--tokens ] ') + expect(result.options).toMatchObject({ + display: 'system', + }) + expect(result.options?.shouldQuery).toBeUndefined() + }) +}) diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx index 2d040b81..29acce87 100644 --- a/src/commands/goal/goal.tsx +++ b/src/commands/goal/goal.tsx @@ -53,11 +53,12 @@ export async function call( return null } + const replaced = Boolean(getThreadGoal(threadId)) const goal = setThreadGoal(threadId, { objective: parsed.objective, tokenBudget: parsed.tokenBudget, }) - onDone(formatGoalStatus(goal), { + onDone(`${replaced ? 'Goal replaced.' : 'Goal created.'}\n${formatGoalStatus(goal)}`, { display: 'system', shouldQuery: true, metaMessages: [buildGoalStartPrompt(goal)], diff --git a/src/goals/goalEvaluator.test.ts b/src/goals/goalEvaluator.test.ts index 5cdb3390..5fa77f73 100644 --- a/src/goals/goalEvaluator.test.ts +++ b/src/goals/goalEvaluator.test.ts @@ -133,4 +133,49 @@ describe('goalEvaluator', () => { 'The transcript shows the tests passed.', ) }) + + test('hydrates an active goal from persisted slash command history before continuing', async () => { + const threadId = 'thread-eval-hydrate' + + const decision = await evaluateThreadGoalAfterTurn({ + threadId, + messages: [ + createUserMessage({ + content: [ + '/goal', + 'ship persisted goal', + ].join('\n'), + }), + createUserMessage({ + content: [ + '', + 'Goal created.', + 'Goal: active', + 'Objective: ship persisted goal', + 'Budget: 42 / 2,000 tokens', + 'Elapsed: 1m', + 'Continuations: 3', + '', + ].join('\n'), + }), + createAssistantMessage({ + content: [{ type: 'text', text: 'Still need to run verification.' }], + }), + ], + assistantMessages: [], + signal: new AbortController().signal, + now: 120_000, + evaluate: async ({ goal }) => ({ + complete: false, + reason: `${goal.objective} is not verified.`, + }), + }) + + expect(decision.action).toBe('continue') + const restored = getThreadGoal(threadId) + expect(restored?.objective).toBe('ship persisted goal') + expect(restored?.tokensUsed).toBe(42) + expect(restored?.tokenBudget).toBe(2_000) + expect(restored?.continuationCount).toBe(4) + }) }) diff --git a/src/goals/goalEvaluator.ts b/src/goals/goalEvaluator.ts index 5bc8005e..948ad1b1 100644 --- a/src/goals/goalEvaluator.ts +++ b/src/goals/goalEvaluator.ts @@ -12,6 +12,7 @@ import { accountThreadGoalUsage, buildGoalContinuationPrompt, getThreadGoal, + hydrateThreadGoalFromMessages, incrementThreadGoalContinuation, markThreadGoalComplete, updateThreadGoalStatus, @@ -48,7 +49,9 @@ export async function evaluateThreadGoalAfterTurn(input: { evaluate?: EvaluateFn }): Promise { const now = input.now ?? Date.now() - const current = getThreadGoal(input.threadId) + const current = + getThreadGoal(input.threadId) ?? + hydrateThreadGoalFromMessages(input.threadId, input.messages, now) if (!current || current.status !== 'active') return { action: 'none' } const tokens = input.assistantMessages.reduce( diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts index c4f99424..61b29015 100644 --- a/src/goals/goalState.test.ts +++ b/src/goals/goalState.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + accountThreadGoalUsage, buildGoalContinuationPrompt, clearThreadGoal, formatGoalStatus, @@ -37,6 +38,30 @@ describe('goalState', () => { expect(formatGoalStatus(goal, 61_000)).toContain('Elapsed: 1m') }) + test('setting a new goal replaces the existing goal and resets accounting', () => { + const first = setThreadGoal('thread-replace', { + objective: 'first target', + tokenBudget: 10_000, + now: 1_000, + }) + accountThreadGoalUsage('thread-replace', 2_500, 2_000) + updateThreadGoalStatus('thread-replace', 'paused', 3_000) + + const replaced = setThreadGoal('thread-replace', { + objective: 'second target', + now: 4_000, + }) + + expect(replaced.goalId).not.toBe(first.goalId) + expect(replaced.objective).toBe('second target') + expect(replaced.status).toBe('active') + expect(replaced.tokenBudget).toBeNull() + expect(replaced.tokensUsed).toBe(0) + expect(replaced.continuationCount).toBe(0) + expect(replaced.createdAt).toBe(4_000) + expect(formatGoalStatus(replaced, 4_000)).toContain('Budget: 0 / unlimited tokens') + }) + test('pause, resume, complete, and clear are scoped to the thread', () => { setThreadGoal('thread-a', { objective: 'ship feature', now: 1_000 }) setThreadGoal('thread-b', { objective: 'different work', now: 1_000 }) diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts index f37724fd..7b7ddb76 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -1,4 +1,9 @@ import { randomUUID } from 'crypto' +import { + COMMAND_NAME_TAG, + LOCAL_COMMAND_STDOUT_TAG, +} from '../constants/xml.js' +import type { Message } from '../types/message.js' export type ThreadGoalStatus = 'active' | 'paused' | 'complete' | 'budget_limited' @@ -64,20 +69,16 @@ export function setThreadGoal( }, ): ThreadGoal { const now = input.now ?? Date.now() - const existing = goalsByThread.get(threadId) const goal: ThreadGoal = { - goalId: existing?.goalId ?? randomUUID(), + goalId: randomUUID(), threadId, objective: input.objective.trim(), status: 'active', - tokenBudget: - input.tokenBudget !== undefined - ? input.tokenBudget - : (existing?.tokenBudget ?? null), - tokensUsed: existing?.tokensUsed ?? 0, + tokenBudget: input.tokenBudget ?? null, + tokensUsed: 0, continuationCount: 0, lastReason: null, - createdAt: existing?.createdAt ?? now, + createdAt: now, updatedAt: now, } goalsByThread.set(threadId, goal) @@ -88,6 +89,38 @@ export function getThreadGoal(threadId: string): ThreadGoal | null { return goalsByThread.get(threadId) ?? null } +export function hydrateThreadGoalFromMessages( + threadId: string, + messages: Message[], + now = Date.now(), +): ThreadGoal | null { + if (goalsByThread.has(threadId)) return goalsByThread.get(threadId) ?? null + + let pendingGoalCommand = false + let restored: ThreadGoal | null = null + + for (const message of messages) { + const text = messageToText(message) + if (!text) continue + + const commandName = readXmlTag(text, COMMAND_NAME_TAG) + if (commandName) { + pendingGoalCommand = commandName.replace(/^\//, '') === 'goal' + continue + } + + const output = readXmlTag(text, LOCAL_COMMAND_STDOUT_TAG) + if (!output) continue + if (!pendingGoalCommand && !looksLikeGoalStatusOutput(output)) continue + + restored = goalFromLocalCommandOutput(threadId, output, restored, now) + pendingGoalCommand = false + } + + if (restored) goalsByThread.set(threadId, restored) + return restored +} + export function clearThreadGoal(threadId: string): boolean { return goalsByThread.delete(threadId) } @@ -216,3 +249,137 @@ function formatElapsed(ms: number): string { if (minutes > 0) return `${minutes}m` return `${seconds}s` } + +function goalFromLocalCommandOutput( + threadId: string, + output: string, + current: ThreadGoal | null, + now: number, +): ThreadGoal | null { + const trimmed = output.trim() + if ( + trimmed === 'Goal cleared.' || + trimmed === 'No active goal.' || + trimmed === 'No goal to resume.' + ) { + return null + } + if (trimmed === 'Goal marked complete.') { + return current ? { ...current, status: 'complete', updatedAt: now } : null + } + + const body = trimmed.startsWith('Goal created.\n') || trimmed.startsWith('Goal replaced.\n') + ? trimmed.split(/\r?\n/).slice(1).join('\n').trim() + : trimmed + const fields = parseStatusFields(body) + const status = parseGoalStatus(fields.goal) + if (!status || !fields.objective) return current + + const budget = parseBudget(fields.budget) + const elapsedMs = parseElapsed(fields.elapsed) + return { + goalId: randomUUID(), + threadId, + objective: fields.objective, + status, + tokenBudget: budget.tokenBudget, + tokensUsed: budget.tokensUsed, + continuationCount: parseInteger(fields.continuations) ?? 0, + lastReason: fields['latest reason'] ?? null, + createdAt: now - elapsedMs, + updatedAt: now, + } +} + +function messageToText(message: Message): string { + if (message.type === 'system') { + return typeof message.content === 'string' ? message.content : '' + } + if (!('message' in message)) return '' + const content = message.message?.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map((block) => { + if (!block || typeof block !== 'object') return '' + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? text : '' + }) + .filter(Boolean) + .join('\n') +} + +function readXmlTag(text: string, tag: string): string | null { + const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = text.match(new RegExp(`<${escaped}>([\\s\\S]*?)`, 'i')) + return match?.[1]?.trim() ?? null +} + +function looksLikeGoalStatusOutput(output: string): boolean { + const trimmed = output.trim() + return ( + trimmed.startsWith('Goal created.\n') || + trimmed.startsWith('Goal replaced.\n') || + trimmed.startsWith('Goal: ') || + trimmed === 'Goal cleared.' || + trimmed === 'Goal marked complete.' + ) +} + +function parseStatusFields(output: string): Record { + return Object.fromEntries( + output + .split(/\r?\n/) + .map((line) => { + const index = line.indexOf(':') + if (index < 0) return null + return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()] + }) + .filter((entry): entry is [string, string] => entry !== null), + ) +} + +function parseGoalStatus(raw: string | undefined): ThreadGoalStatus | null { + if ( + raw === 'active' || + raw === 'paused' || + raw === 'complete' || + raw === 'budget_limited' + ) { + return raw + } + return null +} + +function parseBudget(raw: string | undefined): { + tokenBudget: number | null + tokensUsed: number +} { + if (!raw) return { tokenBudget: null, tokensUsed: 0 } + const match = raw.match(/^([\d,]+)\s*\/\s*(unlimited|[\d,]+)\s+tokens$/i) + if (!match) return { tokenBudget: null, tokensUsed: 0 } + return { + tokensUsed: parseInteger(match[1]) ?? 0, + tokenBudget: match[2]?.toLowerCase() === 'unlimited' + ? null + : parseInteger(match[2]) ?? null, + } +} + +function parseElapsed(raw: string | undefined): number { + if (!raw) return 0 + let ms = 0 + for (const match of raw.matchAll(/(\d+)\s*([hms])/g)) { + const value = Number(match[1]) + if (match[2] === 'h') ms += value * 60 * 60 * 1000 + if (match[2] === 'm') ms += value * 60 * 1000 + if (match[2] === 's') ms += value * 1000 + } + return ms +} + +function parseInteger(raw: string | undefined): number | null { + if (!raw) return null + const value = Number(raw.replace(/,/g, '')) + return Number.isFinite(value) ? value : null +} diff --git a/src/query.ts b/src/query.ts index a0bf10df..646af94e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -48,6 +48,7 @@ import { createUserInterruptionMessage, normalizeMessagesForAPI, createSystemMessage, + createCommandInputMessage, createAssistantAPIErrorMessage, getMessagesAfterCompactBoundary, createToolUseSummaryMessage, @@ -1343,6 +1344,12 @@ async function* queryLoop( continue } + if (goalDecision.action === 'complete') { + yield createCommandInputMessage( + 'Goal marked complete.', + ) + } + if (goalDecision.action === 'budget_limited') { logForDebugging('Goal stopped because its budget was reached') } diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index f9da55f9..86d7e301 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -35,6 +35,181 @@ describe('WebSocket memory events', () => { }) }) +describe('WebSocket goal command events', () => { + const goalStatusOutput = [ + 'Goal created.', + 'Goal: active', + 'Objective: ship the smoke test', + 'Budget: 0 / 2,000 tokens', + 'Elapsed: 0s', + 'Continuations: 0', + ].join('\n') + + const runGoalCommand = (sessionId: string, args: string, output: string, type: 'system' | 'user' = 'system') => { + expect(translateCliMessage({ + type: 'system', + subtype: 'local_command', + content: [ + { text: '/goal' }, + { text: `${args}` }, + ], + }, sessionId)).toEqual([]) + + if (type === 'user') { + return translateCliMessage({ + type: 'user', + message: { + content: [{ + type: 'text', + text: `${output}`, + }], + }, + }, sessionId) + } + + return translateCliMessage({ + type: 'system', + subtype: 'local_command_output', + content: `${output}`, + }, sessionId) + } + + it('turns confirmed /goal local command output into a desktop goal event', () => { + const sessionId = `goal-event-${crypto.randomUUID()}` + + expect(translateCliMessage({ + type: 'system', + subtype: 'local_command', + content: '/goal\n--tokens 2k ship the smoke test', + }, sessionId)).toEqual([]) + + expect(translateCliMessage({ + type: 'system', + subtype: 'local_command', + content: [ + '', + goalStatusOutput, + '', + ].join('\n'), + }, sessionId)).toEqual([ + { + type: 'system_notification', + subtype: 'goal_event', + message: goalStatusOutput, + data: { + action: 'created', + status: 'active', + objective: 'ship the smoke test', + budget: '0 / 2,000 tokens', + elapsed: '0s', + continuations: '0', + message: goalStatusOutput, + }, + }, + ]) + }) + + it('classifies /goal lifecycle subcommand output for the desktop client', () => { + const statusOutput = goalStatusOutput.split('\n').slice(1).join('\n') + + expect(runGoalCommand(`goal-status-${crypto.randomUUID()}`, 'status', statusOutput)).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + data: expect.objectContaining({ action: 'status', status: 'active' }), + }), + ]) + + expect(runGoalCommand(`goal-pause-${crypto.randomUUID()}`, 'pause', 'Goal: paused\nObjective: ship docs')).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + data: expect.objectContaining({ action: 'paused', status: 'paused' }), + }), + ]) + + expect(runGoalCommand(`goal-resume-${crypto.randomUUID()}`, 'resume', statusOutput)).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + data: expect.objectContaining({ action: 'resumed', status: 'active' }), + }), + ]) + + expect(runGoalCommand(`goal-complete-${crypto.randomUUID()}`, 'complete', 'Goal marked complete.')).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + data: { action: 'completed', message: 'Goal marked complete.' }, + }), + ]) + + expect(runGoalCommand(`goal-clear-${crypto.randomUUID()}`, 'clear', 'Goal cleared.')).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + data: { action: 'cleared', message: 'Goal cleared.' }, + }), + ]) + }) + + it('marks replacement output distinctly for the desktop client', () => { + const output = [ + 'Goal replaced.', + 'Goal: active', + 'Objective: ship the replacement target', + 'Budget: 0 / unlimited tokens', + 'Elapsed: 0s', + 'Continuations: 0', + ].join('\n') + + expect(runGoalCommand(`goal-replaced-${crypto.randomUUID()}`, 'ship the replacement target', output)).toEqual([ + expect.objectContaining({ + type: 'system_notification', + subtype: 'goal_event', + message: output, + data: expect.objectContaining({ + action: 'replaced', + status: 'active', + objective: 'ship the replacement target', + budget: '0 / unlimited tokens', + continuations: '0', + }), + }), + ]) + }) + + it('keeps negative /goal command output visible as a goal message event', () => { + expect(runGoalCommand(`goal-empty-${crypto.randomUUID()}`, '', 'No active goal.', 'user')).toEqual([ + { + type: 'system_notification', + subtype: 'goal_event', + message: 'No active goal.', + data: { action: 'message', message: 'No active goal.' }, + }, + ]) + }) + + it('does not turn unrelated local command output into a goal event', () => { + const sessionId = `goal-unrelated-${crypto.randomUUID()}` + + expect(translateCliMessage({ + type: 'system', + subtype: 'local_command', + content: '/status', + }, sessionId)).toEqual([]) + + expect(translateCliMessage({ + type: 'system', + subtype: 'local_command_output', + content: 'Goal: active', + }, sessionId)).toEqual([ + { type: 'content_start', blockType: 'text' }, + { type: 'content_delta', text: 'Goal: active' }, + ]) + }) +}) + describe('WebSocket stream event translation', () => { it('keeps DeepSeek-style thinking blocks in thinking state until text starts', () => { const sessionId = `deepseek-thinking-${crypto.randomUUID()}` diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 553b8b43..f6aaa373 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -21,6 +21,7 @@ import { diagnosticsService } from '../services/diagnosticsService.js' import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { + COMMAND_NAME_TAG, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, } from '../../constants/xml.js' @@ -700,6 +701,7 @@ type SessionStreamState = { hasReceivedStreamEvents: boolean activeBlockTypes: Map activeToolBlocks: Map + pendingLocalCommand?: { name: string; args: string } /** Tool blocks whose input JSON failed to parse in content_block_stop. * The assistant message carries the complete input — defer to that. */ pendingToolBlocks: Map @@ -718,6 +720,7 @@ function getStreamState(sessionId: string): SessionStreamState { hasReceivedStreamEvents: false, activeBlockTypes: new Map(), activeToolBlocks: new Map(), + pendingLocalCommand: undefined, pendingToolBlocks: new Map(), lastApiError: undefined, } @@ -962,8 +965,22 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa cliMsg.message?.content, ) if (localCommandOutput) { - messages.push({ type: 'content_start', blockType: 'text' }) - messages.push({ type: 'content_delta', text: localCommandOutput }) + const goalEvent = extractGoalEvent( + localCommandOutput, + streamState.pendingLocalCommand, + ) + streamState.pendingLocalCommand = undefined + if (goalEvent) { + messages.push({ + type: 'system_notification', + subtype: 'goal_event', + message: goalEvent.message, + data: goalEvent, + }) + } else { + messages.push({ type: 'content_start', blockType: 'text' }) + messages.push({ type: 'content_delta', text: localCommandOutput }) + } } if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) { @@ -1210,11 +1227,30 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa return [] } if (subtype === 'local_command' || subtype === 'local_command_output') { + const localCommand = extractLocalCommand(cliMsg.content ?? cliMsg.message) + if (localCommand) { + streamState.pendingLocalCommand = localCommand + return [] + } + const localCommandOutput = extractLocalCommandOutput( cliMsg.content ?? cliMsg.message, { allowUntagged: subtype === 'local_command_output' }, ) if (!localCommandOutput) return [] + const goalEvent = extractGoalEvent( + localCommandOutput, + streamState.pendingLocalCommand, + ) + streamState.pendingLocalCommand = undefined + if (goalEvent) { + return [{ + type: 'system_notification', + subtype: 'goal_event', + message: goalEvent.message, + data: goalEvent, + }] + } return [ { type: 'content_start', blockType: 'text' }, { type: 'content_delta', text: localCommandOutput }, @@ -1325,6 +1361,103 @@ function extractTaggedContent(raw: string, tag: string): string | null { return match?.[1]?.trim() ?? null } +function extractLocalCommand(content: unknown): { name: string; args: string } | null { + const raw = typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }) + .join('\n') + : '' + + const name = extractTaggedContent(raw, COMMAND_NAME_TAG) + if (!name) return null + return { + name: name.replace(/^\//, ''), + args: extractTaggedContent(raw, 'command-args') ?? '', + } +} + +type GoalEventData = { + action: 'created' | 'replaced' | 'status' | 'paused' | 'resumed' | 'completed' | 'cleared' | 'message' + status?: string + objective?: string + budget?: string + elapsed?: string + continuations?: string + message?: string +} + +function extractGoalEvent( + output: string, + command?: { name: string; args: string }, +): GoalEventData | null { + if (command && command.name !== 'goal') return null + + const trimmed = output.trim() + if (!trimmed) return null + + if (trimmed === 'Goal cleared.') { + return { action: 'cleared', message: trimmed } + } + if (trimmed === 'Goal marked complete.') { + return { action: 'completed', message: trimmed } + } + if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') { + return { action: 'message', message: trimmed } + } + + const actionPrefix = trimmed.startsWith('Goal replaced.\n') + ? 'replaced' + : trimmed.startsWith('Goal created.\n') + ? 'created' + : null + const body = actionPrefix + ? trimmed.split(/\r?\n/).slice(1).join('\n').trim() + : trimmed + + const lines = Object.fromEntries( + body + .split(/\r?\n/) + .map((line) => { + const index = line.indexOf(':') + if (index < 0) return null + return [line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()] + }) + .filter((entry): entry is [string, string] => entry !== null), + ) + + if (!lines.goal) return command?.name === 'goal' ? { action: 'message', message: trimmed } : null + + return { + action: actionPrefix ?? resolveGoalEventAction(command, lines.goal), + status: lines.goal, + objective: lines.objective, + budget: lines.budget, + elapsed: lines.elapsed, + continuations: lines.continuations, + message: trimmed, + } +} + +function resolveGoalEventAction( + command: { name: string; args: string } | undefined, + status: string, +): GoalEventData['action'] { + const args = command?.args.trim() ?? '' + if (args === 'pause') return 'paused' + if (args === 'resume') return 'resumed' + if (!args || args === 'status') return 'status' + if (status === 'paused') return 'paused' + if (status === 'complete') return 'completed' + if (status === 'active') return 'created' + return 'status' +} + function getCompactBoundaryMessage(cliMsg: any): string { const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : '' if (message) return message