Merge pull request #116 from IronRookieCoder/fix/tasks-tool-arguments-repair
Fix/tasks tool arguments repair
This commit is contained in:
commit
7178eac204
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
144
src/__tests__/queryMissingToolUseRecovery.test.ts
Normal file
144
src/__tests__/queryMissingToolUseRecovery.test.ts
Normal file
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
45
src/query.ts
45
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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user