fix(ai): coerce streamed tool-call fragments to strings

This commit is contained in:
John Smith
2026-09-24 02:23:59 -04:00
parent cfb7218aa1
commit 13e0a5b7fe
3 changed files with 74 additions and 12 deletions
+7 -1
View File
@@ -7,7 +7,7 @@
"asset-curator",
"qa-playtester"
],
"last_cycle": 203,
"last_cycle": 204,
"completed_targets": [
{
"cycle": 75,
@@ -1192,6 +1192,12 @@
"target": "Commit the mock provider as a reusable offline harness tool (Tier 4)",
"metric_delta": "The cycle-202 mock lived in /tmp and would be lost. Added scripts/dev/mock-ai-provider.js (dependency-free OpenAI-compatible provider: SSE + non-streamed, x-omniroute-* telemetry, permissive CORS AND Access-Control-Expose-Headers so the browser can read the headers), with a usage header and a scripts/README.md section under a new 'Development harness' heading. This lets the @feedback-loop harness run end-to-end without credentials. Verified: node --check, a live smoke (GET /v1/models + POST with Expose-Headers and telemetry present), and the divergence guard (forkOnly +1, scripts/README.md already in modified).",
"commit": "feat(scripts): add a reusable offline mock AI provider"
},
{
"cycle": 204,
"target": "Stop streamed tool-call assembly corrupting non-string arguments",
"metric_delta": "The SSE accumulator stored the first chunk's `arguments` as-is (`|| ''`) and later appended string fragments, so a chunk carrying the whole arguments object produced '[object Object]...' - unparseable JSON, losing the tool call. Now arguments are stringified when the first chunk is an object (or dropped when neither string nor object), and names/ids are coerced to strings before concatenation/assignment. +1 test (object-arguments chunk then finish -> assembled JSON parses to the object, no '[object Object]'), mutation-checked. 1640/1640 across 81 suites, eslint+prettier clean, production build exit 0, divergence clean.",
"commit": "fix(ai): coerce streamed tool-call fragments to strings"
}
],
"backlog": [
+23 -11
View File
@@ -3175,27 +3175,39 @@ const streamChatCompletion = async ({
for (const toolCall of delta.tool_calls) {
const index = resolveStreamToolCallIndex(toolCall);
const existing = toolCalls[index];
const fragmentName = toolCall.function && toolCall.function.name;
const fragmentArgs = toolCall.function && toolCall.function.arguments;
if (!existing) {
// Arguments are normally a string, but a proxy may send the whole
// object on one chunk: stringify it so a later string fragment
// cannot concatenate as '[object Object]'. Names and ids are
// coerced to strings for the same reason.
toolCalls[index] = {
id: toolCall.id,
id: typeof toolCall.id === 'string' ? toolCall.id : undefined,
type: toolCall.type || 'function',
function: {
name: (toolCall.function && toolCall.function.name) || '',
name: typeof fragmentName === 'string' ? fragmentName : '',
arguments:
(toolCall.function && toolCall.function.arguments) || '',
typeof fragmentArgs === 'string'
? fragmentArgs
: fragmentArgs && typeof fragmentArgs === 'object'
? JSON.stringify(fragmentArgs)
: '',
},
};
} else {
if (toolCall.id) existing.id = toolCall.id;
if (toolCall.function && toolCall.function.name) {
if (typeof toolCall.id === 'string') existing.id = toolCall.id;
if (typeof fragmentName === 'string') {
existing.function.name =
(existing.function.name || '') + toolCall.function.name;
(typeof existing.function.name === 'string'
? existing.function.name
: '') + fragmentName;
}
if (
toolCall.function &&
typeof toolCall.function.arguments === 'string'
) {
existing.function.arguments += toolCall.function.arguments;
if (typeof fragmentArgs === 'string') {
existing.function.arguments =
(typeof existing.function.arguments === 'string'
? existing.function.arguments
: '') + fragmentArgs;
}
}
}
+44
View File
@@ -2369,6 +2369,50 @@ describe('CustomAIClient', () => {
_resetForStreamTests();
});
it('does not corrupt arguments when a chunk sends them as an object', async () => {
// Streaming fragments are strings by spec, but a proxy may send a whole
// object on one chunk; the old `arguments || ''` kept the object and a
// later string fragment then concatenated as '[object Object]...'.
const sse =
'data: ' +
JSON.stringify({
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'c1',
function: {
name: 'create_scene',
arguments: { scene_name: 'Town' },
},
},
],
},
},
],
}) +
'\n' +
'data: ' +
JSON.stringify({
choices: [{ delta: {}, finish_reason: 'tool_calls' }],
}) +
'\n' +
'data: [DONE]\n';
global.fetch = jest.fn().mockResolvedValue(mockStreamResponse(sse));
const message = await sendChatCompletion({
messages: [{ role: 'user', content: 'hi' }],
config: streamConfig,
});
expect(message.tool_calls).toHaveLength(1);
const args = message.tool_calls[0].function.arguments;
expect(args).not.toContain('[object Object]');
expect(JSON.parse(args)).toEqual({ scene_name: 'Town' });
});
it('reports reasoning as streaming progress before any answer token', async () => {
// Live finding (Ollama, deepseek-v4.1-flash): a reasoning model streams
// many `delta.reasoning` chunks before the first `delta.content`, so the