mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge commit '376fc6de'
This commit is contained in:
commit
62713f30de
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)]">
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
110
src/commands/goal/goal.test.tsx
Normal file
110
src/commands/goal/goal.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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)],
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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()}`
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user