fix(activity): settle stale tasks after turns end (#1178)

This commit is contained in:
Relakkes Yang
2026-08-04 19:50:48 +08:00
parent 4c064dadb2
commit 27c20389ab
10 changed files with 391 additions and 47 deletions
@@ -557,6 +557,112 @@ describe('buildSessionActivityModel', () => {
expect(model.badgeCount).toBe(1)
})
it('does not let a stale live task list regress a successful TaskUpdate', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{
id: 'task-create-1',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-1',
input: { subject: '完成当前任务' },
timestamp: 1000,
},
{
id: 'task-create-result-1',
type: 'tool_result',
toolUseId: 'task-create-call-1',
content: 'Task #1 created successfully: 完成当前任务',
isError: false,
timestamp: 1001,
},
{
id: 'task-update-1',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-1',
input: { taskId: '1', status: 'completed' },
timestamp: 1002,
},
successfulTaskUpdateResult('task-update-call-1', '1', 1003),
],
tasks: [task({ id: '1', subject: '完成当前任务', status: 'in_progress' })],
completedAndDismissed: false,
isForegroundTurnActive: true,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ id: '1', status: 'completed' }),
])
expect(model.badgeCount).toBe(0)
})
it('lets confirmed updates reopen tasks while live state advances untouched tasks', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{
id: 'task-create-1',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-1',
input: { subject: '重新执行验收' },
timestamp: 1000,
},
{
id: 'task-create-result-1',
type: 'tool_result',
toolUseId: 'task-create-call-1',
content: 'Task #1 created successfully: 重新执行验收',
isError: false,
timestamp: 1001,
},
{
id: 'task-create-2',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-2',
input: { subject: '等待后台完成' },
timestamp: 1002,
},
{
id: 'task-create-result-2',
type: 'tool_result',
toolUseId: 'task-create-call-2',
content: 'Task #2 created successfully: 等待后台完成',
isError: false,
timestamp: 1003,
},
{
id: 'task-update-1',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-1',
input: { taskId: '1', status: 'in_progress' },
timestamp: 1004,
},
successfulTaskUpdateResult('task-update-call-1', '1', 1005),
],
tasks: [
task({ id: '1', subject: '重新执行验收', status: 'completed' }),
task({ id: '2', subject: '等待后台完成', status: 'completed' }),
],
completedAndDismissed: false,
isForegroundTurnActive: true,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ id: '1', status: 'in_progress' }),
expect.objectContaining({ id: '2', status: 'completed' }),
])
expect(model.badgeCount).toBe(1)
})
it('keeps parent-linked SubAgent tasks out of the session task section', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
@@ -1199,6 +1305,42 @@ describe('buildSessionActivityModel', () => {
expect(model.badgeCount).toBe(1)
})
it('uses message order when TodoWrite and a successful TaskUpdate share a timestamp', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{
id: 'todo-1',
type: 'tool_use',
toolName: 'TodoWrite',
toolUseId: 'todo-call-1',
input: {
todos: [{ content: '完成历史兼容验证', status: 'in_progress' }],
},
timestamp: 1000,
},
{
id: 'task-update-1',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-1',
input: { taskId: '1', subject: '完成历史兼容验证', status: 'completed' },
timestamp: 1000,
},
successfulTaskUpdateResult('task-update-call-1', '1', 1000),
],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ id: '1', label: '完成历史兼容验证', status: 'completed' }),
])
expect(model.badgeCount).toBe(0)
})
it('prefers task summary rows over earlier TodoWrite rows', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
@@ -1358,6 +1500,80 @@ describe('buildSessionActivityModel', () => {
expect(model.badgeCount).toBe(0)
})
it('seals unfinished current tasks when the foreground turn becomes idle', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{ id: 'user-1', type: 'user_text', content: '执行任务后暂停', timestamp: 1000 },
{
id: 'task-create-1',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-1',
input: { subject: '只读确认 README.md 存在' },
timestamp: 1001,
},
{
id: 'task-create-result-1',
type: 'tool_result',
toolUseId: 'task-create-call-1',
content: 'Task #1 created successfully: 只读确认 README.md 存在',
isError: false,
timestamp: 1002,
},
{
id: 'task-create-2',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-2',
input: { subject: '等待后续指令' },
timestamp: 1003,
},
{
id: 'task-create-result-2',
type: 'tool_result',
toolUseId: 'task-create-call-2',
content: 'Task #2 created successfully: 等待后续指令',
isError: false,
timestamp: 1004,
},
{
id: 'task-update-1',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-1',
input: { taskId: '1', status: 'completed' },
timestamp: 1005,
},
successfulTaskUpdateResult('task-update-call-1', '1', 1006),
{
id: 'task-update-2',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-2',
input: { taskId: '2', status: 'in_progress' },
timestamp: 1007,
},
successfulTaskUpdateResult('task-update-call-2', '2', 1008),
{ id: 'assistant-1', type: 'assistant_text', content: '已暂停,等待后续指令', timestamp: 1009 },
],
tasks: [
task({ id: '1', subject: '只读确认 README.md 存在', status: 'completed' }),
task({ id: '2', subject: '等待后续指令', status: 'in_progress' }),
],
completedAndDismissed: false,
isForegroundTurnActive: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ id: '1', status: 'completed' }),
expect.objectContaining({ id: '2', status: 'stopped' }),
])
expect(model.badgeCount).toBe(0)
})
it('does not show orphan non-agent notifications in the SubAgents section', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
@@ -50,6 +50,7 @@ export type BuildSessionActivityModelInput = {
messages?: UIMessage[]
tasks: CLITask[]
completedAndDismissed: boolean
isForegroundTurnActive?: boolean
backgroundTasks: BackgroundAgentTask[]
dismissedBackgroundTaskKeys?: Set<string>
agentNotifications: AgentTaskNotification[]
@@ -219,8 +220,11 @@ type TaskMessageTurn = {
type TaskTurnRows = {
turn: TaskMessageTurn
rows: ActivityRow[]
confirmedStatuses: Map<string, TaskStatus>
}
type BuiltTaskRows = Pick<TaskTurnRows, 'rows' | 'confirmedStatuses'>
function splitMessagesIntoTurns(messages: UIMessage[]): TaskMessageTurn[] {
const turns: TaskMessageTurn[] = []
let current: TaskMessageTurn = { id: 'turn-0', index: 0, messages: [] }
@@ -509,9 +513,10 @@ function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
function buildTaskRowsFromTaskTools(
messages: UIMessage[],
resultsByToolUseId = collectToolResults(messages),
): ActivityRow[] {
): BuiltTaskRows {
const rowsByTaskId = new Map<string, ActivityRow>()
const confirmedStatuses = new Map<string, TaskStatus>()
let createIndex = 0
for (const message of messages) {
@@ -538,6 +543,7 @@ function buildTaskRowsFromTaskTools(
// deleted 不是一种任务状态:CLI 侧 TaskUpdateTool 会真的删掉任务文件
if (isDeletedStatus(input)) {
rowsByTaskId.delete(taskId)
confirmedStatuses.delete(taskId)
continue
}
@@ -556,48 +562,65 @@ function buildTaskRowsFromTaskTools(
status,
...(activeForm && activeForm !== (existing?.label ?? subject) ? { description: activeForm } : {}),
})
const confirmedStatus = parseTaskStatus(input.status)
if (confirmedStatus) confirmedStatuses.set(taskId, confirmedStatus)
}
}
return Array.from(rowsByTaskId.values())
return {
rows: Array.from(rowsByTaskId.values()),
confirmedStatuses,
}
}
function buildTaskRowsFromTurnMessages(
messages: UIMessage[],
resultsByToolUseId = collectToolResults(messages),
): ActivityRow[] {
): BuiltTaskRows {
let latestSummary: Extract<UIMessage, { type: 'task_summary' }> | undefined
let latestTodoWrite: Extract<UIMessage, { type: 'tool_use' }> | undefined
let latestTaskToolTimestamp = -Infinity
let latestTodoWriteIndex = -1
let latestTaskToolIndex = -1
for (const message of messages) {
for (const [index, message] of messages.entries()) {
if (message.type === 'task_summary') {
latestSummary = message
} else if (message.type === 'tool_use' && message.toolName === 'TodoWrite') {
latestTodoWrite = message
latestTodoWriteIndex = index
} else if (message.type === 'tool_use' && message.toolName === 'TaskCreate') {
latestTaskToolTimestamp = Math.max(latestTaskToolTimestamp, message.timestamp)
latestTaskToolIndex = index
} else if (message.type === 'tool_use' && message.toolName === 'TaskUpdate') {
const input = isRecordValue(message.input) ? message.input : {}
if (isSuccessfulTaskUpdate(input, resultsByToolUseId.get(message.toolUseId))) {
latestTaskToolTimestamp = Math.max(latestTaskToolTimestamp, message.timestamp)
latestTaskToolIndex = index
}
}
}
if (latestSummary?.tasks.length) {
return dedupeTaskRows(latestSummary.tasks.map(buildTaskSummaryRow))
return {
rows: dedupeTaskRows(latestSummary.tasks.map(buildTaskSummaryRow)),
confirmedStatuses: new Map(),
}
}
const input = latestTodoWrite?.input
if (latestTodoWrite && isRecordValue(input) && Array.isArray(input.todos) && latestTodoWrite.timestamp >= latestTaskToolTimestamp) {
return dedupeTaskRows(input.todos.map(buildTodoTaskRow))
if (latestTodoWrite && isRecordValue(input) && Array.isArray(input.todos) && latestTodoWriteIndex >= latestTaskToolIndex) {
return {
rows: dedupeTaskRows(input.todos.map(buildTodoTaskRow)),
confirmedStatuses: new Map(),
}
}
return buildTaskRowsFromTaskTools(messages, resultsByToolUseId)
}
function mergeTaskRowsById(baseRows: ActivityRow[], liveRows: ActivityRow[]): ActivityRow[] {
function mergeTaskRowsById(
baseRows: ActivityRow[],
liveRows: ActivityRow[],
confirmedStatuses: Map<string, TaskStatus>,
): ActivityRow[] {
const liveRowsById = new Map<string, ActivityRow>()
for (const row of liveRows) {
if (row.taskId || row.id) {
@@ -611,7 +634,9 @@ function mergeTaskRowsById(baseRows: ActivityRow[], liveRows: ActivityRow[]): Ac
const liveRow = liveRowsById.get(id)
if (!liveRow) return row
usedLiveIds.add(id)
return mergeTaskRows(row, liveRow)
const mergedRow = mergeTaskRows(row, liveRow)
const confirmedStatus = confirmedStatuses.get(id)
return confirmedStatus ? { ...mergedRow, status: confirmedStatus } : mergedRow
})
for (const row of liveRows) {
@@ -655,10 +680,14 @@ function buildTaskRowsFromMessages(messages: UIMessage[], liveTasks: CLITask[]):
// 任务列表要等 tool_result 到达后才异步刷新,这中间 liveTasks 里还留着已删的任务
const liveRows = liveTasks.map(buildTaskRow).filter(isSessionTaskRow)
const taskTurnRows = splitMessagesIntoTurns(sessionMessages)
.map((turn) => ({
turn,
rows: buildTaskRowsFromTurnMessages(turn.messages, resultsByToolUseId).filter(isSessionTaskRow),
}))
.map((turn) => {
const builtRows = buildTaskRowsFromTurnMessages(turn.messages, resultsByToolUseId)
return {
turn,
rows: builtRows.rows.filter(isSessionTaskRow),
confirmedStatuses: builtRows.confirmedStatuses,
}
})
.filter((group) => group.rows.length > 0)
if (taskTurnRows.length === 0) {
@@ -667,12 +696,22 @@ function buildTaskRowsFromMessages(messages: UIMessage[], liveTasks: CLITask[]):
const currentGroup = taskTurnRows[taskTurnRows.length - 1]!
const earlierGroups = taskTurnRows.slice(0, -1)
const currentRows = dedupeTaskRows(mergeTaskRowsById(currentGroup.rows, liveRows))
const currentRows = dedupeTaskRows(mergeTaskRowsById(
currentGroup.rows,
liveRows,
currentGroup.confirmedStatuses,
))
const historicalRow = buildHistoricalTasksRow(earlierGroups)
return historicalRow ? [...currentRows, historicalRow] : currentRows
}
function sealUnfinishedTaskRows(rows: ActivityRow[]): ActivityRow[] {
return rows.map((row) => row.status === 'pending' || row.status === 'in_progress'
? { ...row, status: 'stopped' }
: row)
}
function mergeSubagentRow(existing: ActivityRow | undefined, row: ActivityRow): ActivityRow {
if (!existing) return row
@@ -734,7 +773,10 @@ function buildOutputRow(key: string, outputFile: string): ActivityRow {
export function buildSessionActivityModel(input: BuildSessionActivityModelInput): SessionActivityModel {
const sections = createEmptySections()
let badgeCount = 0
sections.tasks.rows = buildTaskRowsFromMessages(input.messages ?? [], input.tasks)
const taskRows = buildTaskRowsFromMessages(input.messages ?? [], input.tasks)
sections.tasks.rows = input.isForegroundTurnActive === false
? sealUnfinishedTaskRows(taskRows)
: taskRows
for (const row of sections.tasks.rows) {
if (isBadgeStatus(row.status)) {
badgeCount += 1
+1
View File
@@ -168,6 +168,7 @@ export function TabBar() {
messages: sessionState?.messages ?? [],
tasks: includeCliTasks ? cliTasks : [],
completedAndDismissed: includeCliTasks ? cliTasksCompletedAndDismissed : false,
isForegroundTurnActive: Boolean(sessionState && sessionState.chatState !== 'idle'),
backgroundTasks: Object.values(sessionState?.backgroundAgentTasks ?? {}),
dismissedBackgroundTaskKeys,
agentNotifications: Object.values(sessionState?.agentTaskNotifications ?? {}),
+20 -3
View File
@@ -703,7 +703,7 @@ describe('ActiveSession task polling', () => {
}, { timeout: 4000 })
})
it('auto-opens the activity panel when the current session first produces activity', async () => {
it('auto-opens for current activity and seals unfinished tasks when the turn becomes idle', async () => {
const sessionId = 'activity-auto-open-session'
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
@@ -714,7 +714,7 @@ describe('ActiveSession task polling', () => {
title: 'Auto Open Activity Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 0,
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
@@ -731,7 +731,7 @@ describe('ActiveSession task polling', () => {
sessions: {
[sessionId]: {
messages: [],
chatState: 'idle',
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
@@ -778,6 +778,23 @@ describe('ActiveSession task polling', () => {
})
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
expect(screen.getByText('Draft implementation plan')).toBeInTheDocument()
expect(within(screen.getByTestId('session-activity-panel')).getByLabelText('Task in progress')).toBeInTheDocument()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: {
...state.sessions[sessionId]!,
chatState: 'idle',
},
},
}))
})
expect(within(screen.getByTestId('session-activity-panel')).getByLabelText('Stopped')).toBeInTheDocument()
expect(within(screen.getByTestId('session-activity-panel')).queryByLabelText('Task in progress')).not.toBeInTheDocument()
expect(screen.queryByText(/session active|会话活跃中/)).not.toBeInTheDocument()
})
it('renders completed historical TodoWrite activity in the rail', () => {
+3 -4
View File
@@ -303,7 +303,6 @@ export function ActiveSession() {
const cliTasks = useCLITaskStore((s) => s.tasks)
const cliTasksCompletedAndDismissed = useCLITaskStore((s) => s.completedAndDismissed)
const hasIncompleteTasks = cliTasks.some((task) => task.status !== 'completed')
const hasRunningTasks = cliTasks.some((task) => task.status === 'in_progress')
const isActivityPanelOpen = useActivityPanelStore((state) => activeTabId ? state.isOpen(activeTabId) : false)
const openActivityPanel = useActivityPanelStore((state) => state.open)
const closeActivityPanel = useActivityPanelStore((state) => state.close)
@@ -404,9 +403,7 @@ export function ActiveSession() {
const visibleMessageCount = messages.length > 0 ? messages.length : session?.messageCount ?? 0
const headerTitle = session?.title || t('session.untitled')
const isActive = chatState !== 'idle' ||
(trackedTaskSessionId === activeTabId && hasRunningTasks) ||
hasRunningBackgroundTasks
const isActive = chatState !== 'idle' || hasRunningBackgroundTasks
const totalTokens = getTokenUsageTotal(tokenUsage)
const cachedTokens = (tokenUsage.cache_read_tokens ?? 0) +
(tokenUsage.cache_creation_tokens ?? 0)
@@ -434,6 +431,7 @@ export function ActiveSession() {
messages,
tasks: includeCliTasks ? cliTasks : [],
completedAndDismissed: includeCliTasks ? cliTasksCompletedAndDismissed : false,
isForegroundTurnActive: chatState !== 'idle',
backgroundTasks,
dismissedBackgroundTaskKeys,
agentNotifications: Object.values(agentTaskNotifications),
@@ -446,6 +444,7 @@ export function ActiveSession() {
backgroundTasks,
cliTasks,
cliTasksCompletedAndDismissed,
chatState,
dismissedBackgroundTaskKeys,
messages,
trackedTaskSessionId,
+1
View File
@@ -2454,6 +2454,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1',
},
])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('idle')
})
it('retains live parent linkage when only content_start carries the parent id', () => {
+6 -4
View File
@@ -2719,10 +2719,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
return {
messages,
...(stoppedTask ? { backgroundAgentTasks } : {}),
chatState: hasPendingPermissionRequests(s)
? 'permission_pending'
: 'thinking',
activeThinkingId: null,
chatState: parentToolUseId
? s.chatState
: hasPendingPermissionRequests(s)
? 'permission_pending'
: 'thinking',
activeThinkingId: parentToolUseId ? s.activeThinkingId : null,
}
})
if (consumePendingTaskToolUseId(sessionId, msg.toolUseId)) {
@@ -794,6 +794,79 @@ describe('WebSocket handler session isolation', () => {
expect(stopSession).not.toHaveBeenCalled()
})
it('does not turn background task lifecycle into foreground activity after the user turn ends', () => {
const sessionId = `background-task-foreground-state-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
const outputCallbacks: Array<(cliMsg: any) => void> = []
spyOn(conversationService, 'hasSession').mockReturnValue(true)
spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => {
outputCallbacks.push(callback)
})
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
handleWebSocket.open(ws)
ws.sent.length = 0
outputCallbacks[0]?.({
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',
})
outputCallbacks[0]?.({
type: 'system',
subtype: 'task_progress',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
summary: 'Running Playwright checks',
task_type: 'local_agent',
})
const idleMessages = ws.sent.map((payload) => JSON.parse(payload))
expect(idleMessages).toContainEqual(expect.objectContaining({
type: 'system_notification',
subtype: 'task_started',
}))
expect(idleMessages).toContainEqual(expect.objectContaining({
type: 'system_notification',
subtype: 'task_progress',
}))
expect(idleMessages).not.toContainEqual(expect.objectContaining({
type: 'status',
state: 'tool_executing',
}))
handleWebSocket.message(ws, JSON.stringify({ type: 'sync_state' }))
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'idle',
})
__markActiveTurnForTests(sessionId)
ws.sent.length = 0
outputCallbacks[0]?.({
type: 'system',
subtype: 'task_progress',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
summary: 'Foreground turn is waiting for the task',
task_type: 'local_agent',
})
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'status',
state: 'tool_executing',
verb: 'Foreground turn is waiting for the task',
})
handleWebSocket.message(ws, JSON.stringify({ type: 'sync_state' }))
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'running',
})
})
it('stops every active Agent task when generation is stopped', async () => {
const sessionId = `stop-agent-fanout-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
+1 -11
View File
@@ -407,7 +407,7 @@ describe('WebSocket API retry events', () => {
})
describe('WebSocket background task events', () => {
it('forwards task start and progress as structured desktop notifications', () => {
it('forwards task start and progress without reviving an idle foreground turn', () => {
const started = {
type: 'system',
subtype: 'task_started',
@@ -425,11 +425,6 @@ describe('WebSocket background task events', () => {
message: 'Verify the todo app',
data: started,
},
{
type: 'status',
state: 'tool_executing',
verb: 'Verify the todo app',
},
])
const progress = {
@@ -454,11 +449,6 @@ describe('WebSocket background task events', () => {
message: 'Running Playwright checks',
data: progress,
},
{
type: 'status',
state: 'tool_executing',
verb: 'Running Playwright checks',
},
])
})
+10 -7
View File
@@ -3529,7 +3529,8 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
if (
cliMsg.task_type === 'dream' ||
sessionStopRequested.has(sessionId) ||
agentStopRequestedSessions.has(sessionId)
agentStopRequestedSessions.has(sessionId) ||
!hasLiveUserTurnForClient(sessionId)
) {
return [notification]
}
@@ -3543,13 +3544,15 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
]
}
if (subtype === 'task_progress') {
const notification: ServerMessage = {
type: 'system_notification',
subtype: 'task_progress',
message: cliMsg.message || cliMsg.summary || cliMsg.description || 'Task in progress',
data: cliMsg,
}
if (!hasLiveUserTurnForClient(sessionId)) return [notification]
return [
{
type: 'system_notification',
subtype: 'task_progress',
message: cliMsg.message || cliMsg.summary || cliMsg.description || 'Task in progress',
data: cliMsg,
},
notification,
{
type: 'status',
state: 'tool_executing',