diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/agent/AgentExecutor.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/agent/AgentExecutor.ts index c9a4a0612bf..6f71d42aa66 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/agent/AgentExecutor.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/agent/AgentExecutor.ts @@ -47,6 +47,7 @@ import { stripAnalysisFromCompactionBlocks, COMPACTION_BLOCK_PREFIX, } from '@wso2/copilot-utilities/context-management'; +import { sanitizeMessages } from './resilience'; import { getLoginMethod } from '../../../utils/ai/auth'; import { sendTelemetryEvent, @@ -368,6 +369,9 @@ export class AgentExecutor extends AICommandExecutor { if (cleanedCompactionSummary) { stripAnalysisFromCompactionBlocks(stepMessages); } + // Must run before addCacheControlToMessages: cache control switches Anthropic + // to a strict path that 400s on string `tool_use.input`. + sanitizeMessages(stepMessages); return { messages: addCacheControlToMessages({ messages: stepMessages, model }) }; }, diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/agent/resilience/index.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/agent/resilience/index.ts new file mode 100644 index 00000000000..4b7b92e1424 --- /dev/null +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/agent/resilience/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { sanitizeMessages, repairToolCallInputs } from "./messageSanitization"; diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/agent/resilience/messageSanitization.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/agent/resilience/messageSanitization.ts new file mode 100644 index 00000000000..46962d03b66 --- /dev/null +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/agent/resilience/messageSanitization.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Repair passes that keep a model-message history provider-valid before it is sent to Anthropic. + * `sanitizeMessages` is the single entry point; individual repairs compose under it, so new failure + * modes can be handled by adding a pass rather than touching call sites. + */ + +function isMalformedToolCallPart(part: any): boolean { + return part?.type === 'tool-call' && typeof part.input === 'string'; +} + +function coerceInput(raw: string): Record { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch { + // unparseable + } + return {}; +} + +/** + * Coerce every malformed (string) tool-call input to an object, in place. Returns the number + * repaired; clean histories are a no-op. + * + * When a streamed tool call's JSON is invalid — most often truncated at the output-token cap on a + * large file write — the AI SDK cannot parse it and keeps the raw text as a string on the + * `tool-call` part. Anthropic requires `tool_use.input` to be an object and rejects replay with + * `tool_use.input: Input should be an object`, which bricks the thread on every later request. + */ +export function repairToolCallInputs(messages: any[]): number { + let repaired = 0; + for (const message of messages ?? []) { + if (!Array.isArray(message?.content)) { + continue; + } + for (const part of message.content) { + if (isMalformedToolCallPart(part)) { + part.input = coerceInput(part.input); + repaired++; + } + } + } + if (repaired > 0) { + console.warn(`[messageSanitization] Coerced ${repaired} malformed tool-call input(s) to objects.`); + } + return repaired; +} + +/** + * Run every repair pass over a message history, in place, so it stays provider-valid. Call before + * sending history to the provider (prepareStep, history load). Add new passes here as needed. + */ +export function sanitizeMessages(messages: any[]): void { + repairToolCallInputs(messages); +} diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/utils/ai-utils.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/utils/ai-utils.ts index e519fa22971..00d3f9e926c 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/utils/ai-utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/utils/ai-utils.ts @@ -45,6 +45,7 @@ import { AiPanelWebview } from "../../../views/ai-panel/webview"; import { MigrationPanelWebview } from "../../../views/migration-panel/webview"; import { VisualizerWebview } from "../../../views/visualizer/webview"; import { GenerationType } from "./libs/libraries"; +import { sanitizeMessages } from "../agent/resilience"; // import { REQUIREMENTS_DOCUMENT_KEY } from "./code/np_prompts"; export function populateHistory(chatHistory: ChatEntry[]): ModelMessage[] { @@ -78,6 +79,8 @@ export function populateHistoryForAgent(chatHistory: any[]): ModelMessage[] { }); } } + // Keep replayed history provider-valid (e.g. coerce malformed tool-call inputs). + sanitizeMessages(messages); return messages; } diff --git a/workspaces/ballerina/ballerina-extension/test/ai/unit_tests/resilience/index.ts b/workspaces/ballerina/ballerina-extension/test/ai/unit_tests/resilience/index.ts new file mode 100644 index 00000000000..723a4b62d0c --- /dev/null +++ b/workspaces/ballerina/ballerina-extension/test/ai/unit_tests/resilience/index.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as path from "path"; +import Mocha from "mocha"; +import { glob } from "glob"; + +export async function run(): Promise { + const mocha = new Mocha({ + ui: "tdd", + color: true, + timeout: 10000, + }); + + const testsRoot = path.resolve(__dirname); + const files = await glob("**/*.test.js", { cwd: testsRoot }); + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); + + return new Promise((resolve, reject) => { + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} tests failed.`)); + } else { + resolve(); + } + }); + }); +} + +// Allow running directly with node +if (require.main === module) { + run().then( + () => process.exit(0), + (err) => { + console.error(err); + process.exit(1); + } + ); +} diff --git a/workspaces/ballerina/ballerina-extension/test/ai/unit_tests/resilience/message-sanitization.test.ts b/workspaces/ballerina/ballerina-extension/test/ai/unit_tests/resilience/message-sanitization.test.ts new file mode 100644 index 00000000000..47a734750d5 --- /dev/null +++ b/workspaces/ballerina/ballerina-extension/test/ai/unit_tests/resilience/message-sanitization.test.ts @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as assert from "assert"; +import { + repairToolCallInputs, + sanitizeMessages, +} from "../../../../src/features/ai/agent/resilience/messageSanitization"; + +// The real bug shape: `"new_string">` (should be `":`) makes the input unparseable, so the SDK +// keeps it as a string on the tool-call part. +const MALFORMED_INPUT = + '{"file_path": "types.bal", "edits": [{"old_string": "a", "new_string">"b"}]}'; + +const assistantWithMalformedCall = () => ({ + role: "assistant", + content: [ + { type: "text", text: "Editing the file." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "file_batch_edit", + input: MALFORMED_INPUT, // string — the bug + }, + ], +}); + +suite("resilience/messageSanitization", () => { + suite("repairToolCallInputs", () => { + test("coerces an unparseable string input to an empty object", () => { + const messages = [assistantWithMalformedCall()]; + const repaired = repairToolCallInputs(messages); + const call = (messages[0].content as any[])[1]; + assert.strictEqual(repaired, 1); + assert.strictEqual(typeof call.input, "object"); + assert.deepStrictEqual(call.input, {}); + }); + + test("parses a well-formed JSON string input into the object it represents", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "c", + toolName: "t", + input: '{"file_path":"main.bal","edits":[]}', + }, + ], + }, + ]; + const repaired = repairToolCallInputs(messages); + assert.strictEqual(repaired, 1); + assert.deepStrictEqual((messages[0].content as any[])[0].input, { + file_path: "main.bal", + edits: [], + }); + }); + + test("coerces a string truncated mid-JSON to {} (output-token cap on large writes)", () => { + // The common production trigger: a large file write whose tool-call JSON is cut off + // at the 8192 output-token limit, leaving unparseable text. + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "c", + toolName: "file_batch_edit", + input: '{"file_path":"main.bal","content":"import ballerina/ht', + }, + ], + }, + ]; + assert.strictEqual(repairToolCallInputs(messages), 1); + assert.deepStrictEqual((messages[0].content as any[])[0].input, {}); + }); + + test("coerces an empty string input to {}", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "c", toolName: "t", input: "" }], + }, + ]; + assert.strictEqual(repairToolCallInputs(messages), 1); + assert.deepStrictEqual((messages[0].content as any[])[0].input, {}); + }); + + test("coerces a JSON array string to {} (provider requires an object, not an array)", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "c", toolName: "t", input: "[1,2,3]" }], + }, + ]; + repairToolCallInputs(messages); + assert.deepStrictEqual((messages[0].content as any[])[0].input, {}); + }); + + test("leaves a valid object input untouched and is a no-op (returns 0)", () => { + const original = { file_path: "main.bal", edits: [{ old_string: "a", new_string: "b" }] }; + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "c", toolName: "t", input: original }], + }, + ]; + const repaired = repairToolCallInputs(messages); + assert.strictEqual(repaired, 0); + assert.strictEqual((messages[0].content as any[])[0].input, original); + }); + + test("repairs multiple malformed calls across messages and counts them", () => { + const messages = [ + assistantWithMalformedCall(), + { role: "user", content: "continue" }, + assistantWithMalformedCall(), + ]; + assert.strictEqual(repairToolCallInputs(messages), 2); + }); + + test("ignores messages with non-array content and tolerates empty input", () => { + assert.strictEqual(repairToolCallInputs([]), 0); + assert.strictEqual(repairToolCallInputs(undefined as any), 0); + assert.strictEqual(repairToolCallInputs([{ role: "user", content: "hi" }]), 0); + }); + }); + + suite("sanitizeMessages", () => { + test("runs the repair passes over the history in place", () => { + const messages = [assistantWithMalformedCall()]; + sanitizeMessages(messages); + assert.deepStrictEqual((messages[0].content as any[])[1].input, {}); + }); + + test("leaves a clean history untouched", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "c", toolName: "t", input: { ok: 1 } }], + }, + ]; + sanitizeMessages(messages); + assert.deepStrictEqual((messages[0].content as any[])[0].input, { ok: 1 }); + }); + }); +});