mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(desktop): control background task lifecycle #990
Keep AutoDream visible without marking the foreground session busy, add a task-scoped stop control, and preserve the authoritative stopped bookend emitted by the CLI runtime. Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:chat-contract Tested: bun run check:coverage Tested: real MiniMax-M3 browser smoke for background stop and AutoDream busy-state isolation Confidence: high Scope-risk: moderate
This commit is contained in:
parent
060a134ca1
commit
d6c156af17
@ -24,6 +24,8 @@ vi.mock('../../i18n', () => ({
|
||||
'session.activity.openTeamMember': 'Open team member {name}',
|
||||
'session.activity.openRun': 'Open run {name}',
|
||||
'session.activity.openBackgroundTask': 'Open background task {name}',
|
||||
'session.activity.stopBackgroundTask': 'Stop background task {name}',
|
||||
'session.activity.stoppingBackgroundTask': 'Stopping background task {name}',
|
||||
'session.activity.details.title': 'Details',
|
||||
'session.activity.details.type': 'Type',
|
||||
'session.activity.details.description': 'Description',
|
||||
@ -257,6 +259,92 @@ describe('SessionActivityPanel', () => {
|
||||
expect(onClearFinishedBackgroundTasks).toHaveBeenCalledWith(['bash-task-1:completed:1000'])
|
||||
})
|
||||
|
||||
it('stops running background tasks and disables repeat requests while stopping', () => {
|
||||
const onStopBackgroundTask = vi.fn()
|
||||
const runningModel = model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
|
||||
subagents: { id: 'subagents', title: 'SubAgents', emptyLabel: 'No SubAgents', rows: [] },
|
||||
backgroundTasks: {
|
||||
id: 'backgroundTasks',
|
||||
title: 'Background Tasks',
|
||||
emptyLabel: 'No background tasks',
|
||||
rows: [{
|
||||
id: 'bash-task-1',
|
||||
section: 'backgroundTasks',
|
||||
label: 'Sleep for 300 seconds',
|
||||
status: 'running',
|
||||
taskId: 'bash-task-1',
|
||||
taskType: 'local_bash',
|
||||
openable: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { rerender } = render(
|
||||
<SessionActivityPanel
|
||||
model={runningModel}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
onStopBackgroundTask={onStopBackgroundTask}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop background task Sleep for 300 seconds' }))
|
||||
expect(onStopBackgroundTask).toHaveBeenCalledWith('bash-task-1')
|
||||
|
||||
rerender(
|
||||
<SessionActivityPanel
|
||||
model={runningModel}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
onStopBackgroundTask={onStopBackgroundTask}
|
||||
stoppingBackgroundTaskIds={{ 'bash-task-1': true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Stopping background task Sleep for 300 seconds' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers the same stop control for a running background SubAgent', () => {
|
||||
const onStopBackgroundTask = vi.fn()
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
model={model({
|
||||
sections: {
|
||||
...model().sections,
|
||||
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
|
||||
subagents: {
|
||||
id: 'subagents',
|
||||
title: 'SubAgents',
|
||||
emptyLabel: 'No SubAgents',
|
||||
rows: [{
|
||||
id: 'agent-task-1',
|
||||
section: 'subagents',
|
||||
label: 'Background reviewer',
|
||||
status: 'running',
|
||||
taskId: 'agent-task-1',
|
||||
toolUseId: 'agent-tool-1',
|
||||
openable: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
})}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onOpenSubagent={vi.fn()}
|
||||
onStopBackgroundTask={onStopBackgroundTask}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop background task Background reviewer' }))
|
||||
expect(onStopBackgroundTask).toHaveBeenCalledWith('agent-task-1')
|
||||
})
|
||||
|
||||
it('keeps SubAgent rows to name and status instead of result previews', () => {
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Terminal, Users, X } from 'lucide-react'
|
||||
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X } from 'lucide-react'
|
||||
import { AgentMascot } from './AgentMascot'
|
||||
import { getVisibleActivitySections, type ActivityRow, type ActivitySectionId, type SessionActivityModel } from './sessionActivityModel'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -245,12 +245,48 @@ function ActivityStatusIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
function BackgroundTaskStopButton({
|
||||
row,
|
||||
stopping,
|
||||
onStop,
|
||||
}: {
|
||||
row: ActivityRow
|
||||
stopping: boolean
|
||||
onStop: (taskId: string) => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
if (row.status !== 'running' || !row.taskId) return null
|
||||
|
||||
const label = stopping
|
||||
? t('session.activity.stoppingBackgroundTask', { name: row.label })
|
||||
: t('session.activity.stopBackgroundTask', { name: row.label })
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={stopping}
|
||||
onClick={() => onStop(row.taskId!)}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-[var(--color-error)]/10 hover:text-[var(--color-error)] active:translate-y-px disabled:cursor-wait disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
{stopping ? (
|
||||
<LoaderCircle size={14} strokeWidth={2.2} className="motion-safe:animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
) : (
|
||||
<Square size={12} strokeWidth={2.4} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityRowView({
|
||||
row,
|
||||
sessionId,
|
||||
onOpenSubagent,
|
||||
onOpenMember,
|
||||
onOpenBackgroundTask,
|
||||
onStopBackgroundTask,
|
||||
stoppingBackgroundTask,
|
||||
selected,
|
||||
}: {
|
||||
row: ActivityRow
|
||||
@ -258,6 +294,8 @@ function ActivityRowView({
|
||||
onOpenSubagent: (payload: OpenSubagentPayload) => void
|
||||
onOpenMember?: (member: TeamMember) => void
|
||||
onOpenBackgroundTask?: (row: ActivityRow) => void
|
||||
onStopBackgroundTask?: (taskId: string) => void
|
||||
stoppingBackgroundTask?: boolean
|
||||
selected?: boolean
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
@ -312,7 +350,14 @@ function ActivityRowView({
|
||||
</>
|
||||
)
|
||||
const interactiveRowClassName =
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
'flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
const stopButton = onStopBackgroundTask ? (
|
||||
<BackgroundTaskStopButton
|
||||
row={row}
|
||||
stopping={Boolean(stoppingBackgroundTask)}
|
||||
onStop={onStopBackgroundTask}
|
||||
/>
|
||||
) : null
|
||||
|
||||
if (row.section === 'team' && row.member && onOpenMember) {
|
||||
return (
|
||||
@ -320,7 +365,7 @@ function ActivityRowView({
|
||||
type="button"
|
||||
aria-label={t('session.activity.openTeamMember', { name: row.label })}
|
||||
onClick={() => onOpenMember(row.member!)}
|
||||
className={interactiveRowClassName}
|
||||
className={`${interactiveRowClassName} w-full`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
@ -329,31 +374,55 @@ function ActivityRowView({
|
||||
|
||||
if (row.section === 'subagents' && row.openable && row.toolUseId) {
|
||||
const statusLabel = getActivityStatusLabel(row.status, t)
|
||||
|
||||
return (
|
||||
const openButton = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('session.activity.openRun', { name: row.label })} · ${statusLabel}`}
|
||||
onClick={() => onOpenSubagent({ sessionId, toolUseId: row.toolUseId!, title: row.label })}
|
||||
className={interactiveRowClassName}
|
||||
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'}`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
|
||||
return stopButton ? (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{openButton}
|
||||
{stopButton}
|
||||
</div>
|
||||
) : openButton
|
||||
}
|
||||
|
||||
if (row.section === 'backgroundTasks' && onOpenBackgroundTask && hasBackgroundTaskDetails(row)) {
|
||||
return (
|
||||
const openButton = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('session.activity.openBackgroundTask', { name: row.label })}
|
||||
aria-expanded={selected}
|
||||
onClick={() => onOpenBackgroundTask(row)}
|
||||
className={`${interactiveRowClassName} ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
|
||||
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'} ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
|
||||
return stopButton ? (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{openButton}
|
||||
{stopButton}
|
||||
</div>
|
||||
) : openButton
|
||||
}
|
||||
|
||||
if (stopButton) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2.5 py-2.5">
|
||||
{content}
|
||||
</div>
|
||||
{stopButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -420,6 +489,8 @@ export function SessionActivityPanel({
|
||||
onOpenSubagent,
|
||||
onClearFinishedBackgroundTasks,
|
||||
onOpenMember,
|
||||
onStopBackgroundTask,
|
||||
stoppingBackgroundTaskIds,
|
||||
placement = 'overlay',
|
||||
}: {
|
||||
model: SessionActivityModel
|
||||
@ -428,6 +499,8 @@ export function SessionActivityPanel({
|
||||
onOpenSubagent: (payload: OpenSubagentPayload) => void
|
||||
onClearFinishedBackgroundTasks?: (taskKeys: string[]) => void
|
||||
onOpenMember?: (member: TeamMember) => void
|
||||
onStopBackgroundTask?: (taskId: string) => void
|
||||
stoppingBackgroundTaskIds?: Record<string, boolean>
|
||||
placement?: SessionActivityPanelPlacement
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
@ -544,6 +617,8 @@ export function SessionActivityPanel({
|
||||
sessionId={model.sessionId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenMember={onOpenMember}
|
||||
onStopBackgroundTask={onStopBackgroundTask}
|
||||
stoppingBackgroundTask={Boolean(row.taskId && stoppingBackgroundTaskIds?.[row.taskId])}
|
||||
onOpenBackgroundTask={(backgroundRow) => {
|
||||
setSelectedBackgroundTaskId((current) => (
|
||||
current === backgroundRow.id ? null : backgroundRow.id
|
||||
|
||||
@ -1699,6 +1699,8 @@ export const en = {
|
||||
'session.activity.openTeamMember': 'Open team member {name}',
|
||||
'session.activity.openRun': 'Open run {name}',
|
||||
'session.activity.openBackgroundTask': 'Open background task {name}',
|
||||
'session.activity.stopBackgroundTask': 'Stop background task {name}',
|
||||
'session.activity.stoppingBackgroundTask': 'Stopping background task {name}',
|
||||
'session.activity.details.title': 'Details',
|
||||
'session.activity.details.type': 'Type',
|
||||
'session.activity.details.description': 'Description',
|
||||
|
||||
@ -1701,6 +1701,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'session.activity.openTeamMember': 'チームメンバー {name} を開く',
|
||||
'session.activity.openRun': '実行 {name} を開く',
|
||||
'session.activity.openBackgroundTask': 'バックグラウンドタスク {name} を開く',
|
||||
'session.activity.stopBackgroundTask': 'バックグラウンドタスク {name} を停止',
|
||||
'session.activity.stoppingBackgroundTask': 'バックグラウンドタスク {name} を停止中',
|
||||
'session.activity.details.title': '詳細',
|
||||
'session.activity.details.type': '種類',
|
||||
'session.activity.details.description': '説明',
|
||||
|
||||
@ -1701,6 +1701,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'session.activity.openTeamMember': '팀 멤버 {name} 열기',
|
||||
'session.activity.openRun': '실행 {name} 열기',
|
||||
'session.activity.openBackgroundTask': '백그라운드 작업 {name} 열기',
|
||||
'session.activity.stopBackgroundTask': '백그라운드 작업 {name} 중지',
|
||||
'session.activity.stoppingBackgroundTask': '백그라운드 작업 {name} 중지 중',
|
||||
'session.activity.details.title': '세부 정보',
|
||||
'session.activity.details.type': '유형',
|
||||
'session.activity.details.description': '설명',
|
||||
|
||||
@ -1701,6 +1701,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'session.activity.openTeamMember': '開啟團隊成員 {name}',
|
||||
'session.activity.openRun': '開啟執行 {name}',
|
||||
'session.activity.openBackgroundTask': '開啟後台任務 {name}',
|
||||
'session.activity.stopBackgroundTask': '停止後台任務 {name}',
|
||||
'session.activity.stoppingBackgroundTask': '正在停止後台任務 {name}',
|
||||
'session.activity.details.title': '詳情',
|
||||
'session.activity.details.type': '類型',
|
||||
'session.activity.details.description': '描述',
|
||||
|
||||
@ -1701,6 +1701,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'session.activity.openTeamMember': '打开团队成员 {name}',
|
||||
'session.activity.openRun': '打开运行 {name}',
|
||||
'session.activity.openBackgroundTask': '打开后台任务 {name}',
|
||||
'session.activity.stopBackgroundTask': '停止后台任务 {name}',
|
||||
'session.activity.stoppingBackgroundTask': '正在停止后台任务 {name}',
|
||||
'session.activity.details.title': '详情',
|
||||
'session.activity.details.type': '类型',
|
||||
'session.activity.details.description': '描述',
|
||||
|
||||
31
desktop/src/lib/backgroundTasks.test.ts
Normal file
31
desktop/src/lib/backgroundTasks.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BackgroundAgentTask } from '../types/chat'
|
||||
import { hasRunningBackgroundTasks } from './backgroundTasks'
|
||||
|
||||
function task(
|
||||
taskId: string,
|
||||
overrides: Partial<BackgroundAgentTask> = {},
|
||||
): BackgroundAgentTask {
|
||||
return {
|
||||
taskId,
|
||||
status: 'running',
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hasRunningBackgroundTasks', () => {
|
||||
it('does not treat AutoDream as foreground session activity', () => {
|
||||
expect(hasRunningBackgroundTasks({
|
||||
dream: task('dream', { taskType: 'dream' }),
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('still reports user-started background tasks as running', () => {
|
||||
expect(hasRunningBackgroundTasks({
|
||||
shell: task('shell', { taskType: 'local_bash' }),
|
||||
dream: task('dream', { taskType: 'dream' }),
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,11 @@ import type { TranslationKey } from '../i18n'
|
||||
type Translator = (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
|
||||
export function hasRunningBackgroundTasks(tasks?: Record<string, BackgroundAgentTask>): boolean {
|
||||
return Object.values(tasks ?? {}).some((task) => task.status === 'running')
|
||||
// AutoDream is detached maintenance work: it remains visible and stoppable
|
||||
// in Activity, but must not keep the foreground conversation marked busy.
|
||||
return Object.values(tasks ?? {}).some(
|
||||
(task) => task.status === 'running' && task.taskType !== 'dream',
|
||||
)
|
||||
}
|
||||
|
||||
export function createBackgroundTaskDismissKey(task: BackgroundAgentTask): string {
|
||||
|
||||
@ -293,6 +293,7 @@ export function ActiveSession() {
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||
const stopBackgroundTask = useChatStore((s) => s.stopBackgroundTask)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
|
||||
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
|
||||
@ -314,6 +315,7 @@ export function ActiveSession() {
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
|
||||
const stoppingBackgroundTaskIds = sessionState?.stoppingBackgroundTaskIds
|
||||
|
||||
const session = sessions.find((s) => s.id === activeTabId)
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
@ -485,6 +487,10 @@ export function ActiveSession() {
|
||||
if (!activeTabId || taskKeys.length === 0) return
|
||||
dismissBackgroundTaskKeys(activeTabId, taskKeys)
|
||||
}, [activeTabId, dismissBackgroundTaskKeys])
|
||||
const handleStopBackgroundTask = useCallback((taskId: string) => {
|
||||
if (!activeTabId) return
|
||||
stopBackgroundTask(activeTabId, taskId)
|
||||
}, [activeTabId, stopBackgroundTask])
|
||||
|
||||
const lastUpdated = useMemo(() => {
|
||||
if (!session?.modifiedAt) return ''
|
||||
@ -680,6 +686,8 @@ export function ActiveSession() {
|
||||
onOpenSubagent={handleOpenSubagentRun}
|
||||
onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks}
|
||||
onOpenMember={handleOpenTeamMember}
|
||||
onStopBackgroundTask={handleStopBackgroundTask}
|
||||
stoppingBackgroundTaskIds={stoppingBackgroundTaskIds}
|
||||
placement="overlay"
|
||||
/>
|
||||
) : null}
|
||||
@ -725,6 +733,8 @@ export function ActiveSession() {
|
||||
onOpenSubagent={handleOpenSubagentRun}
|
||||
onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks}
|
||||
onOpenMember={handleOpenTeamMember}
|
||||
onStopBackgroundTask={handleStopBackgroundTask}
|
||||
stoppingBackgroundTaskIds={stoppingBackgroundTaskIds}
|
||||
placement="rail"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -3344,6 +3344,114 @@ describe('chatStore history mapping', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('requests a background task stop and waits for the terminal event', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({
|
||||
chatState: 'idle',
|
||||
backgroundAgentTasks: {
|
||||
'bash-task-1': {
|
||||
taskId: 'bash-task-1',
|
||||
taskType: 'local_bash',
|
||||
status: 'running',
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().stopBackgroundTask(TEST_SESSION_ID, 'bash-task-1')
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
|
||||
type: 'stop_background_task',
|
||||
taskId: 'bash-task-1',
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.stoppingBackgroundTaskIds).toEqual({
|
||||
'bash-task-1': true,
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['bash-task-1']?.status).toBe('running')
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_notification',
|
||||
data: {
|
||||
task_id: 'bash-task-1',
|
||||
status: 'stopped',
|
||||
summary: 'Sleep stopped',
|
||||
},
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['bash-task-1']?.status).toBe('stopped')
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.stoppingBackgroundTaskIds).toEqual({})
|
||||
})
|
||||
|
||||
it('clears the pending stop marker when the server rejects the request', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({
|
||||
chatState: 'idle',
|
||||
backgroundAgentTasks: {
|
||||
'bash-task-1': {
|
||||
taskId: 'bash-task-1',
|
||||
taskType: 'local_bash',
|
||||
status: 'running',
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
stoppingBackgroundTaskIds: { 'bash-task-1': true },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'background_task_stop_failed',
|
||||
taskId: 'bash-task-1',
|
||||
message: 'Task is not running',
|
||||
})
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.stoppingBackgroundTaskIds).toEqual({})
|
||||
expect(session?.messages.at(-1)).toMatchObject({
|
||||
type: 'error',
|
||||
code: 'STOP_BACKGROUND_TASK_FAILED',
|
||||
message: 'Task is not running',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not surface a stop error when the task already finished naturally', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({
|
||||
chatState: 'idle',
|
||||
messages: [{ id: 'done', type: 'assistant_text', content: 'Done', timestamp: 1 }],
|
||||
backgroundAgentTasks: {
|
||||
'bash-task-1': {
|
||||
taskId: 'bash-task-1',
|
||||
taskType: 'local_bash',
|
||||
status: 'completed',
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
stoppingBackgroundTaskIds: { 'bash-task-1': true },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'background_task_stop_failed',
|
||||
taskId: 'bash-task-1',
|
||||
message: 'Task is not running',
|
||||
})
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.stoppingBackgroundTaskIds).toEqual({})
|
||||
expect(session?.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears local desktop chat state when the server confirms /clear', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@ -127,6 +127,7 @@ export type PerSessionState = {
|
||||
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
|
||||
agentTaskNotifications: Record<string, AgentTaskNotification>
|
||||
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
|
||||
stoppingBackgroundTaskIds?: Record<string, boolean>
|
||||
suppressNextTaskNotificationResponse?: boolean
|
||||
activeGoal?: ActiveGoalState | null
|
||||
elapsedTimer: ReturnType<typeof setInterval> | null
|
||||
@ -166,6 +167,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
backgroundAgentTasks: {},
|
||||
stoppingBackgroundTaskIds: {},
|
||||
suppressNextTaskNotificationResponse: false,
|
||||
activeGoal: null,
|
||||
elapsedTimer: null,
|
||||
@ -282,6 +284,7 @@ type ChatStore = {
|
||||
setSessionRuntime: (sessionId: string, selection: RuntimeSelection) => void
|
||||
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
|
||||
stopGeneration: (sessionId: string) => void
|
||||
stopBackgroundTask: (sessionId: string, taskId: string) => void
|
||||
loadHistory: (sessionId: string) => Promise<void>
|
||||
reloadHistory: (
|
||||
sessionId: string,
|
||||
@ -1345,6 +1348,22 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle')
|
||||
},
|
||||
|
||||
stopBackgroundTask: (sessionId, taskId) => {
|
||||
const session = get().sessions[sessionId]
|
||||
const task = session?.backgroundAgentTasks?.[taskId]
|
||||
if (!task || task.status !== 'running' || session?.stoppingBackgroundTaskIds?.[taskId]) return
|
||||
|
||||
set((state) => ({
|
||||
sessions: updateSessionIn(state.sessions, sessionId, (current) => ({
|
||||
stoppingBackgroundTaskIds: {
|
||||
...current.stoppingBackgroundTaskIds,
|
||||
[taskId]: true,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
wsManager.send(sessionId, { type: 'stop_background_task', taskId })
|
||||
},
|
||||
|
||||
loadHistory: async (sessionId) => {
|
||||
const existingLoad = historyLoadsInFlight.get(sessionId)
|
||||
if (existingLoad) return existingLoad
|
||||
@ -2487,6 +2506,29 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}
|
||||
break
|
||||
|
||||
case 'background_task_stop_failed':
|
||||
update((session) => {
|
||||
const stoppingBackgroundTaskIds = { ...session.stoppingBackgroundTaskIds }
|
||||
delete stoppingBackgroundTaskIds[msg.taskId]
|
||||
const taskAlreadyFinished = session.backgroundAgentTasks?.[msg.taskId]?.status !== 'running'
|
||||
return {
|
||||
stoppingBackgroundTaskIds,
|
||||
...(taskAlreadyFinished ? {} : {
|
||||
messages: [
|
||||
...session.messages,
|
||||
{
|
||||
id: nextId(),
|
||||
type: 'error',
|
||||
message: msg.message,
|
||||
code: 'STOP_BACKGROUND_TASK_FAILED',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'team_created':
|
||||
useTeamStore.getState().handleTeamCreated(msg.teamName)
|
||||
break
|
||||
@ -2546,6 +2588,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
slashCommands: [],
|
||||
activeGoal: null,
|
||||
backgroundAgentTasks: {},
|
||||
stoppingBackgroundTaskIds: {},
|
||||
agentTaskNotifications: {},
|
||||
}))
|
||||
clearPendingDelta(sessionId)
|
||||
@ -2681,8 +2724,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
taskEvent.status === 'failed' ||
|
||||
taskEvent.status === 'stopped') &&
|
||||
shouldSuppressTaskNotificationResponse(session)
|
||||
const stoppingBackgroundTaskIds = { ...session.stoppingBackgroundTaskIds }
|
||||
delete stoppingBackgroundTaskIds[taskEvent.taskId]
|
||||
return {
|
||||
...buildBackgroundTaskSessionUpdate(session, backgroundAgentTasks, task, now),
|
||||
stoppingBackgroundTaskIds,
|
||||
...(suppressNotificationResponse ? { suppressNextTaskNotificationResponse: true } : {}),
|
||||
agentTaskNotifications: {
|
||||
...session.agentTaskNotifications,
|
||||
|
||||
@ -26,6 +26,7 @@ export type ClientMessage =
|
||||
| { type: 'set_permission_mode'; mode: PermissionMode }
|
||||
| ({ type: 'set_runtime_config' } & RuntimeSelection)
|
||||
| { type: 'stop_generation' }
|
||||
| { type: 'stop_background_task'; taskId: string }
|
||||
| { type: 'ping' }
|
||||
|
||||
export type AttachmentRef = {
|
||||
@ -125,6 +126,7 @@ export type ServerMessage =
|
||||
// 流式请求失败后的恢复状态:可能安全重试流,也可能降级为非流式请求。
|
||||
| { type: 'streaming_fallback'; cause: StreamingFallbackCause }
|
||||
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
|
||||
| { type: 'background_task_stop_failed'; taskId: string; message: string }
|
||||
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
|
||||
| { type: 'pong' }
|
||||
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }
|
||||
|
||||
@ -268,6 +268,60 @@ describe('WebSocket handler session isolation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards background task stop requests to the CLI control channel', async () => {
|
||||
const sessionId = `stop-background-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({})
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
type: 'stop_background_task',
|
||||
taskId: 'bash-task-1',
|
||||
}))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(requestControl).toHaveBeenCalledWith(sessionId, {
|
||||
subtype: 'stop_task',
|
||||
task_id: 'bash-task-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a task-scoped failure when the CLI rejects a background stop', async () => {
|
||||
const sessionId = `stop-background-failed-${crypto.randomUUID()}`
|
||||
const ws = makeClientSocket(sessionId)
|
||||
spyOn(conversationService, 'requestControl').mockRejectedValue(new Error('Task is not running'))
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
type: 'stop_background_task',
|
||||
taskId: 'bash-task-1',
|
||||
}))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'background_task_stop_failed',
|
||||
taskId: 'bash-task-1',
|
||||
message: 'Task is not running',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed background task ids without throwing from the async handler', async () => {
|
||||
const ws = makeClientSocket(`stop-background-invalid-${crypto.randomUUID()}`)
|
||||
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({})
|
||||
|
||||
handleWebSocket.message(ws, JSON.stringify({
|
||||
type: 'stop_background_task',
|
||||
taskId: 42,
|
||||
}))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(requestControl).not.toHaveBeenCalled()
|
||||
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
||||
type: 'background_task_stop_failed',
|
||||
taskId: '',
|
||||
message: 'Background task id is required',
|
||||
})
|
||||
})
|
||||
|
||||
it('broadcasts tool and Computer Use permission resolutions to every client', () => {
|
||||
const sessionId = `permission-resolution-${crypto.randomUUID()}`
|
||||
const first = makeClientSocket(sessionId)
|
||||
|
||||
@ -452,6 +452,23 @@ describe('WebSocket background task events', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps AutoDream lifecycle visible without reviving foreground activity', () => {
|
||||
const started = {
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
task_id: 'dream-task-1',
|
||||
description: 'dreaming',
|
||||
task_type: 'dream',
|
||||
}
|
||||
|
||||
expect(translateCliMessage(started, 'session-1')).toEqual([{
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
message: 'dreaming',
|
||||
data: started,
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket goal command events', () => {
|
||||
|
||||
@ -29,6 +29,7 @@ export type ClientMessage =
|
||||
| { type: 'set_permission_mode'; mode: string }
|
||||
| { type: 'set_runtime_config'; providerId: string | null; modelId: string; effortLevel?: string }
|
||||
| { type: 'stop_generation' }
|
||||
| { type: 'stop_background_task'; taskId: string }
|
||||
| { type: 'ping' }
|
||||
|
||||
export type AttachmentRef = {
|
||||
@ -97,6 +98,7 @@ export type ServerMessage =
|
||||
// 期间没有任何增量输出,前端据此显示"慢速模式"轻提示而不是裸转圈。
|
||||
| { type: 'streaming_fallback'; cause: StreamingFallbackCause }
|
||||
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
|
||||
| { type: 'background_task_stop_failed'; taskId: string; message: string }
|
||||
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
|
||||
| { type: 'pong' }
|
||||
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }
|
||||
|
||||
@ -297,6 +297,10 @@ export const handleWebSocket = {
|
||||
handleStopGeneration(ws)
|
||||
break
|
||||
|
||||
case 'stop_background_task':
|
||||
void handleStopBackgroundTask(ws, message)
|
||||
break
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong' } satisfies ServerMessage))
|
||||
break
|
||||
@ -1007,6 +1011,36 @@ function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
}
|
||||
|
||||
async function handleStopBackgroundTask(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
message: Extract<ClientMessage, { type: 'stop_background_task' }>,
|
||||
): Promise<void> {
|
||||
const { sessionId } = ws.data
|
||||
const taskId = typeof message.taskId === 'string' ? message.taskId.trim() : ''
|
||||
|
||||
if (!taskId) {
|
||||
sendMessage(ws, {
|
||||
type: 'background_task_stop_failed',
|
||||
taskId,
|
||||
message: 'Background task id is required',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await conversationService.requestControl(sessionId, {
|
||||
subtype: 'stop_task',
|
||||
task_id: taskId,
|
||||
})
|
||||
} catch (error) {
|
||||
sendMessage(ws, {
|
||||
type: 'background_task_stop_failed',
|
||||
taskId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Title generation
|
||||
// ============================================================================
|
||||
@ -1963,13 +1997,17 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
}]
|
||||
}
|
||||
if (subtype === 'task_started') {
|
||||
const notification: ServerMessage = {
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
message: cliMsg.message || cliMsg.description || 'Task started',
|
||||
data: cliMsg,
|
||||
}
|
||||
// AutoDream is detached maintenance work. Keep it visible in Activity,
|
||||
// but do not revive the already-completed foreground turn.
|
||||
if (cliMsg.task_type === 'dream') return [notification]
|
||||
return [
|
||||
{
|
||||
type: 'system_notification',
|
||||
subtype: 'task_started',
|
||||
message: cliMsg.message || cliMsg.description || 'Task started',
|
||||
data: cliMsg,
|
||||
},
|
||||
notification,
|
||||
{
|
||||
type: 'status',
|
||||
state: 'tool_executing',
|
||||
|
||||
86
src/tasks/stopTask.test.ts
Normal file
86
src/tasks/stopTask.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setIsInteractive,
|
||||
switchSession,
|
||||
} from '../bootstrap/state.js'
|
||||
import type { AppState } from '../state/AppState.js'
|
||||
import type { SessionId } from '../types/ids.js'
|
||||
import { drainSdkEvents } from '../utils/sdkEventQueue.js'
|
||||
import { stopTask } from './stopTask.js'
|
||||
|
||||
function makeShellTaskHarness() {
|
||||
let killed = false
|
||||
let state = {
|
||||
tasks: {
|
||||
btask123: {
|
||||
id: 'btask123',
|
||||
type: 'local_bash',
|
||||
status: 'running',
|
||||
description: 'Sleep for 300 seconds',
|
||||
command: 'sleep 300',
|
||||
toolUseId: 'bash-tool-1',
|
||||
startTime: 1,
|
||||
outputFile: '/tmp/btask123.output',
|
||||
outputOffset: 0,
|
||||
notified: false,
|
||||
completionStatusSentInAttachment: false,
|
||||
shellCommand: {
|
||||
kill: () => {
|
||||
killed = true
|
||||
},
|
||||
cleanup: () => {},
|
||||
},
|
||||
lastReportedTotalLines: 0,
|
||||
isBackgrounded: true,
|
||||
},
|
||||
},
|
||||
} as unknown as AppState
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
get killed() {
|
||||
return killed
|
||||
},
|
||||
setAppState(updater: (prev: AppState) => AppState) {
|
||||
state = updater(state)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStateForTests()
|
||||
setIsInteractive(false)
|
||||
switchSession('stop-task-sdk-events' as SessionId)
|
||||
drainSdkEvents()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
drainSdkEvents()
|
||||
resetStateForTests()
|
||||
})
|
||||
|
||||
describe('stopTask SDK events', () => {
|
||||
test('emits a stopped bookend after LocalShellTask marks itself notified', async () => {
|
||||
const harness = makeShellTaskHarness()
|
||||
|
||||
await stopTask('btask123', {
|
||||
getAppState: () => harness.state,
|
||||
setAppState: harness.setAppState,
|
||||
})
|
||||
|
||||
expect(harness.killed).toBe(true)
|
||||
expect(harness.state.tasks.btask123?.status).toBe('killed')
|
||||
expect(drainSdkEvents()).toContainEqual(expect.objectContaining({
|
||||
type: 'system',
|
||||
subtype: 'task_notification',
|
||||
task_id: 'btask123',
|
||||
tool_use_id: 'bash-tool-1',
|
||||
status: 'stopped',
|
||||
summary: 'Sleep for 300 seconds',
|
||||
session_id: 'stop-task-sdk-events',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -62,19 +62,22 @@ export async function stopTask(
|
||||
)
|
||||
}
|
||||
|
||||
// LocalShellTask.kill() atomically marks the task notified before returning.
|
||||
// Capture the pre-kill state so the desktop SDK bookend is not suppressed by
|
||||
// that implementation detail.
|
||||
const shouldEmitShellTermination = isLocalShellTask(task) && !task.notified
|
||||
|
||||
await taskImpl.kill(taskId, setAppState)
|
||||
|
||||
// Bash: suppress the "exit code 137" notification (noise). Agent tasks: don't
|
||||
// suppress — the AbortError catch sends a notification carrying
|
||||
// extractPartialResult(agentMessages), which is the payload not noise.
|
||||
if (isLocalShellTask(task)) {
|
||||
let suppressed = false
|
||||
setAppState(prev => {
|
||||
const prevTask = prev.tasks[taskId]
|
||||
if (!prevTask || prevTask.notified) {
|
||||
return prev
|
||||
}
|
||||
suppressed = true
|
||||
return {
|
||||
...prev,
|
||||
tasks: {
|
||||
@ -86,7 +89,7 @@ export async function stopTask(
|
||||
// Suppressing the XML notification also suppresses print.ts's parsed
|
||||
// task_notification SDK event — emit it directly so SDK consumers see
|
||||
// the task close.
|
||||
if (suppressed) {
|
||||
if (shouldEmitShellTermination) {
|
||||
emitTaskTerminatedSdk(taskId, 'stopped', {
|
||||
toolUseId: task.toolUseId,
|
||||
summary: task.description,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user