diff --git a/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts b/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts index fef15c358..d44bc16aa 100644 --- a/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts +++ b/packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts @@ -184,6 +184,212 @@ describe('adaptOpenAIStreamToAnthropic', () => { expect(fullArgs).toBe('{"command":"ls"}') }) + test('deduplicates cumulative tool argument snapshots', async () => { + const events = await collectEvents([ + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_cumulative', + type: 'function', + function: { name: 'TaskUpdate', arguments: '{"status"' }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '{"status":"completed"' }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '{"status":"completed","taskId":"1"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + }), + ]) + + const jsonDeltas = events.filter( + e => + e.type === 'content_block_delta' && e.delta.type === 'input_json_delta', + ) as any[] + expect(jsonDeltas.map(e => e.delta.partial_json).join('')).toBe( + '{"status":"completed","taskId":"1"}', + ) + }) + + test('starts a new tool block when a backend reuses tool_calls index with a new id', async () => { + const events = await collectEvents([ + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'TaskUpdate', arguments: '' }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '{"status": in_progresss", "taskId": "1"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_2', + type: 'function', + function: { + name: 'TaskUpdate', + arguments: '{"status": "completed", "taskId": "2"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + }), + ]) + + const blockStarts = events.filter( + e => e.type === 'content_block_start', + ) as any[] + expect(blockStarts.map(e => e.content_block.id)).toEqual([ + 'call_1', + 'call_2', + ]) + + const jsonDeltas = events.filter( + e => + e.type === 'content_block_delta' && e.delta.type === 'input_json_delta', + ) as any[] + expect(jsonDeltas.map(e => e.index)).toEqual([ + blockStarts[0].index, + blockStarts[1].index, + ]) + }) + + test('keeps one tool block when a backend sends the id after arguments started', async () => { + const events = await collectEvents([ + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + type: 'function', + function: { name: 'TaskUpdate', arguments: '{"status":' }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_late', + type: 'function', + function: { arguments: '"completed"}' }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + }), + ]) + + const blockStarts = events.filter( + e => e.type === 'content_block_start', + ) as any[] + expect(blockStarts).toHaveLength(1) + + const jsonDeltas = events.filter( + e => + e.type === 'content_block_delta' && e.delta.type === 'input_json_delta', + ) as any[] + expect(jsonDeltas.map(e => e.delta.partial_json).join('')).toBe( + '{"status":"completed"}', + ) + }) + test('maps finish_reason stop to end_turn', async () => { const events = await collectEvents([ makeChunk({ @@ -256,6 +462,26 @@ describe('adaptOpenAIStreamToAnthropic', () => { expect(msgDelta.delta.stop_reason).toBe('tool_use') }) + test('maps finish_reason tool_calls without tool calls to tool_use', async () => { + const events = await collectEvents([ + makeChunk({ + choices: [ + { + index: 0, + delta: { content: 'I should list tasks next.' }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + }), + ]) + + const msgDelta = events.find(e => e.type === 'message_delta') as any + expect(msgDelta.delta.stop_reason).toBe('tool_use') + }) + test('maps finish_reason length to max_tokens', async () => { const events = await collectEvents([ makeChunk({ @@ -308,6 +534,26 @@ describe('adaptOpenAIStreamToAnthropic', () => { expect(blockStarts[0].content_block.type).toBe('text') expect(blockStarts[1].content_block.type).toBe('tool_use') }) + + test('preserves tool_calls finish reason when provider omits tool call blocks', async () => { + const events = await collectEvents([ + makeChunk({ + choices: [ + { + index: 0, + delta: { content: '现在调用 TaskUpdate 标记完成。' }, + finish_reason: null, + }, + ], + }), + makeChunk({ + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + }), + ]) + + const messageDelta = events.find(e => e.type === 'message_delta') as any + expect(messageDelta.delta.stop_reason).toBe('tool_use') + }) }) describe('thinking support (reasoning_content)', () => { diff --git a/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts b/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts index 02e32d957..f41c25739 100644 --- a/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts +++ b/packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts @@ -44,7 +44,13 @@ export async function* adaptOpenAIStreamToAnthropic( // Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments } const toolBlocks = new Map< number, - { contentIndex: number; id: string; name: string; arguments: string } + { + contentIndex: number + id: string + name: string + arguments: string + idFromProvider: boolean + } >() // Track thinking block state @@ -183,6 +189,25 @@ export async function* adaptOpenAIStreamToAnthropic( for (const tc of delta.tool_calls) { const tcIndex = tc.index + const existingBlock = toolBlocks.get(tcIndex) + const hasNewToolIdentity = + existingBlock && + ((tc.id && existingBlock.idFromProvider && tc.id !== existingBlock.id) || + (tc.function?.name && + existingBlock.name && + tc.function.name !== existingBlock.name)) + + if (hasNewToolIdentity) { + if (openBlockIndices.has(existingBlock.contentIndex)) { + yield { + type: 'content_block_stop', + index: existingBlock.contentIndex, + } as BetaRawMessageStreamEvent + openBlockIndices.delete(existingBlock.contentIndex) + } + toolBlocks.delete(tcIndex) + } + if (!toolBlocks.has(tcIndex)) { // Close thinking block if open if (thinkingBlockOpen) { @@ -215,6 +240,7 @@ export async function* adaptOpenAIStreamToAnthropic( id: toolId, name: toolName, arguments: '', + idFromProvider: Boolean(tc.id), }) openBlockIndices.add(currentContentIndex) @@ -233,13 +259,25 @@ export async function* adaptOpenAIStreamToAnthropic( // Stream argument fragments const argFragment = tc.function?.arguments if (argFragment) { - toolBlocks.get(tcIndex)!.arguments += argFragment + const block = toolBlocks.get(tcIndex)! + let deltaFragment = argFragment + if ( + block.arguments && + argFragment.startsWith(block.arguments) + ) { + deltaFragment = argFragment.slice(block.arguments.length) + block.arguments = argFragment + } else { + block.arguments += argFragment + } + + if (!deltaFragment) continue yield { type: 'content_block_delta', - index: toolBlocks.get(tcIndex)!.contentIndex, + index: block.contentIndex, delta: { type: 'input_json_delta', - partial_json: argFragment, + partial_json: deltaFragment, }, } as BetaRawMessageStreamEvent } diff --git a/src/__tests__/queryMissingToolUseRecovery.test.ts b/src/__tests__/queryMissingToolUseRecovery.test.ts new file mode 100644 index 000000000..4dab1c2a1 --- /dev/null +++ b/src/__tests__/queryMissingToolUseRecovery.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { randomUUID } from 'crypto' +import { resetStateForTests } from '../bootstrap/state' +import { query } from '../query' +import { getEmptyToolPermissionContext } from '../Tool' +import type { AssistantMessage } from '../types/message' +import { createUserMessage } from '../utils/messages' +import { asSystemPrompt } from '../utils/systemPromptType' + +beforeEach(() => { + resetStateForTests() +}) + +function createToolUseContext(): any { + let appState = { + toolPermissionContext: getEmptyToolPermissionContext(), + fastMode: false, + mcp: { + tools: [], + clients: [], + }, + effortValue: undefined, + advisorModel: undefined, + sessionHooks: new Map(), + } + + return { + options: { + commands: [], + debug: false, + mainLoopModel: 'claude-sonnet-4-5-20250929', + tools: [], + verbose: false, + thinkingConfig: { type: 'disabled' }, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: true, + agentDefinitions: { + activeAgents: [], + allowedAgentTypes: [], + }, + }, + abortController: new AbortController(), + readFileState: new Map(), + getAppState: () => appState, + setAppState: (updater: (state: any) => any) => { + appState = updater(appState as never) + }, + setInProgressToolUseIDs: () => {}, + setResponseLength: () => {}, + updateFileHistoryState: () => {}, + updateAttributionState: () => {}, + messages: [], + } as any +} + +function createAssistantMessage( + content: Array<{ type: 'text'; text: string }>, + stopReason: 'end_turn' | 'tool_use', +): AssistantMessage { + return { + type: 'assistant', + uuid: randomUUID(), + timestamp: new Date().toISOString(), + requestId: undefined, + message: { + id: `msg_${stopReason}`, + type: 'message', + role: 'assistant', + model: 'test-model', + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + content, + }, + } as unknown as AssistantMessage +} + +describe('query missing tool_use recovery', () => { + test('continues once when provider reports tool_use without a tool_use block', async () => { + const toolUseContext = createToolUseContext() + + let callCount = 0 + const observedPrompts: unknown[] = [] + const deps = { + uuid: () => 'query-chain-id', + microcompact: async (messages: unknown[]) => ({ messages }), + autocompact: async () => ({ + compactionResult: undefined, + consecutiveFailures: 0, + }), + callModel: async function* ({ messages }: { messages: unknown[] }) { + callCount += 1 + observedPrompts.push(messages) + if (callCount === 1) { + yield createAssistantMessage( + [{ type: 'text', text: '现在调用 TaskUpdate 标记完成。' }], + 'tool_use', + ) + return + } + yield createAssistantMessage( + [{ type: 'text', text: '已继续处理。' }], + 'end_turn', + ) + }, + } + + const generator = query({ + messages: [ + createUserMessage({ + content: '创建一个任务列表,并标记任务状态为完成', + }), + ], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async (_tool, input) => ({ + behavior: 'allow', + updatedInput: input, + }), + toolUseContext, + querySource: 'sdk', + maxTurns: 3, + deps: deps as never, + }) + + let next = await generator.next() + while (!next.done) { + next = await generator.next() + } + + expect(next.value.reason).toBe('completed') + expect(callCount).toBe(2) + expect(JSON.stringify(observedPrompts[1])).toContain( + 'Your previous response ended with stop_reason=tool_use', + ) + }) +}) diff --git a/src/query.ts b/src/query.ts index cfdae5eb1..79c63c251 100644 --- a/src/query.ts +++ b/src/query.ts @@ -194,6 +194,20 @@ function isWithheldMaxOutputTokens( return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens' } +function isMissingToolUseResponse(msg: Message | undefined): boolean { + if (msg?.type !== 'assistant') return false + if (msg.isApiErrorMessage) return false + const message = msg.message + if (!message) return false + if (message.stop_reason !== 'tool_use') return false + const content = message.content + if (!Array.isArray(content)) return false + return !content.some(block => block.type === 'tool_use') +} + +const MISSING_TOOL_USE_RECOVERY_MESSAGE = + 'Your previous response ended with stop_reason=tool_use but did not include a tool_use block. Continue by issuing the missing tool call now. If the user asked to update task state, call TaskUpdate with the appropriate taskId and status; do not just describe the action.' + export type QueryParams = { messages: Message[] systemPrompt: SystemPrompt @@ -1357,6 +1371,37 @@ async function* queryLoop( return { reason: 'completed' } } + if ( + isMissingToolUseResponse(lastMessage) && + state.transition?.reason !== 'missing_tool_use_recovery' + ) { + logEvent('tengu_missing_tool_use_recovery', { + queryChainId: queryChainIdForAnalytics, + queryDepth: queryTracking.depth, + }) + const next: State = { + messages: [ + ...messagesForQuery, + ...assistantMessages, + createUserMessage({ + content: MISSING_TOOL_USE_RECOVERY_MESSAGE, + isMeta: true, + }), + ], + toolUseContext, + autoCompactTracking: tracking, + maxOutputTokensRecoveryCount: 0, + hasAttemptedReactiveCompact, + maxOutputTokensOverride: undefined, + pendingToolUseSummary: undefined, + stopHookActive: undefined, + turnCount, + transition: { reason: 'missing_tool_use_recovery' }, + } + state = next + continue + } + const stopHookResult = yield* handleStopHooks( messagesForQuery, assistantMessages, diff --git a/src/query/transitions.ts b/src/query/transitions.ts index ba2fa8b40..56a7f327c 100644 --- a/src/query/transitions.ts +++ b/src/query/transitions.ts @@ -17,4 +17,5 @@ export type Continue = | { reason: 'max_output_tokens_recovery'; attempt: number } | { reason: 'stop_hook_blocking' } | { reason: 'token_budget_continuation' } + | { reason: 'missing_tool_use_recovery' } | { reason: 'next_turn' } diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index dbf529bf6..454a2806f 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -27,12 +27,15 @@ import { AUTO_REJECT_MESSAGE, DONT_ASK_REJECT_MESSAGE, SYNTHETIC_MODEL, + normalizeContentFromAPI, } from '../messages' import type { Message, AssistantMessage, UserMessage, } from '../../types/message' +import { TaskGetTool } from '@claude-code-best/builtin-tools/tools/TaskGetTool/TaskGetTool.js' +import { TaskUpdateTool } from '@claude-code-best/builtin-tools/tools/TaskUpdateTool/TaskUpdateTool.js' // ─── Helpers ───────────────────────────────────────────────────────────── @@ -203,6 +206,165 @@ describe('createToolResultStopMessage', () => { }) }) +describe('normalizeContentFromAPI task tool input repair', () => { + test('repairs unquoted TaskUpdate status and preserves taskId', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskUpdate', + input: '{"status": in_progresss", "taskId": "1"}', + } as any, + ], + [TaskUpdateTool] as any, + ) + + expect((content[0] as any).input).toEqual({ + status: 'in_progress', + taskId: '1', + }) + }) + + test('repairs misspelled completed TaskUpdate status', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskUpdate', + input: '{"status": completedd", "taskId": "2"}', + } as any, + ], + [TaskUpdateTool] as any, + ) + + expect((content[0] as any).input).toEqual({ + status: 'completed', + taskId: '2', + }) + }) + + test('repairs common TaskUpdate aliases and loose scalar values', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskUpdate', + input: + "{'status': done, 'task_id': 2, 'addBlockedBy': [1, '3'], 'active_form': 'Running checks'}", + } as any, + ], + [TaskUpdateTool] as any, + ) + + expect((content[0] as any).input).toEqual({ + status: 'completed', + taskId: '2', + addBlockedBy: ['1', '3'], + activeForm: 'Running checks', + }) + }) + + test('repairs unterminated TaskUpdate string values', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskUpdate', + input: '{"taskId": "4", "subject": "Run final verification', + } as any, + ], + [TaskUpdateTool] as any, + ) + + expect((content[0] as any).input).toEqual({ + taskId: '4', + subject: 'Run final verification', + }) + }) + + test('does not repair malformed non-task tool input', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskGet', + input: '{"taskId": "1"}', + } as any, + { + type: 'tool_use', + id: 'toolu_2', + name: 'Bash', + input: '{"command": ls"}', + } as any, + ], + [TaskGetTool] as any, + ) + + expect((content[0] as any).input).toEqual({ taskId: '1' }) + expect((content[1] as any).input).toEqual({}) + }) + + test('does not repair task inputs missing required fields', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskCreate', + input: '{"subject": "missing description"', + } as any, + { + type: 'tool_use', + id: 'toolu_2', + name: 'TaskUpdate', + input: '{"status": completedd"}', + } as any, + ], + [] as any, + ) + + expect((content[0] as any).input).toEqual({}) + expect((content[1] as any).input).toEqual({}) + }) + + test('does not repair TaskUpdate into a no-op when status is invalid', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskUpdate', + input: '{"status": unknown_state", "taskId": "2"}', + } as any, + ], + [TaskUpdateTool] as any, + ) + + expect((content[0] as any).input).toEqual({}) + }) + + test('does not repair TaskUpdate with only taskId', () => { + const content = normalizeContentFromAPI( + [ + { + type: 'tool_use', + id: 'toolu_1', + name: 'TaskUpdate', + input: '{"taskId": "2"', + } as any, + ], + [TaskUpdateTool] as any, + ) + + expect((content[0] as any).input).toEqual({}) + }) +}) + // ─── isSyntheticMessage ───────────────────────────────────────────────── describe('isSyntheticMessage', () => { @@ -289,6 +451,66 @@ describe('hasToolCallsInLastAssistantTurn', () => { }) }) +describe('normalizeMessagesForAPI parallel task results', () => { + test('merges consecutive tool_result messages after one assistant turn', () => { + const assistant = createAssistantMessage({ + content: [ + { + type: 'tool_use', + id: 'task-create-1', + name: 'TaskCreate', + input: { + subject: 'one', + description: 'first', + }, + }, + { + type: 'tool_use', + id: 'task-create-2', + name: 'TaskCreate', + input: { + subject: 'two', + description: 'second', + }, + }, + ] as any, + }) + const result1 = createUserMessage({ + content: [ + { + type: 'tool_result', + tool_use_id: 'task-create-1', + content: 'Task #1 created', + }, + ] as any, + toolUseResult: { task: { id: '1', subject: 'one' } }, + sourceToolAssistantUUID: assistant.uuid as any, + }) + const result2 = createUserMessage({ + content: [ + { + type: 'tool_result', + tool_use_id: 'task-create-2', + content: 'Task #2 created', + }, + ] as any, + toolUseResult: { task: { id: '2', subject: 'two' } }, + sourceToolAssistantUUID: assistant.uuid as any, + }) + + const normalized = normalizeMessagesForAPI([assistant, result1, result2]) + + expect(normalized).toHaveLength(2) + expect(normalized[0]!.type).toBe('assistant') + expect(normalized[1]!.type).toBe('user') + const content = normalized[1]!.message.content as any[] + expect(content.map(block => block.tool_use_id)).toEqual([ + 'task-create-1', + 'task-create-2', + ]) + }) +}) + // ─── extractTag ───────────────────────────────────────────────────────── describe('extractTag', () => { diff --git a/src/utils/messages.ts b/src/utils/messages.ts index eeeb9d1bc..dc583e06c 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -142,6 +142,8 @@ import { } from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js' import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js' import { TASK_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/constants.js' +import { TASK_GET_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskGetTool/constants.js' +import { TASK_LIST_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskListTool/constants.js' import { TASK_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskOutputTool/constants.js' import { TASK_UPDATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskUpdateTool/constants.js' import type { PermissionMode } from '../types/permissions.js' @@ -178,6 +180,154 @@ const MEMORY_CORRECTION_HINT = "\n\nNote: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions." const TOOL_REFERENCE_TURN_BOUNDARY = 'Tool loaded.' +const TASK_TOOL_NAMES = new Set([ + TASK_CREATE_TOOL_NAME, + TASK_GET_TOOL_NAME, + TASK_LIST_TOOL_NAME, + TASK_UPDATE_TOOL_NAME, +]) +const TASK_STATUS_VALUES = ['pending', 'in_progress', 'completed', 'deleted'] + +function normalizeLooseTaskStatus(value: string): string { + const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_') + if (key === 'open' || key === 'todo') return 'pending' + if ( + key === 'started' || + key === 'active' || + key === 'inprogress' || + key.startsWith('in_progress') + ) { + return 'in_progress' + } + if ( + key === 'complete' || + key === 'done' || + key === 'resolved' || + key === 'closed' || + key.startsWith('completed') || + key.startsWith('complete') + ) { + return 'completed' + } + if (key === 'delete' || key === 'removed' || key.startsWith('deleted')) { + return 'deleted' + } + return value +} + +function extractLooseTaskStringField(raw: string, key: string): string | null { + const quoted = new RegExp(`["']${key}["']\\s*:\\s*["']([^"']*)`).exec(raw) + if (quoted) return quoted[1] ?? '' + + const bare = new RegExp(`["']${key}["']\\s*:\\s*([^,}\\n\\r]+)`).exec(raw) + if (!bare) return null + + return (bare[1] ?? '') + .trim() + .replace(/^["']/, '') + .replace(/["']$/, '') +} + +function extractLooseTaskStringArray(raw: string, key: string): string[] | null { + const match = new RegExp(`["']${key}["']\\s*:\\s*\\[([^\\]]*)\\]`).exec(raw) + if (!match) return null + + return (match[1] ?? '') + .split(',') + .map(item => + item + .trim() + .replace(/^["']/, '') + .replace(/["']$/, ''), + ) + .filter(Boolean) +} + +function repairTaskToolInput(toolName: string, rawInput: string): unknown { + if (!TASK_TOOL_NAMES.has(toolName)) return null + + const repaired: Record = {} + + if (toolName === TASK_LIST_TOOL_NAME) { + return rawInput.trim() === '' || rawInput.trim() === '{' ? {} : null + } + + for (const key of ['taskId', 'task_id', 'id']) { + const value = extractLooseTaskStringField(rawInput, key) + if (value !== null) { + repaired.taskId = value + break + } + } + + if (toolName === TASK_UPDATE_TOOL_NAME) { + const status = extractLooseTaskStringField(rawInput, 'status') + if (status !== null) { + const normalizedStatus = normalizeLooseTaskStatus(status) + if (TASK_STATUS_VALUES.includes(normalizedStatus)) { + repaired.status = normalizedStatus + } else { + return null + } + } + + for (const key of ['subject', 'description', 'activeForm', 'owner']) { + const value = extractLooseTaskStringField(rawInput, key) + if (value !== null) repaired[key] = value + } + + const activeForm = extractLooseTaskStringField(rawInput, 'active_form') + if (activeForm !== null && repaired.activeForm === undefined) { + repaired.activeForm = activeForm + } + + for (const [sourceKey, targetKey] of [ + ['addBlocks', 'addBlocks'], + ['add_blocks', 'addBlocks'], + ['blocks', 'addBlocks'], + ['addBlockedBy', 'addBlockedBy'], + ['add_blocked_by', 'addBlockedBy'], + ['blockedBy', 'addBlockedBy'], + ['blocked_by', 'addBlockedBy'], + ] as const) { + const value = extractLooseTaskStringArray(rawInput, sourceKey) + if (value !== null && repaired[targetKey] === undefined) { + repaired[targetKey] = value + } + } + } else if (toolName === TASK_CREATE_TOOL_NAME) { + for (const key of ['subject', 'description', 'activeForm']) { + const value = extractLooseTaskStringField(rawInput, key) + if (value !== null) repaired[key] = value + } + const activeForm = extractLooseTaskStringField(rawInput, 'active_form') + if (activeForm !== null && repaired.activeForm === undefined) { + repaired.activeForm = activeForm + } + } + + if ( + (toolName === TASK_GET_TOOL_NAME || toolName === TASK_UPDATE_TOOL_NAME) && + typeof repaired.taskId !== 'string' + ) { + return null + } + if ( + toolName === TASK_UPDATE_TOOL_NAME && + !Object.keys(repaired).some(key => key !== 'taskId') + ) { + return null + } + if ( + toolName === TASK_CREATE_TOOL_NAME && + (typeof repaired.subject !== 'string' || + typeof repaired.description !== 'string') + ) { + return null + } + + return Object.keys(repaired).length > 0 ? repaired : null +} /** * Appends a memory correction hint to a rejection/cancellation message @@ -3000,7 +3150,10 @@ export function normalizeContentFromAPI( ) } } - normalizedInput = parsed ?? {} + normalizedInput = + parsed ?? + repairTaskToolInput(contentBlock.name, contentBlock.input) ?? + {} } else { normalizedInput = contentBlock.input }