mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(activity): settle stale tasks after turns end (#1178)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user