mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
fed9b6c9f6
commit
685560f62c
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user