fix(desktop): stop background tasks when closing a running session

The "Session running" dialog treats a running background task as the session
being busy, but Stop & Close only called stopGeneration, which interrupts the
foreground turn and Agent tasks. A background shell command (and its CLI) kept
running, so the reopened session spun again and asked the same question on the
next close.

Stop & Close now also stops every running background task, before the socket is
closed. "Is the session running" and "which tasks to stop" share one predicate
in backgroundTasks, so the dialog cannot offer a Stop that leaves the task that
raised it alive. Keep running sends nothing.

Refs #1398
This commit is contained in:
程序员阿江(Relakkes)
2026-09-29 15:45:26 +08:00
parent fba81834de
commit 096e87619d
6 changed files with 343 additions and 11 deletions
+268 -1
View File
@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import type { PerSessionState } from '../../stores/chatStore'
import type { ChatState, UIMessage } from '../../types/chat'
import type { BackgroundAgentTask, ChatState, UIMessage } from '../../types/chat'
import type { TeamWorkbenchSessionTimeline, TeamWorkbenchTask, TeamWorkbenchTimeline } from '../../types/team'
import type { WorkflowRun } from '../../types/workflow'
import { browserHost } from '../../lib/desktopHost/browserHost'
@@ -84,6 +84,29 @@ function makeChatSession(chatState: ChatState): PerSessionState {
}
}
function makeBackgroundTask(
taskId: string,
overrides: Partial<BackgroundAgentTask> = {},
): BackgroundAgentTask {
return {
taskId,
toolUseId: `tool-${taskId}`,
status: 'running',
taskType: 'local_bash',
description: `Task ${taskId}`,
startedAt: 1,
updatedAt: 2,
...overrides,
}
}
function makeSessionWithTasks(chatState: ChatState, tasks: BackgroundAgentTask[]): PerSessionState {
return {
...makeChatSession(chatState),
backgroundAgentTasks: Object.fromEntries(tasks.map((task) => [task.taskId, task])),
}
}
const completedTodoWriteMessage = (overrides: Partial<ToolUseMessage> = {}): UIMessage => ({
id: 'todo-1',
type: 'tool_use',
@@ -2883,6 +2906,250 @@ describe('TabBar', () => {
expect(useTabStore.getState().tabs).toEqual([])
})
// The "Session running" dialog counts background tasks as work in progress, so
// its Stop & Close has to stop them. `stopGeneration` alone does not: the server
// only interrupts the foreground turn and Agent tasks, which left a background
// shell command (and the session it kept busy) alive after "Stop & Close" and
// made the reopened session ask the same question again (issue #1398).
//
// These tests run the real chat-store actions on top of a recorded socket so
// they see what would really leave the client, and in which order.
describe('stopping background tasks when closing a running session', () => {
async function recordSocketTraffic() {
const { wsManager } = await import('../../api/websocket')
const traffic: string[] = []
vi.spyOn(wsManager, 'send').mockImplementation((sessionId, message) => {
traffic.push(
`send ${sessionId} ${message.type}${message.type === 'stop_background_task' ? ` ${message.taskId}` : ''}`,
)
})
vi.spyOn(wsManager, 'disconnect').mockImplementation((sessionId) => {
traffic.push(`disconnect ${sessionId}`)
})
return traffic
}
it('stops the background shell task that kept an otherwise idle session running', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const traffic = await recordSocketTraffic()
useTabStore.setState({
tabs: [{ sessionId: 'tab-shell', title: 'Shell Session', type: 'session', status: 'idle' }],
activeTabId: 'tab-shell',
})
useChatStore.setState({
sessions: {
'tab-shell': makeSessionWithTasks('idle', [makeBackgroundTask('shell-1')]),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close Shell Session'))
expect(screen.getByRole('dialog', { name: 'Session Running' })).toBeInTheDocument()
expect(traffic).toEqual([])
fireEvent.click(screen.getByText('Stop & Close'))
expect(traffic).toEqual([
'send tab-shell stop_generation',
'send tab-shell stop_background_task shell-1',
'disconnect tab-shell',
])
expect(useTabStore.getState().tabs).toEqual([])
})
it('stops only the tasks that are still running and count as session activity', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const traffic = await recordSocketTraffic()
useTabStore.setState({
tabs: [{ sessionId: 'tab-mixed', title: 'Mixed Session', type: 'session', status: 'running' }],
activeTabId: 'tab-mixed',
})
useChatStore.setState({
sessions: {
'tab-mixed': makeSessionWithTasks('thinking', [
makeBackgroundTask('shell-running'),
makeBackgroundTask('shell-done', { status: 'completed' }),
makeBackgroundTask('shell-failed', { status: 'failed' }),
makeBackgroundTask('shell-stopped', { status: 'stopped' }),
// AutoDream is detached maintenance: it never counted as the session running.
makeBackgroundTask('dream-running', { taskType: 'dream' }),
// A teammate runtime is a container, not an activity row the user can stop.
makeBackgroundTask('teammate-running', { taskType: 'in_process_teammate' }),
]),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close Mixed Session'))
fireEvent.click(screen.getByText('Stop & Close'))
expect(traffic).toEqual([
'send tab-mixed stop_generation',
'send tab-mixed stop_background_task shell-running',
'disconnect tab-mixed',
])
})
it('does not stop a running Agent a second time after the session-level stop', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const traffic = await recordSocketTraffic()
useTabStore.setState({
tabs: [{ sessionId: 'tab-agent', title: 'Agent Session', type: 'session', status: 'idle' }],
activeTabId: 'tab-agent',
})
useChatStore.setState({
sessions: {
'tab-agent': makeSessionWithTasks('idle', [
makeBackgroundTask('agent-1', { taskType: 'local_agent' }),
makeBackgroundTask('shell-1'),
]),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close Agent Session'))
fireEvent.click(screen.getByText('Stop & Close'))
// `stopGeneration` already marks the Agent as stopping; only the shell is left to stop.
expect(traffic).toEqual([
'send tab-agent stop_generation',
'send tab-agent stop_background_task shell-1',
'disconnect tab-agent',
])
})
it('sends no stop message when the user keeps the session running', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const traffic = await recordSocketTraffic()
useTabStore.setState({
tabs: [{ sessionId: 'tab-shell', title: 'Shell Session', type: 'session', status: 'idle' }],
activeTabId: 'tab-shell',
})
useChatStore.setState({
sessions: {
'tab-shell': makeSessionWithTasks('idle', [makeBackgroundTask('shell-1')]),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close Shell Session'))
fireEvent.click(screen.getByText('Keep Running'))
expect(traffic).toEqual([])
expect(useTabStore.getState().tabs).toEqual([])
expect(useChatStore.getState().sessions['tab-shell']?.backgroundAgentTasks?.['shell-1']?.status).toBe('running')
})
it('stops the background tasks of every running session when closing all tabs', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const traffic = await recordSocketTraffic()
useTabStore.setState({
tabs: [
{ sessionId: 'tab-a', title: 'Session A', type: 'session', status: 'idle' },
{ sessionId: 'tab-b', title: 'Session B', type: 'session', status: 'idle' },
{ sessionId: 'tab-idle', title: 'Idle Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-a',
})
useChatStore.setState({
sessions: {
'tab-a': makeSessionWithTasks('idle', [makeBackgroundTask('a-1')]),
'tab-b': makeSessionWithTasks('idle', [makeBackgroundTask('b-1'), makeBackgroundTask('b-2')]),
'tab-idle': makeSessionWithTasks('idle', [makeBackgroundTask('old', { status: 'completed' })]),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.contextMenu(screen.getByText('Session A'))
fireEvent.click(screen.getByText('Close All'))
expect(screen.getByRole('dialog', { name: 'Sessions Running' })).toBeInTheDocument()
expect(traffic).toEqual([])
fireEvent.click(screen.getByText('Stop All & Close'))
expect(traffic).toEqual([
'send tab-a stop_generation',
'send tab-a stop_background_task a-1',
'disconnect tab-a',
'send tab-b stop_generation',
'send tab-b stop_background_task b-1',
'send tab-b stop_background_task b-2',
'disconnect tab-b',
'disconnect tab-idle',
])
expect(useTabStore.getState().tabs).toEqual([])
})
it('leaves the tabs that stay open alone when closing the others', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const traffic = await recordSocketTraffic()
useTabStore.setState({
tabs: [
{ sessionId: 'tab-other', title: 'Other Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-kept', title: 'Kept Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-kept',
})
useChatStore.setState({
sessions: {
'tab-other': makeSessionWithTasks('idle', [makeBackgroundTask('other-1')]),
'tab-kept': makeSessionWithTasks('idle', [makeBackgroundTask('kept-1')]),
},
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.contextMenu(screen.getByText('Kept Session'))
fireEvent.click(screen.getByText('Close Others'))
fireEvent.click(screen.getByText('Stop & Close'))
expect(traffic).toEqual([
'send tab-other stop_generation',
'send tab-other stop_background_task other-1',
'disconnect tab-other',
])
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-kept'])
expect(useChatStore.getState().sessions['tab-kept']?.backgroundAgentTasks?.['kept-1']?.status).toBe('running')
})
})
it('shows a running marker on tabs from tab status, live chat state, or background tasks', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
+12 -2
View File
@@ -28,7 +28,7 @@ import { IconButton } from '@/components/ui/IconButton'
import { useDismissable } from '@/hooks/useDismissable'
import { useTranslation } from '../../i18n'
import { getDesktopHost } from '../../lib/desktopHost'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { hasRunningBackgroundTasks, listRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { WindowControls, showWindowControls } from './WindowControls'
import { OpenProjectMenu } from './OpenProjectMenu'
import { SquareTerminal } from 'lucide-react'
@@ -371,7 +371,17 @@ export function TabBar() {
if (isSessionTab(tab)) {
const isRunning = runningSessionSet.has(tab.sessionId)
if (isRunning && stopRunning) {
useChatStore.getState().stopGeneration(tab.sessionId)
const chat = useChatStore.getState()
const runningTasks = listRunningBackgroundTasks(chat.sessions[tab.sessionId]?.backgroundAgentTasks)
chat.stopGeneration(tab.sessionId)
// The dialog counts background tasks as the session running, but
// stopGeneration only reaches the foreground turn and Agent tasks (and
// marks the latter as stopping, which stopBackgroundTask skips). A shell
// command would outlive "Stop & Close" and keep the reopened session
// running. Both must go out before disconnectSession closes the socket.
for (const task of runningTasks) {
chat.stopBackgroundTask(tab.sessionId, task.taskId)
}
}
if (!isRunning || stopRunning) {
// Auto-delete only when both server metadata and the loaded transcript
+43
View File
@@ -6,6 +6,7 @@ import {
hasRunningBackgroundTasks,
hasRunningSubagentTasks,
isVisibleSessionBackgroundTask,
listRunningBackgroundTasks,
} from './backgroundTasks'
import { translate } from '../i18n'
@@ -44,6 +45,48 @@ describe('hasRunningBackgroundTasks', () => {
})
})
describe('listRunningBackgroundTasks', () => {
it('lists user-started tasks that are still running, in insertion order', () => {
const tasks = {
shell: task('shell', { taskType: 'local_bash' }),
agent: task('agent', { taskType: 'local_agent' }),
workflow: task('workflow', { taskType: 'local_workflow' }),
}
expect(listRunningBackgroundTasks(tasks).map((entry) => entry.taskId)).toEqual(['shell', 'agent', 'workflow'])
})
it('leaves out finished tasks, AutoDream and teammate runtime containers', () => {
const tasks = {
shell: task('shell', { taskType: 'local_bash' }),
completed: task('completed', { taskType: 'local_bash', status: 'completed' }),
failed: task('failed', { taskType: 'local_bash', status: 'failed' }),
stopped: task('stopped', { taskType: 'local_bash', status: 'stopped' }),
dream: task('dream', { taskType: 'dream' }),
teammate: task('teammate', { taskType: 'in_process_teammate' }),
}
expect(listRunningBackgroundTasks(tasks).map((entry) => entry.taskId)).toEqual(['shell'])
})
it('is empty for a missing task record', () => {
expect(listRunningBackgroundTasks(undefined)).toEqual([])
expect(listRunningBackgroundTasks({})).toEqual([])
})
it('agrees with hasRunningBackgroundTasks about what counts as running', () => {
const records: Array<Record<string, BackgroundAgentTask>> = [
{},
{ dream: task('dream', { taskType: 'dream' }) },
{ teammate: task('teammate', { taskType: 'in_process_teammate' }) },
{ done: task('done', { status: 'completed' }) },
{ shell: task('shell', { taskType: 'local_bash' }) },
{ shell: task('shell', { taskType: 'local_bash' }), dream: task('dream', { taskType: 'dream' }) },
]
for (const record of records) {
expect(listRunningBackgroundTasks(record).length > 0).toBe(hasRunningBackgroundTasks(record))
}
})
})
describe('hasRunningSubagentTasks', () => {
it.each(['local_agent', 'remote_agent'])('reports a running %s as stoppable', (taskType) => {
expect(hasRunningSubagentTasks({
+18 -6
View File
@@ -10,14 +10,26 @@ export function isVisibleSessionBackgroundTask(
return task.taskType !== 'in_process_teammate'
}
export function hasRunningBackgroundTasks(tasks?: Record<string, BackgroundAgentTask>): boolean {
function isBusyBackgroundTask(task: BackgroundAgentTask): boolean {
// 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) => isVisibleSessionBackgroundTask(task) &&
task.status === 'running' &&
task.taskType !== 'dream',
)
return isVisibleSessionBackgroundTask(task) &&
task.status === 'running' &&
task.taskType !== 'dream'
}
export function hasRunningBackgroundTasks(tasks?: Record<string, BackgroundAgentTask>): boolean {
return Object.values(tasks ?? {}).some(isBusyBackgroundTask)
}
/**
* The tasks `hasRunningBackgroundTasks` counted, for a caller that has been told
* to stop them. Asking "is this session running?" and stopping it share one
* definition so a confirmation can never offer a Stop that leaves running the
* very task that raised it.
*/
export function listRunningBackgroundTasks(tasks?: Record<string, BackgroundAgentTask>): BackgroundAgentTask[] {
return Object.values(tasks ?? {}).filter(isBusyBackgroundTask)
}
export function hasRunningSubagentTasks(tasks?: Record<string, BackgroundAgentTask>): boolean {
+1 -1
View File
@@ -13,7 +13,7 @@ order: 1
点侧边栏的「新建会话」,或按 `⌘N`(Windows / Linux 是 `Ctrl+N`)。空会话首屏只要求你做一件事:选一个项目目录。选完就能提问了,模型和权限沿用你在设置里的默认值。
会话开出来是一个标签页,可以像浏览器一样开很多个并排跑。标签上有小圆点表示这条会话还在运行;关一个正在跑的标签会先问你是「保持运行」还是「停止并关闭」。
会话开出来是一个标签页,可以像浏览器一样开很多个并排跑。标签上有小圆点表示这条会话还在运行;关一个正在跑的标签会先问你是「保持运行」还是「停止并关闭」;「停止并关闭」会连这条会话里还在跑的后台任务一起停掉。
会话标题下面那行小字是元信息:项目路径、分支、模型。想换项目就新建一条会话,一条会话绑定一个目录。
+1 -1
View File
@@ -13,7 +13,7 @@ A session is one complete collaboration: you describe what you want, Claude read
Click **New session** in the sidebar, or press `⌘N` (`Ctrl+N` on Windows and Linux). The empty session asks you for exactly one thing: a project directory. After that you can start typing — the model and permission mode come from your defaults in Settings.
Each session opens as a tab, and you can run many side by side. A dot on the tab means that session is still running; closing a running tab asks whether you want to **Keep running** or **Stop and close**.
Each session opens as a tab, and you can run many side by side. A dot on the tab means that session is still running; closing a running tab asks whether you want to **Keep running** or **Stop and close**, and **Stop and close** also stops the background tasks that session still has running.
The small line under the session title is metadata: project path, branch, model. A session is bound to one directory — to work on a different project, start a new session.