Merge commit '376fc6de'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 23:27:02 +08:00
commit 62713f30de
19 changed files with 1439 additions and 16 deletions

View File

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

View File

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

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
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 (
<div className="mb-3 flex justify-center">
<div className="flex max-w-[min(680px,100%)] items-start gap-3 rounded-[8px] border border-[var(--color-success)]/25 bg-[var(--color-success-container)]/28 px-3.5 py-3 text-left shadow-sm">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[var(--color-success)]/12 text-[var(--color-success)]">
<Target size={15} strokeWidth={2.25} aria-hidden="true" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
{details.length > 0 ? (
<div className="mt-1 space-y-0.5 text-xs leading-5 text-[var(--color-text-secondary)]">
{details.map((detail) => (
<div key={detail} className="break-words">{detail}</div>
))}
</div>
) : message.message ? (
<div className="mt-1 whitespace-pre-wrap text-xs leading-5 text-[var(--color-text-secondary)]">
{message.message}
</div>
) : null}
</div>
</div>
</div>
)
}
function SelectableChatMessage({
sessionId,
messageId,
@ -1126,6 +1163,8 @@ export const MessageBlock = memo(function MessageBlock({
return <InlineTaskSummary tasks={message.tasks} />
case 'memory_event':
return <MemoryEventCard message={message} />
case 'goal_event':
return <GoalEventCard message={message} />
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">

View File

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

View File

@ -890,6 +890,26 @@ export const zh: Record<TranslationKey, string> = {
'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}',

View File

@ -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(<ActiveSession />)
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()

View File

@ -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 (
<div
data-testid="active-goal-panel"
className={
compact
? 'border-b border-[var(--color-success)]/20 bg-[var(--color-success)]/6 px-4 py-2'
: 'mx-auto w-full max-w-[860px] border-b border-[var(--color-success)]/20 px-8 py-2'
}
>
<div className="flex min-w-0 items-center gap-3 rounded-md border border-[var(--color-success)]/25 bg-[var(--color-success)]/8 px-3 py-2">
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-success)]">track_changes</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-normal text-[var(--color-success)]">
{t('chat.activeGoal.title')}
</span>
<span className="shrink-0 rounded-sm bg-[var(--color-success)]/12 px-1.5 py-0.5 text-[10px] font-semibold text-[var(--color-success)]">
{stateLabel}
</span>
{goal.objective && (
<span className="truncate text-xs font-semibold text-[var(--color-text-primary)]">
{goal.objective}
</span>
)}
</div>
{(goal.budget || goal.continuations || goal.elapsed) && (
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-[var(--color-text-secondary)]">
{goal.budget && <span>{t('chat.activeGoal.budget', { value: goal.budget })}</span>}
{goal.continuations && <span>{t('chat.activeGoal.continuations', { value: goal.continuations })}</span>}
{goal.elapsed && <span>{t('chat.activeGoal.elapsed', { value: goal.elapsed })}</span>}
</div>
)}
</div>
</div>
</div>
)
}
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() {
</div>
)}
{activeGoal && (
<GoalStatusPanel
goal={activeGoal}
isRunning={isActive}
compact={showWorkspacePanel || isMobileLayout}
/>
)}
<MessageList compact={showWorkspacePanel} />
</>
)}

View File

