Compare commits

...
Author SHA1 Message Date
bincxz afcf201367 test(ai): isolate mocked bridge window typing 2026-09-18 11:48:04 +08:00
bincxz 45b4dc6aa4 fix(ai): normalize sparse streamed tool call indexes 2026-09-18 11:46:16 +08:00
2 changed files with 93 additions and 4 deletions

No files matched your search

@@ -1234,3 +1234,74 @@ test('re-injects the remembered tool name when the SDK missed the naming chunk',
assert.deepEqual(executedCommands, ['pwd']);
assert.equal(text, 'tool completed');
});
for (const indexes of [[0], [1], [1, 4], [4, 1]]) {
test(`completes terminal_execute with streamed tool indexes ${indexes.join(',')} (#3274)`, async (t) => {
const globals = globalThis as unknown as { window?: unknown };
const originalWindow = globals.window;
t.after(() => { globals.window = originalWindow; });
const dataHandlers = new Map<string, (data: string) => void>();
const endHandlers = new Map<string, () => void>();
const sentBodies: Array<Record<string, unknown>> = [];
globals.window = {
netcatty: {
aiChatCancel: async () => true,
onAiStreamData: (id: string, cb: (data: string) => void) => {
dataHandlers.set(id, cb);
return () => dataHandlers.delete(id);
},
onAiStreamEnd: (id: string, cb: () => void) => {
endHandlers.set(id, cb);
return () => endHandlers.delete(id);
},
onAiStreamError: () => () => undefined,
aiChatStream: async (id: string, _url: string, _headers: unknown, body: string) => {
sentBodies.push(JSON.parse(body));
const emit = (delta: unknown, finishReason: string | null = null) => dataHandlers.get(id)?.(JSON.stringify({
id: 'chatcmpl-sparse', object: 'chat.completion.chunk', created: 0, model: 'test',
choices: [{ index: 0, delta, finish_reason: finishReason }],
}));
if (sentBodies.length === 1) {
emit({ content: 'Checking.' });
for (const index of indexes) {
emit({ tool_calls: [{ index, id: `call_${index}`, type: 'function',
function: { name: 'terminal_execute', arguments: '{"command":' } }] });
}
// Interleave continuations in reverse order to verify stable correlation.
for (const index of [...indexes].reverse()) {
emit({ tool_calls: [{ index, function: { arguments: `"echo ${index}"}` } }] });
}
emit({}, 'tool_calls');
} else {
emit({ content: 'Done.' });
emit({}, 'stop');
}
endHandlers.get(id)?.();
return { ok: true, statusCode: 200, statusText: 'OK' };
},
},
};
const executed: string[] = [];
const result = streamText({
model: createModelFromConfig({ id: 'sparse-test', providerId: 'custom', name: 'Test',
apiKey: 'test', baseURL: 'https://example.test/v1', defaultModel: 'test', enabled: true }),
messages: [{ role: 'user', content: 'Check the terminal' }],
tools: { terminal_execute: tool({ inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => { executed.push(command); return { ok: true }; } }) },
stopWhen: isStepCount(2),
});
const errors: unknown[] = [];
let text = '';
for await (const part of result.stream) {
if (part.type === 'error') errors.push(part.error);
if (part.type === 'text-delta') text += part.text;
}
assert.deepEqual(errors, []);
assert.deepEqual(executed.sort(), indexes.map(index => `echo ${index}`).sort());
assert.equal(text, 'Checking.Done.');
assert.equal(sentBodies.length, 2);
const messages = sentBodies[1].messages as Array<{ role: string; tool_call_id?: string }>;
assert.deepEqual(messages.filter(message => message.role === 'tool').map(message => message.tool_call_id).sort(),
indexes.map(index => `call_${index}`).sort());
});
}
+22 -4
View File
@@ -136,6 +136,7 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
// Keys forwarded inside choices array position 0 — the only element the
// AI SDK reads. Tool calls living at position > 0 are invisible to it.
const sdkVisibleKeys = new Set<string>();
const sdkIndexesByKey = new Map<string, number>();
const requestIdToken = requestId.replace(/[^a-zA-Z0-9_-]/g, '_');
return (data: string): string => {
@@ -165,6 +166,23 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
const choiceIndex = typeof choiceRecord.index === 'number' ? choiceRecord.index : choicePosition;
let deltaChanged = false;
const normalizedToolCalls: unknown[] = [];
const forwardToolCall = (toolCall: Record<string, unknown>, key: string) => {
// The SDK reads only choices[0] and stores calls in a dense array.
// Assign indexes only when forwarding a named call to that array.
if (choicePosition === 0) {
let sdkIndex = sdkIndexesByKey.get(key);
if (sdkIndex === undefined) {
sdkIndex = sdkIndexesByKey.size;
sdkIndexesByKey.set(key, sdkIndex);
}
if (toolCall.index !== sdkIndex) {
toolCall = { ...toolCall, index: sdkIndex };
changed = true;
deltaChanged = true;
}
}
normalizedToolCalls.push(toolCall);
};
for (const [toolCallPosition, toolCall] of deltaRecord.tool_calls.entries()) {
if (!toolCall || typeof toolCall !== 'object') {
normalizedToolCalls.push(toolCall);
@@ -209,11 +227,11 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
normalizedToolCall.function === toolCallRecord.function &&
(!rememberedName || hasFunctionName(toolCallRecord))
) {
normalizedToolCalls.push(toolCall);
forwardToolCall(toolCallRecord, key);
} else {
changed = true;
deltaChanged = true;
normalizedToolCalls.push(normalizedToolCall);
forwardToolCall(normalizedToolCall, key);
}
continue;
}
@@ -254,13 +272,13 @@ function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) =
normalizedToolCall.type === toolCallRecord.type &&
normalizedToolCall.function === toolCallRecord.function
) {
normalizedToolCalls.push(toolCall);
forwardToolCall(toolCallRecord, key);
continue;
}
changed = true;
deltaChanged = true;
normalizedToolCalls.push(normalizedToolCall);
forwardToolCall(normalizedToolCall, key);
}
if (!deltaChanged) return choice;