Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -368,6 +369,9 @@ export class AgentExecutor extends AICommandExecutor<GenerateAgentCodeRequest> {
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 }) };
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void>((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);
}
);
}
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
Loading