@ -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: '<command-name>/goal</command-name>\n<command-args>--tokens 2k ship the smoke test</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship the smoke test',
'Budget: 0 / 2,000 tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].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: '<command-name>/goal</command-name>\n<command-args>ship the replacement target</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal replaced.',
'Goal: active',
'Objective: ship the replacement target',
'Budget: 0 / unlimited tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].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: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship the smoke test',
'Budget: 0 / 2,000 tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
},
{
id: 'goal-complete',
type: 'system',
timestamp: '2026-04-06T00:00:02.000Z',
content: '<local-command-stdout>Goal marked complete.</local-command-stdout>',
},
],
})
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: {

View File

@ -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<string, AgentTaskNotification>
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | 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<ChatStore>((set, get) => ({
...createDefaultSessionState(),
connectionState: 'connecting',
messages: existing?.messages ?? [],
activeGoal: existing?.activeGoal ?? null,
},
},
}))
@ -527,6 +534,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
try {
const {
uiMessages,
activeGoal,
restoredNotifications,
lastTodos,
hasMessagesAfterTaskCompletion,
@ -536,6 +544,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((set, get) => ({
try {
const {
uiMessages,
activeGoal,
restoredNotifications,
lastTodos,
hasMessagesAfterTaskCompletion,
@ -569,6 +579,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<string, unknown>
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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const GOAL_CLEAR_ALIASES = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel'])
const GOAL_EVENT_ACTIONS = new Set<GoalEventAction>([
'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<Extract<UIMessage, { type: 'goal_event' }>, '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<string, unknown>
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<Extract<UIMessage, { type: 'goal_event' }>, '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<ActiveGoalState | null>((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<Extract<UIMessage, { type: 'goal_event' }>, '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

View File

@ -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<GoalEventAction, 'cleared' | 'message'>
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'

View File

@ -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(
'<objective>ship the smoke test</objective>',
)
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(
'<objective>ship the replacement target</objective>',
)
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(
'<objective>ship the replacement target</objective>',
)
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 <budget>] <objective>')
expect(result.options).toMatchObject({
display: 'system',
})
expect(result.options?.shouldQuery).toBeUndefined()
})
})

View File

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

View File

@ -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: [
'<command-name>/goal</command-name>',
'<command-args>ship persisted goal</command-args>',
].join('\n'),
}),
createUserMessage({
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship persisted goal',
'Budget: 42 / 2,000 tokens',
'Elapsed: 1m',
'Continuations: 3',
'</local-command-stdout>',
].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)
})
})

View File

@ -12,6 +12,7 @@ import {
accountThreadGoalUsage,
buildGoalContinuationPrompt,
getThreadGoal,
hydrateThreadGoalFromMessages,
incrementThreadGoalContinuation,
markThreadGoalComplete,
updateThreadGoalStatus,
@ -48,7 +49,9 @@ export async function evaluateThreadGoalAfterTurn(input: {
evaluate?: EvaluateFn
}): Promise<GoalTurnDecision> {
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(

View File

@ -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 })

View File

@ -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]*?)</${escaped}>`, '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<string, string> {
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
}

View File

@ -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(
'<local-command-stdout>Goal marked complete.</local-command-stdout>',
)
}
if (goalDecision.action === 'budget_limited') {
logForDebugging('Goal stopped because its budget was reached')
}

View File

@ -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: '<command-name>/goal</command-name>' },
{ text: `<command-args>${args}</command-args>` },
],
}, sessionId)).toEqual([])
if (type === 'user') {
return translateCliMessage({
type: 'user',
message: {
content: [{
type: 'text',
text: `<local-command-stdout>${output}</local-command-stdout>`,
}],
},
}, sessionId)
}
return translateCliMessage({
type: 'system',
subtype: 'local_command_output',
content: `<local-command-stdout>${output}</local-command-stdout>`,
}, 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: '<command-name>/goal</command-name>\n<command-args>--tokens 2k ship the smoke test</command-args>',
}, sessionId)).toEqual([])
expect(translateCliMessage({
type: 'system',
subtype: 'local_command',
content: [
'<local-command-stdout>',
goalStatusOutput,
'</local-command-stdout>',
].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: '<command-name>/status</command-name>',
}, sessionId)).toEqual([])
expect(translateCliMessage({
type: 'system',
subtype: 'local_command_output',
content: '<local-command-stdout>Goal: active</local-command-stdout>',
}, 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()}`

View File

@ -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<number, 'text' | 'tool_use' | 'thinking'>
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string }>
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<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
@ -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