Keep stopped background tasks from looking alive

A desktop session can receive a successful TaskStop result without a matching task_notification bookend. The transcript already proves the background command was stopped, but the desktop store kept the local_bash task record in running state, leaving the card spinner active after the assistant turn had ended.

This change derives the stopped background-task update from successful TaskStop/KillShell tool results and upserts the transcript task card through the existing background task merge path. Failed TaskStop results are ignored so an unsuccessful stop cannot hide still-running work.

Constraint: Some persisted and live task-stop paths only surface as ordinary tool_result messages.
Rejected: Change composer/run-button state | that would couple input availability to background task bookkeeping and miss the stuck task card.
Confidence: high
Scope-risk: narrow
Directive: Keep TaskStop result handling gated on non-error results; failed stops must not mark background tasks terminal.
Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Full root verify gate; change is scoped to desktop store and covered by check:desktop.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 21:58:31 +08:00
parent fed9b6c9f6
commit 685560f62c
2 changed files with 132 additions and 6 deletions

View File

@ -1641,6 +1641,64 @@ describe('chatStore history mapping', () => {
vi.useRealTimers()
})
it('marks a background shell task stopped when TaskStop returns before a task notification', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-19T13:34:19.000Z'))
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession(),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_started',
data: {
task_id: 'shell-task-1',
tool_use_id: 'shell-tool-1',
description: 'Start tap proxy',
task_type: 'local_bash',
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'TaskStop',
toolUseId: 'task-stop-1',
input: { task_id: 'shell-task-1' },
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_result',
toolUseId: 'task-stop-1',
isError: false,
content: JSON.stringify({
message: 'Successfully stopped task: shell-task-1 (tap proxy)',
task_id: 'shell-task-1',
task_type: 'local_bash',
command: 'tap proxy',
}),
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.backgroundAgentTasks?.['shell-task-1']).toMatchObject({
status: 'stopped',
taskType: 'local_bash',
description: 'tap proxy',
})
expect(session?.messages.find((message) => message.type === 'background_task')).toMatchObject({
type: 'background_task',
task: {
taskId: 'shell-task-1',
status: 'stopped',
taskType: 'local_bash',
description: 'tap proxy',
},
})
vi.useRealTimers()
})
it('removes stale agent task transcript cards by matching tool use id', () => {
useChatStore.setState({
sessions: {

View File

@ -32,6 +32,7 @@ import type {
} from '../types/chat'
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
export type ComposerDraftState = {
input: string
@ -143,6 +144,7 @@ type ChatStore = {
}
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
const TASK_STOP_TOOL_NAMES = new Set(['TaskStop', 'KillShell'])
const pendingTaskToolUseIdsBySession = new Map<string, Set<string>>()
const pendingToolParentUseIdsBySession = new Map<string, Map<string, string>>()
@ -1059,15 +1061,32 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
case 'tool_result': {
const now = Date.now()
const pendingParentToolUseId = consumePendingToolParentUseId(sessionId, msg.toolUseId)
const parentToolUseId = msg.parentToolUseId ?? pendingParentToolUseId
update((s) => ({
messages: [...s.messages, {
update((s) => {
let messages: UIMessage[] = [...s.messages, {
id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId,
}],
chatState: 'thinking', activeThinkingId: null,
}))
content: msg.content, isError: msg.isError, timestamp: now, parentToolUseId,
}]
let backgroundAgentTasks = s.backgroundAgentTasks ?? {}
const stoppedTask = msg.isError
? null
: getStoppedBackgroundTaskFromToolResult(s.messages, msg.toolUseId, msg.content)
if (stoppedTask) {
backgroundAgentTasks = upsertBackgroundAgentTask(backgroundAgentTasks, stoppedTask, now)
const task = backgroundAgentTasks[stoppedTask.taskId]
if (task) {
messages = upsertBackgroundTaskMessage(messages, task, now)
}
}
return {
messages,
...(stoppedTask ? { backgroundAgentTasks } : {}),
chatState: 'thinking',
activeThinkingId: null,
}
})
if (consumePendingTaskToolUseId(sessionId, msg.toolUseId)) {
useCLITaskStore.getState().refreshTasks(sessionId)
}
@ -1470,6 +1489,55 @@ function readNonEmptyString(record: Record<string, unknown>, ...keys: string[]):
return undefined
}
function readRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function parseJsonRecord(value: unknown): Record<string, unknown> | null {
const record = readRecord(value)
if (record) return record
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed.startsWith('{')) return null
try {
return readRecord(JSON.parse(trimmed))
} catch {
return null
}
}
function findToolUseMessage(messages: UIMessage[], toolUseId: string): ToolCall | null {
return messages.find((
message,
): message is ToolCall =>
message.type === 'tool_use' &&
message.toolUseId === toolUseId) ?? null
}
function getStoppedBackgroundTaskFromToolResult(
messages: UIMessage[],
toolUseId: string,
content: unknown,
): (Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>) | null {
const toolUse = findToolUseMessage(messages, toolUseId)
if (!toolUse || !TASK_STOP_TOOL_NAMES.has(toolUse.toolName)) return null
const input = readRecord(toolUse.input) ?? {}
const output = parseJsonRecord(content) ?? {}
const taskId = readNonEmptyString(output, 'task_id', 'taskId') ??
readNonEmptyString(input, 'task_id', 'taskId', 'shell_id', 'shellId')
if (!taskId) return null
return {
taskId,
status: 'stopped',
taskType: readNonEmptyString(output, 'task_type', 'taskType'),
description: readNonEmptyString(output, 'command', 'description', 'message'),
summary: readNonEmptyString(output, 'message'),
}
}
function normalizeBackgroundTaskUsage(value: unknown): BackgroundAgentTaskUsage | undefined {
if (!value || typeof value !== 'object') return undefined
const record = value as Record<string, unknown>