mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Reveal background agent progress during goal runs
Desktop sessions previously only showed the final local-agent output, so long verification phases looked stalled even while the CLI was still working. The websocket bridge now forwards task start/progress events, and the desktop session state renders them as a compact background-agent panel below the active goal. Constraint: CLI already emits task lifecycle events; desktop needed to consume the existing stream instead of polling transcript files. Rejected: Wait for final task notifications only | that keeps the long-running phase invisible. Confidence: high Scope-risk: moderate Directive: Keep task_started/task_progress available as structured desktop notifications when changing CLI event translation. Tested: bun run verify (passed=8 failed=0 skipped=2) Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:native Tested: bun run check:coverage Not-tested: Post-formatting check:server rerun was intentionally stopped at user request.
This commit is contained in:
parent
2d7d11d822
commit
3fb3d24911
@ -910,6 +910,14 @@ export const en = {
|
||||
'chat.activeGoal.budget': 'Budget {value}',
|
||||
'chat.activeGoal.continuations': 'Continuations {value}',
|
||||
'chat.activeGoal.elapsed': 'Elapsed {value}',
|
||||
'chat.backgroundAgents.title': 'Background agents',
|
||||
'chat.backgroundAgents.count': '{count} active or recent',
|
||||
'chat.backgroundAgents.agent': 'agent',
|
||||
'chat.backgroundAgents.tokens': '{count} tokens',
|
||||
'chat.backgroundAgents.status.running': 'running',
|
||||
'chat.backgroundAgents.status.completed': 'completed',
|
||||
'chat.backgroundAgents.status.failed': 'failed',
|
||||
'chat.backgroundAgents.status.stopped': 'stopped',
|
||||
'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}',
|
||||
|
||||
@ -912,6 +912,14 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.activeGoal.budget': '预算 {value}',
|
||||
'chat.activeGoal.continuations': '续作次数 {value}',
|
||||
'chat.activeGoal.elapsed': '已用时 {value}',
|
||||
'chat.backgroundAgents.title': '后台 Agent',
|
||||
'chat.backgroundAgents.count': '{count} 个运行中或最近任务',
|
||||
'chat.backgroundAgents.agent': 'agent',
|
||||
'chat.backgroundAgents.tokens': '{count} tokens',
|
||||
'chat.backgroundAgents.status.running': '运行中',
|
||||
'chat.backgroundAgents.status.completed': '已完成',
|
||||
'chat.backgroundAgents.status.failed': '失败',
|
||||
'chat.backgroundAgents.status.stopped': '已停止',
|
||||
'slash.mcp.title': '可用 MCP 工具',
|
||||
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
|
||||
'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP:{path}',
|
||||
|
||||
@ -207,6 +207,83 @@ describe('ActiveSession task polling', () => {
|
||||
expect(panel).toHaveTextContent('续作次数 0')
|
||||
})
|
||||
|
||||
it('shows background agent progress below the goal panel', () => {
|
||||
const sessionId = 'background-agent-visible-session'
|
||||
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Background Agent 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: 'Background Agent Session', type: 'session', status: 'running' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [],
|
||||
activeGoal: {
|
||||
action: 'created',
|
||||
status: 'active',
|
||||
objective: 'ship the smoke test',
|
||||
updatedAt: 1,
|
||||
},
|
||||
backgroundAgentTasks: {
|
||||
'agent-task-1': {
|
||||
taskId: 'agent-task-1',
|
||||
toolUseId: 'agent-tool-1',
|
||||
status: 'running',
|
||||
taskType: 'local_agent',
|
||||
description: 'Verify the todo app',
|
||||
summary: 'Running Playwright checks',
|
||||
usage: {
|
||||
totalTokens: 1200,
|
||||
toolUses: 4,
|
||||
durationMs: 45000,
|
||||
},
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
chatState: 'tool_executing',
|
||||
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('background-agent-panel')
|
||||
expect(panel).toHaveTextContent('后台 Agent')
|
||||
expect(panel).toHaveTextContent('运行中')
|
||||
expect(panel).toHaveTextContent('local_agent')
|
||||
expect(panel).toHaveTextContent('Running Playwright checks')
|
||||
})
|
||||
|
||||
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@ -28,8 +28,8 @@ 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'
|
||||
import { Target } from 'lucide-react'
|
||||
import type { ActiveGoalState, BackgroundAgentTask } from '../types/chat'
|
||||
import { Bot, CheckCircle2, LoaderCircle, Target, XCircle } from 'lucide-react'
|
||||
|
||||
const TASK_POLL_INTERVAL_MS = 1000
|
||||
const WORKSPACE_RESIZE_STEP = 32
|
||||
@ -274,6 +274,92 @@ function GoalStatusPanel({
|
||||
)
|
||||
}
|
||||
|
||||
function formatBackgroundTaskDuration(durationMs?: number) {
|
||||
if (typeof durationMs !== 'number' || durationMs < 0) return null
|
||||
const seconds = Math.round(durationMs / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
|
||||
function BackgroundAgentTasksPanel({
|
||||
tasks,
|
||||
compact,
|
||||
}: {
|
||||
tasks: BackgroundAgentTask[]
|
||||
compact: boolean
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="background-agent-panel"
|
||||
className={
|
||||
compact
|
||||
? 'mx-auto w-full max-w-[860px] px-4 py-2'
|
||||
: 'mx-auto w-full max-w-[860px] px-8 py-2.5'
|
||||
}
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
|
||||
<Bot size={15} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-secondary)]" aria-hidden="true" />
|
||||
<span className="shrink-0 text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{t('chat.backgroundAgents.title')}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{t('chat.backgroundAgents.count', { count: tasks.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{tasks.map((task) => {
|
||||
const isRunning = task.status === 'running'
|
||||
const isFailed = task.status === 'failed' || task.status === 'stopped'
|
||||
const duration = formatBackgroundTaskDuration(task.usage?.durationMs)
|
||||
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
|
||||
return (
|
||||
<div key={task.taskId} className="flex min-w-0 items-start gap-2 px-3 py-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
{isRunning ? (
|
||||
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-accent)]" aria-hidden="true" />
|
||||
) : isFailed ? (
|
||||
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
|
||||
) : (
|
||||
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
|
||||
{task.taskType || t('chat.backgroundAgents.agent')}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t(`chat.backgroundAgents.status.${task.status}`)}
|
||||
</span>
|
||||
{task.usage?.totalTokens ? (
|
||||
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
|
||||
{t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })}
|
||||
</span>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[12px] text-[var(--color-text-secondary)]">
|
||||
{detail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActiveSession() {
|
||||
const isMobileLayout = useMobileViewport() && !isTauriRuntime()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
@ -287,6 +373,15 @@ export function ActiveSession() {
|
||||
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const activeGoal = sessionState?.activeGoal ?? null
|
||||
const backgroundAgentTasks = useMemo(
|
||||
() => Object.values(sessionState?.backgroundAgentTasks ?? {})
|
||||
.sort((a, b) => {
|
||||
if (a.status === 'running' && b.status !== 'running') return -1
|
||||
if (a.status !== 'running' && b.status === 'running') return 1
|
||||
return b.updatedAt - a.updatedAt
|
||||
}),
|
||||
[sessionState?.backgroundAgentTasks],
|
||||
)
|
||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||
|
||||
const session = sessions.find((s) => s.id === activeTabId)
|
||||
@ -503,6 +598,10 @@ export function ActiveSession() {
|
||||
compact={showWorkspacePanel || isMobileLayout}
|
||||
/>
|
||||
)}
|
||||
<BackgroundAgentTasksPanel
|
||||
tasks={backgroundAgentTasks}
|
||||
compact={showWorkspacePanel || isMobileLayout}
|
||||
/>
|
||||
|
||||
<MessageList compact={showWorkspacePanel} />
|
||||
</>
|
||||
|
||||
@ -150,6 +150,7 @@ function makeSession(overrides: Partial<PerSessionState> = {}): PerSessionState
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
backgroundAgentTasks: {},
|
||||
elapsedTimer: null,
|
||||
...overrides,
|
||||
}
|
||||
@ -1127,6 +1128,98 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks background agent task lifecycle events for desktop visibility', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({
|
||||
chatState: 'tool_executing',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
data: {
|
||||
task_id: 'agent-task-1',
|
||||
tool_use_id: 'agent-tool-1',
|
||||
description: 'Verify the todo app',
|
||||
task_type: 'local_agent',
|
||||
prompt: 'Run E2E verification',
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||||
taskId: 'agent-task-1',
|
||||
toolUseId: 'agent-tool-1',
|
||||
status: 'running',
|
||||
description: 'Verify the todo app',
|
||||
taskType: 'local_agent',
|
||||
prompt: 'Run E2E verification',
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_progress',
|
||||
data: {
|
||||
task_id: 'agent-task-1',
|
||||
tool_use_id: 'agent-tool-1',
|
||||
description: 'Verify the todo app',
|
||||
summary: 'Running Playwright checks',
|
||||
last_tool_name: 'Bash',
|
||||
usage: {
|
||||
total_tokens: 1200,
|
||||
tool_uses: 4,
|
||||
duration_ms: 45000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||||
status: 'running',
|
||||
summary: 'Running Playwright checks',
|
||||
lastToolName: 'Bash',
|
||||
usage: {
|
||||
totalTokens: 1200,
|
||||
toolUses: 4,
|
||||
durationMs: 45000,
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_notification',
|
||||
data: {
|
||||
task_id: 'agent-task-1',
|
||||
tool_use_id: 'agent-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
output_file: '/tmp/agent-output.txt',
|
||||
usage: {
|
||||
total_tokens: 2400,
|
||||
tool_uses: 9,
|
||||
duration_ms: 120000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||||
status: 'completed',
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
outputFile: '/tmp/agent-output.txt',
|
||||
usage: {
|
||||
totalTokens: 2400,
|
||||
toolUses: 9,
|
||||
durationMs: 120000,
|
||||
},
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications['agent-tool-1']).toMatchObject({
|
||||
status: 'completed',
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
outputFile: '/tmp/agent-output.txt',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears local desktop chat state when the server confirms /clear', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@ -17,6 +17,8 @@ import type {
|
||||
ActiveGoalState,
|
||||
AgentTaskNotification,
|
||||
AttachmentRef,
|
||||
BackgroundAgentTask,
|
||||
BackgroundAgentTaskUsage,
|
||||
ChatState,
|
||||
ComputerUsePermissionRequest,
|
||||
ComputerUsePermissionResponse,
|
||||
@ -55,6 +57,7 @@ export type PerSessionState = {
|
||||
statusVerb: string
|
||||
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
|
||||
agentTaskNotifications: Record<string, AgentTaskNotification>
|
||||
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
|
||||
activeGoal?: ActiveGoalState | null
|
||||
elapsedTimer: ReturnType<typeof setInterval> | null
|
||||
composerPrefill?: {
|
||||
@ -80,6 +83,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
backgroundAgentTasks: {},
|
||||
activeGoal: null,
|
||||
elapsedTimer: null,
|
||||
composerPrefill: null,
|
||||
@ -929,6 +933,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
slashCommands: [],
|
||||
activeGoal: null,
|
||||
backgroundAgentTasks: {},
|
||||
agentTaskNotifications: {},
|
||||
}))
|
||||
clearPendingDelta(sessionId)
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
@ -988,38 +994,52 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}))
|
||||
}
|
||||
}
|
||||
if ((msg.subtype === 'task_started' || msg.subtype === 'task_progress') && msg.data && typeof msg.data === 'object') {
|
||||
const taskEvent = normalizeBackgroundAgentTaskEvent(msg.data, msg.subtype)
|
||||
if (taskEvent) {
|
||||
const now = Date.now()
|
||||
update((session) => ({
|
||||
backgroundAgentTasks: upsertBackgroundAgentTask(
|
||||
session.backgroundAgentTasks ?? {},
|
||||
taskEvent,
|
||||
now,
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
|
||||
const data = msg.data as Record<string, unknown>
|
||||
const taskEvent = normalizeBackgroundAgentTaskEvent(data, 'task_notification')
|
||||
const toolUseId =
|
||||
typeof data.tool_use_id === 'string' && data.tool_use_id.trim()
|
||||
? data.tool_use_id
|
||||
: null
|
||||
const taskStatus = data.status
|
||||
if (
|
||||
toolUseId &&
|
||||
(taskStatus === 'completed' ||
|
||||
taskStatus === 'failed' ||
|
||||
taskStatus === 'stopped')
|
||||
) {
|
||||
if (taskEvent) {
|
||||
const now = Date.now()
|
||||
update((session) => ({
|
||||
backgroundAgentTasks: upsertBackgroundAgentTask(
|
||||
session.backgroundAgentTasks ?? {},
|
||||
taskEvent,
|
||||
now,
|
||||
),
|
||||
agentTaskNotifications: {
|
||||
...session.agentTaskNotifications,
|
||||
[toolUseId]: {
|
||||
taskId:
|
||||
typeof data.task_id === 'string' && data.task_id.trim()
|
||||
? data.task_id
|
||||
: toolUseId,
|
||||
toolUseId,
|
||||
status: taskStatus,
|
||||
summary:
|
||||
typeof data.summary === 'string' && data.summary.trim()
|
||||
? data.summary
|
||||
: undefined,
|
||||
outputFile:
|
||||
typeof data.output_file === 'string' && data.output_file.trim()
|
||||
? data.output_file
|
||||
: undefined,
|
||||
},
|
||||
...(toolUseId &&
|
||||
(taskStatus === 'completed' ||
|
||||
taskStatus === 'failed' ||
|
||||
taskStatus === 'stopped')
|
||||
? {
|
||||
[toolUseId]: {
|
||||
taskId: taskEvent.taskId,
|
||||
toolUseId,
|
||||
status: taskStatus,
|
||||
summary: taskEvent.summary,
|
||||
outputFile: taskEvent.outputFile,
|
||||
usage: taskEvent.usage,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}))
|
||||
}
|
||||
@ -1109,6 +1129,88 @@ function readXmlTag(xml: string, tag: string): string | undefined {
|
||||
return match?.[1] ? decodeXmlText(match[1].trim()) : undefined
|
||||
}
|
||||
|
||||
function readNonEmptyString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeBackgroundTaskUsage(value: unknown): BackgroundAgentTaskUsage | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const record = value as Record<string, unknown>
|
||||
const usage: BackgroundAgentTaskUsage = {}
|
||||
const totalTokens = record.total_tokens ?? record.totalTokens
|
||||
const toolUses = record.tool_uses ?? record.toolUses
|
||||
const durationMs = record.duration_ms ?? record.durationMs
|
||||
if (typeof totalTokens === 'number') usage.totalTokens = totalTokens
|
||||
if (typeof toolUses === 'number') usage.toolUses = toolUses
|
||||
if (typeof durationMs === 'number') usage.durationMs = durationMs
|
||||
return Object.keys(usage).length > 0 ? usage : undefined
|
||||
}
|
||||
|
||||
function normalizeBackgroundAgentTaskEvent(
|
||||
data: unknown,
|
||||
subtype: string,
|
||||
): Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'> | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const record = data as Record<string, unknown>
|
||||
const taskId = readNonEmptyString(record, 'task_id', 'taskId')
|
||||
const toolUseId = readNonEmptyString(record, 'tool_use_id', 'toolUseId')
|
||||
const id = taskId ?? toolUseId
|
||||
if (!id) return null
|
||||
|
||||
const rawStatus = readNonEmptyString(record, 'status')
|
||||
const status = rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped'
|
||||
? rawStatus
|
||||
: rawStatus === 'killed'
|
||||
? 'stopped'
|
||||
: subtype === 'task_notification'
|
||||
? 'completed'
|
||||
: 'running'
|
||||
|
||||
return {
|
||||
taskId: id,
|
||||
toolUseId,
|
||||
status,
|
||||
description: readNonEmptyString(record, 'description', 'message', 'title'),
|
||||
taskType: readNonEmptyString(record, 'task_type', 'taskType'),
|
||||
workflowName: readNonEmptyString(record, 'workflow_name', 'workflowName'),
|
||||
prompt: readNonEmptyString(record, 'prompt'),
|
||||
summary: readNonEmptyString(record, 'summary'),
|
||||
lastToolName: readNonEmptyString(record, 'last_tool_name', 'lastToolName'),
|
||||
outputFile: readNonEmptyString(record, 'output_file', 'outputFile'),
|
||||
usage: normalizeBackgroundTaskUsage(record.usage),
|
||||
}
|
||||
}
|
||||
|
||||
function upsertBackgroundAgentTask(
|
||||
current: Record<string, BackgroundAgentTask>,
|
||||
event: Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>,
|
||||
now: number,
|
||||
): Record<string, BackgroundAgentTask> {
|
||||
const existing = current[event.taskId]
|
||||
return {
|
||||
...current,
|
||||
[event.taskId]: {
|
||||
taskId: event.taskId,
|
||||
toolUseId: event.toolUseId ?? existing?.toolUseId,
|
||||
status: event.status,
|
||||
description: event.description ?? existing?.description,
|
||||
taskType: event.taskType ?? existing?.taskType,
|
||||
workflowName: event.workflowName ?? existing?.workflowName,
|
||||
prompt: event.prompt ?? existing?.prompt,
|
||||
summary: event.summary ?? existing?.summary,
|
||||
lastToolName: event.lastToolName ?? existing?.lastToolName,
|
||||
outputFile: event.outputFile ?? existing?.outputFile,
|
||||
usage: event.usage ?? existing?.usage,
|
||||
startedAt: existing?.startedAt ?? now,
|
||||
updatedAt: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGoalEventData(
|
||||
data: unknown,
|
||||
fallbackMessage?: string,
|
||||
|
||||
@ -156,6 +156,29 @@ export type AgentTaskNotification = {
|
||||
status: 'completed' | 'failed' | 'stopped'
|
||||
summary?: string
|
||||
outputFile?: string
|
||||
usage?: BackgroundAgentTaskUsage
|
||||
}
|
||||
|
||||
export type BackgroundAgentTaskUsage = {
|
||||
totalTokens?: number
|
||||
toolUses?: number
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export type BackgroundAgentTask = {
|
||||
taskId: string
|
||||
toolUseId?: string
|
||||
status: 'running' | 'completed' | 'failed' | 'stopped'
|
||||
description?: string
|
||||
taskType?: string
|
||||
workflowName?: string
|
||||
prompt?: string
|
||||
summary?: string
|
||||
lastToolName?: string
|
||||
outputFile?: string
|
||||
usage?: BackgroundAgentTaskUsage
|
||||
startedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type MemoryEventFile = {
|
||||
|
||||
@ -39,6 +39,63 @@ describe('WebSocket memory events', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket background task events', () => {
|
||||
it('forwards task start and progress as structured desktop notifications', () => {
|
||||
const started = {
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
task_id: 'agent-task-1',
|
||||
tool_use_id: 'agent-tool-1',
|
||||
description: 'Verify the todo app',
|
||||
task_type: 'local_agent',
|
||||
prompt: 'Run E2E checks',
|
||||
}
|
||||
|
||||
expect(translateCliMessage(started, 'session-1')).toEqual([
|
||||
{
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
message: 'Verify the todo app',
|
||||
data: started,
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
verb: 'Verify the todo app',
|
||||
},
|
||||
])
|
||||
|
||||
const progress = {
|
||||
type: 'system',
|
||||
subtype: 'task_progress',
|
||||
task_id: 'agent-task-1',
|
||||
tool_use_id: 'agent-tool-1',
|
||||
description: 'Verify the todo app',
|
||||
summary: 'Running Playwright checks',
|
||||
last_tool_name: 'Bash',
|
||||
usage: {
|
||||
total_tokens: 1200,
|
||||
tool_uses: 4,
|
||||
duration_ms: 45000,
|
||||
},
|
||||
}
|
||||
|
||||
expect(translateCliMessage(progress, 'session-1')).toEqual([
|
||||
{
|
||||
type: 'system_notification',
|
||||
subtype: 'task_progress',
|
||||
message: 'Running Playwright checks',
|
||||
data: progress,
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
verb: 'Running Playwright checks',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket goal command events', () => {
|
||||
const goalStatusOutput = 'Goal set: ship the smoke test'
|
||||
|
||||
|
||||
@ -137,233 +137,242 @@ export function startServer(port = PORT, host = HOST) {
|
||||
process.env.SERVER_AUTH_REQUIRED === '1'
|
||||
const h5AccessService = new H5AccessService()
|
||||
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
hostname: host,
|
||||
idleTimeout: 60,
|
||||
let server: ReturnType<typeof Bun.serve<WebSocketData>>
|
||||
|
||||
async fetch(req, server) {
|
||||
await ensurePersistentStorageUpgraded()
|
||||
const url = new URL(req.url)
|
||||
const origin = req.headers.get('Origin')
|
||||
const clientAddress = server.requestIP(req)?.address ?? null
|
||||
const h5RequestContext = { clientAddress }
|
||||
const h5Settings = await h5AccessService.getSettings()
|
||||
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
|
||||
const cors = await resolveCors(origin, url.origin, {
|
||||
h5Enabled: h5Settings.enabled,
|
||||
isOriginAllowed: async (candidateOrigin) =>
|
||||
candidateOrigin === h5PublicOrigin ||
|
||||
await h5AccessService.isOriginAllowed(candidateOrigin),
|
||||
})
|
||||
const authRequired = shouldRequireH5Token({
|
||||
request: req,
|
||||
url,
|
||||
h5Enabled: h5Settings.enabled,
|
||||
context: h5RequestContext,
|
||||
})
|
||||
const h5AccessDisabledBlocked = shouldBlockDisabledH5Access({
|
||||
request: req,
|
||||
url,
|
||||
h5Enabled: h5Settings.enabled,
|
||||
explicitAuthRequired: forceAuth,
|
||||
context: h5RequestContext,
|
||||
})
|
||||
const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext)
|
||||
try {
|
||||
server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
hostname: host,
|
||||
idleTimeout: 60,
|
||||
|
||||
if (h5AccessControlBlocked) {
|
||||
return h5AccessControlRejectedResponse()
|
||||
}
|
||||
|
||||
if (h5AccessDisabledBlocked) {
|
||||
return h5AccessDisabledResponse()
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
return new Response(null, { status: 204, headers: cors.headers })
|
||||
}
|
||||
|
||||
// WebSocket upgrade
|
||||
if (url.pathname.startsWith('/ws/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
// Enforce authentication when required
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req, url.searchParams.get('token'))
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req, url.searchParams.get('token'))
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session ID format
|
||||
const sessionId = url.pathname.split('/').pop() || ''
|
||||
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
|
||||
return new Response('Invalid session ID', { status: 400 })
|
||||
}
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
sessionId,
|
||||
connectedAt: Date.now(),
|
||||
channel: 'client',
|
||||
sdkToken: null,
|
||||
serverPort: port,
|
||||
serverHost: localConnectHost,
|
||||
},
|
||||
async fetch(req, server) {
|
||||
await ensurePersistentStorageUpgraded()
|
||||
const url = new URL(req.url)
|
||||
const origin = req.headers.get('Origin')
|
||||
const clientAddress = server.requestIP(req)?.address ?? null
|
||||
const h5RequestContext = { clientAddress }
|
||||
const h5Settings = await h5AccessService.getSettings()
|
||||
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
|
||||
const cors = await resolveCors(origin, url.origin, {
|
||||
h5Enabled: h5Settings.enabled,
|
||||
isOriginAllowed: async (candidateOrigin) =>
|
||||
candidateOrigin === h5PublicOrigin ||
|
||||
await h5AccessService.isOriginAllowed(candidateOrigin),
|
||||
})
|
||||
if (upgraded) return undefined
|
||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||
}
|
||||
const authRequired = shouldRequireH5Token({
|
||||
request: req,
|
||||
url,
|
||||
h5Enabled: h5Settings.enabled,
|
||||
context: h5RequestContext,
|
||||
})
|
||||
const h5AccessDisabledBlocked = shouldBlockDisabledH5Access({
|
||||
request: req,
|
||||
url,
|
||||
h5Enabled: h5Settings.enabled,
|
||||
explicitAuthRequired: forceAuth,
|
||||
context: h5RequestContext,
|
||||
})
|
||||
const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext)
|
||||
|
||||
// Internal SDK WebSocket used by the spawned Claude CLI.
|
||||
if (url.pathname.startsWith('/sdk/')) {
|
||||
if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') {
|
||||
if (h5AccessControlBlocked) {
|
||||
return h5AccessControlRejectedResponse()
|
||||
}
|
||||
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
if (h5AccessDisabledBlocked) {
|
||||
return h5AccessDisabledResponse()
|
||||
}
|
||||
|
||||
if (forceAuth) {
|
||||
const authError = await requireAuth(req, url.searchParams.get('token'))
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
return new Response(null, { status: 204, headers: cors.headers })
|
||||
}
|
||||
|
||||
const sessionId = url.pathname.split('/').pop() || ''
|
||||
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
|
||||
return new Response('Invalid session ID', { status: 400 })
|
||||
}
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
sessionId,
|
||||
connectedAt: Date.now(),
|
||||
channel: 'sdk',
|
||||
sdkToken: url.searchParams.get('token'),
|
||||
serverPort: port,
|
||||
serverHost: localConnectHost,
|
||||
},
|
||||
})
|
||||
if (upgraded) return undefined
|
||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||
}
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
return handleHahaOAuthCallback(url)
|
||||
}
|
||||
|
||||
if (url.pathname === '/callback/openai') {
|
||||
return handleHahaOpenAIOAuthCallback(url)
|
||||
}
|
||||
|
||||
// REST API
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
// Enforce authentication when required
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
// WebSocket upgrade
|
||||
if (url.pathname.startsWith('/ws/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await handleApiRequest(req, url)
|
||||
return withCors(response, cors)
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'api_request_failed',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: { path: url.pathname, method: req.method, error },
|
||||
// Enforce authentication when required
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req, url.searchParams.get('token'))
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req, url.searchParams.get('token'))
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session ID format
|
||||
const sessionId = url.pathname.split('/').pop() || ''
|
||||
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
|
||||
return new Response('Invalid session ID', { status: 400 })
|
||||
}
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
sessionId,
|
||||
connectedAt: Date.now(),
|
||||
channel: 'client',
|
||||
sdkToken: null,
|
||||
serverPort: port,
|
||||
serverHost: localConnectHost,
|
||||
},
|
||||
})
|
||||
console.error('[Server] API error:', error)
|
||||
return withCors(Response.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 },
|
||||
), cors)
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs
|
||||
if (url.pathname.startsWith('/proxy/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
if (upgraded) return undefined
|
||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||
}
|
||||
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
// Internal SDK WebSocket used by the spawned Claude CLI.
|
||||
if (url.pathname.startsWith('/sdk/')) {
|
||||
if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') {
|
||||
return h5AccessControlRejectedResponse()
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await handleProxyRequest(req, url)
|
||||
return withCors(response, cors)
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'proxy_request_failed',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: { path: url.pathname, method: req.method, error },
|
||||
|
||||
if (forceAuth) {
|
||||
const authError = await requireAuth(req, url.searchParams.get('token'))
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = url.pathname.split('/').pop() || ''
|
||||
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
|
||||
return new Response('Invalid session ID', { status: 400 })
|
||||
}
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
sessionId,
|
||||
connectedAt: Date.now(),
|
||||
channel: 'sdk',
|
||||
sdkToken: url.searchParams.get('token'),
|
||||
serverPort: port,
|
||||
serverHost: localConnectHost,
|
||||
},
|
||||
})
|
||||
console.error('[Server] Proxy error:', error)
|
||||
return withCors(Response.json(
|
||||
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
|
||||
{ status: 500 },
|
||||
), cors)
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
if (upgraded) return undefined
|
||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ status: 'ok', timestamp: new Date().toISOString() },
|
||||
{ headers: cors.headers },
|
||||
)
|
||||
}
|
||||
if (url.pathname === '/callback') {
|
||||
return handleHahaOAuthCallback(url)
|
||||
}
|
||||
|
||||
// Static H5 shell/assets are non-secret bootstrap content and must load
|
||||
// before the browser can read the QR token; API/proxy/ws stay protected above.
|
||||
const staticResponse = await handleStaticH5Request(req, url)
|
||||
if (staticResponse) {
|
||||
return staticResponse
|
||||
}
|
||||
if (url.pathname === '/callback/openai') {
|
||||
return handleHahaOpenAIOAuthCallback(url)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
// REST API
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
websocket: handleWebSocket,
|
||||
})
|
||||
// Enforce authentication when required
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await handleApiRequest(req, url)
|
||||
return withCors(response, cors)
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'api_request_failed',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: { path: url.pathname, method: req.method, error },
|
||||
})
|
||||
console.error('[Server] API error:', error)
|
||||
return withCors(Response.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 },
|
||||
), cors)
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs
|
||||
if (url.pathname.startsWith('/proxy/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await handleProxyRequest(req, url)
|
||||
return withCors(response, cors)
|
||||
} catch (error) {
|
||||
void diagnosticsService.recordEvent({
|
||||
type: 'proxy_request_failed',
|
||||
severity: 'error',
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
details: { path: url.pathname, method: req.method, error },
|
||||
})
|
||||
console.error('[Server] Proxy error:', error)
|
||||
return withCors(Response.json(
|
||||
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
|
||||
{ status: 500 },
|
||||
), cors)
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ status: 'ok', timestamp: new Date().toISOString() },
|
||||
{ headers: cors.headers },
|
||||
)
|
||||
}
|
||||
|
||||
// Static H5 shell/assets are non-secret bootstrap content and must load
|
||||
// before the browser can read the QR token; API/proxy/ws stay protected above.
|
||||
const staticResponse = await handleStaticH5Request(req, url)
|
||||
if (staticResponse) {
|
||||
return staticResponse
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
|
||||
websocket: handleWebSocket,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: `Failed to start server. Is port ${port} in use?`
|
||||
throw new Error(message, { cause: error })
|
||||
}
|
||||
|
||||
// Start watching ~/.claude/teams/ for real-time WebSocket push
|
||||
teamWatcher.start()
|
||||
@ -373,8 +382,8 @@ export function startServer(port = PORT, host = HOST) {
|
||||
|
||||
void ensureDesktopCliLauncherInstalled().catch((error) => {
|
||||
console.error(
|
||||
'[desktop-cli-launcher] failed to install bundled launcher:',
|
||||
error instanceof Error ? error.message : error,
|
||||
'[desktop-cli-launcher] failed to install bundled launcher:',
|
||||
error instanceof Error ? error.message : error,
|
||||
)
|
||||
})
|
||||
|
||||
@ -390,7 +399,7 @@ function cleanupAllSessions() {
|
||||
if (active.length > 0) {
|
||||
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
|
||||
for (const sessionId of active) {
|
||||
conversationService.stopSession(sessionId)
|
||||
conversationService.stopSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1276,18 +1276,34 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
}]
|
||||
}
|
||||
if (subtype === 'task_started') {
|
||||
return [{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
verb: cliMsg.message || 'Task started',
|
||||
}]
|
||||
return [
|
||||
{
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
message: cliMsg.message || cliMsg.description || 'Task started',
|
||||
data: cliMsg,
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
verb: cliMsg.message || cliMsg.description || 'Task started',
|
||||
},
|
||||
]
|
||||
}
|
||||
if (subtype === 'task_progress') {
|
||||
return [{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
verb: cliMsg.message || 'Task in progress',
|
||||
}]
|
||||
return [
|
||||
{
|
||||
type: 'system_notification',
|
||||
subtype: 'task_progress',
|
||||
message: cliMsg.message || cliMsg.summary || cliMsg.description || 'Task in progress',
|
||||
data: cliMsg,
|
||||
},
|
||||
{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
verb: cliMsg.message || cliMsg.summary || cliMsg.description || 'Task in progress',
|
||||
},
|
||||
]
|
||||
}
|
||||
if (subtype === 'session_state_changed') {
|
||||
return [{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user