Skip to content
Draft
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
@@ -0,0 +1,150 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 assert from 'node:assert/strict';
import { test } from 'node:test';
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test';
import { ModelAdapter } from '../model-adapter.js';

async function activityCount(parts: LanguageModelV4StreamPart[]): Promise<number> {
const model = new MockLanguageModelV4({
doStream: {
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
...parts,
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
},
},
]),
},
});
const adapter = new ModelAdapter({
connection: { providerType: 'openai' } as never,
apiKey: 'test',
modelId: 'mock',
modelFactory: () => model,
newId: () => 'id',
now: () => 0,
});
let count = 0;
const result = await adapter.startStream({
model,
messages: [{ role: 'user', content: 'run pwd' }],
tools: {},
activeTools: [],
abortSignal: new AbortController().signal,
repairToolCall: async () => null,
onStreamActivity: () => {
count += 1;
},
});
for await (const _event of result.events) {
/* drain */
}
assert.equal((await result.outcome).kind, 'completed');
// SDK start/start-step and finish-step/finish remain activity.
return count - 4;
}

for (const providerExecuted of [false, true]) {
test(`ignores insignificant whitespace after a value (providerExecuted=${providerExecuted})`, async () => {
const parts: LanguageModelV4StreamPart[] = [
{ type: 'tool-input-start', id: 'a', toolName: 'Bash', providerExecuted },
{ type: 'tool-input-delta', id: 'a', delta: '{"boundary_intent":"current"' },
...Array.from({ length: 30 }, () => ({
type: 'tool-input-delta' as const,
id: 'a',
delta: ' \n\r\t',
})),
{ type: 'tool-input-end', id: 'a' },
];
assert.equal(await activityCount(parts), 3);
});
}

test('preserves whitespace inside strings across escaped quotes and backslashes', async () => {
const deltas = ['{"content":"line1', '\n\n ', '\\', '"', ' ', '\\', '\\', '"', ' \t', '}'];
const parts: LanguageModelV4StreamPart[] = [
{ type: 'tool-input-start', id: 'a', toolName: 'Write' },
...deltas.map((delta) => ({ type: 'tool-input-delta' as const, id: 'a', delta })),
{ type: 'tool-input-end', id: 'a' },
];
// The escaped quote stays inside; the quote after an escaped backslash closes.
assert.equal(await activityCount(parts), parts.length - 1);
});

test('keeps interleaved call state independent and resets ended IDs', async () => {
const parts: LanguageModelV4StreamPart[] = [
{ type: 'tool-input-start', id: 'a', toolName: 'Write' },
{ type: 'tool-input-delta', id: 'a', delta: '{"content":"' },
{ type: 'tool-input-start', id: 'b', toolName: 'Bash' },
{ type: 'tool-input-delta', id: 'b', delta: '{"command":"pwd"' },
{ type: 'tool-input-delta', id: 'b', delta: ' ' },
{ type: 'tool-input-delta', id: 'a', delta: ' ' },
{ type: 'tool-input-end', id: 'a' },
{ type: 'tool-input-start', id: 'a', toolName: 'Write' },
{ type: 'tool-input-delta', id: 'a', delta: ' ' },
{ type: 'tool-input-delta', id: 'b', delta: ' }' },
{ type: 'tool-input-end', id: 'a' },
{ type: 'tool-input-end', id: 'b' },
];
assert.equal(await activityCount(parts), parts.length - 2);
});

test('only JSON whitespace is insignificant outside strings', async () => {
const parts: LanguageModelV4StreamPart[] = [
{ type: 'tool-input-start', id: 'a', toolName: 'Bash' },
{ type: 'tool-input-delta', id: 'a', delta: '\u00a0' },
{ type: 'tool-input-delta', id: 'a', delta: '\v' },
{ type: 'tool-input-delta', id: 'a', delta: ' \t{}' },
{ type: 'tool-input-end', id: 'a' },
];
assert.equal(await activityCount(parts), parts.length);
});

test('counts every whitespace delta while a string remains open', async () => {
const parts: LanguageModelV4StreamPart[] = [
{ type: 'tool-input-start', id: 'a', toolName: 'Write' },
{ type: 'tool-input-delta', id: 'a', delta: '{"content":"line1' },
{ type: 'tool-input-delta', id: 'a', delta: ' ' },
{ type: 'tool-input-delta', id: 'a', delta: '\\' },
{ type: 'tool-input-delta', id: 'a', delta: '"' },
{ type: 'tool-input-delta', id: 'a', delta: '\n\t ' },
{ type: 'tool-input-end', id: 'a' },
];
assert.equal(await activityCount(parts), parts.length);
});

test('preserves whitespace activity for text and reasoning streams', async () => {
const parts: LanguageModelV4StreamPart[] = [
{ type: 'text-start', id: 'text' },
{ type: 'text-delta', id: 'text', delta: ' \n' },
{ type: 'text-end', id: 'text' },
{ type: 'reasoning-start', id: 'thought' },
{ type: 'reasoning-delta', id: 'thought', delta: ' \n' },
{ type: 'reasoning-end', id: 'thought' },
];
assert.equal(await activityCount(parts), parts.length);
});
29 changes: 27 additions & 2 deletions packages/runtime/src/model-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export interface ModelAdapterStreamInput {
messages: ModelMessage[];
tools: ModelToolSet;
activeTools: string[];
/** Observe each successfully pulled SDK stream part before semantic translation. */
/** Observe SDK activity, excluding insignificant JSON tool-argument whitespace. */
onStreamActivity: () => void;
system?: string;
abortSignal: AbortSignal;
Expand Down Expand Up @@ -385,9 +385,34 @@ export class ModelAdapter {
let streamedFinishReason: string | undefined;
let streamedRawFinishReason: string | undefined;
let sawUnfinalizedPlaintextSummary = false;
const toolInputStates = new Map<unknown, { inString: boolean; escapeNext: boolean }>();
try {
for await (const chunk of sdk.stream as AsyncIterable<AiSdkStreamChunk>) {
onStreamActivity();
let hasActivity = true;
if (chunk.type === 'tool-input-start') {
toolInputStates.set(chunk.id, { inString: false, escapeNext: false });
} else if (chunk.type === 'tool-input-end') {
toolInputStates.delete(chunk.id);
} else if (chunk.type === 'tool-input-delta') {
const state = toolInputStates.get(chunk.id);
if (state) {
hasActivity = false;
// JSON permits only these four whitespace characters outside
// strings. Preserve string/escape state across delta boundaries.
for (const char of chunk.delta ?? '') {
if (state.inString) {
hasActivity = true;
if (state.escapeNext) state.escapeNext = false;
else if (char === '\\') state.escapeNext = true;
else if (char === '"') state.inString = false;
} else if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') {
hasActivity = true;
if (char === '"') state.inString = true;
}
}
}
}
if (hasActivity) onStreamActivity();
if (
chunk.type === 'finish' ||
chunk.type === 'finish-step' ||
Expand Down