mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-12 02:02:42 +00:00
fix(ai): coerce streamed tool-call fragments to strings
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user