From cc8310bc851318cec04990eeb780939825b79a2b Mon Sep 17 00:00:00 2001 From: xiaozh Date: Sun, 6 Sep 2026 10:33:31 +0800 Subject: [PATCH] fix(runtime): stop counting insignificant tool-argument whitespace as stream activity Track JSON string and escape state independently for each streaming tool call. Keep whitespace inside string literals active across split and escaped deltas. Let the existing idle watchdog cancel whitespace stalls and apply its recovery. Cover interleaved calls and preserve text and reasoning stream behavior. Refs #4861 Generated-by: pi (gpt-6-astra) --- .../model-adapter-tool-input-activity.test.ts | 150 ++++++++++++++++++ packages/runtime/src/model-adapter.ts | 29 +++- 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 packages/runtime/src/__tests__/model-adapter-tool-input-activity.test.ts diff --git a/packages/runtime/src/__tests__/model-adapter-tool-input-activity.test.ts b/packages/runtime/src/__tests__/model-adapter-tool-input-activity.test.ts new file mode 100644 index 0000000000..e376a53123 --- /dev/null +++ b/packages/runtime/src/__tests__/model-adapter-tool-input-activity.test.ts @@ -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 { + 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); +}); diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 83a62a01b0..ad1621cac4 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -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; @@ -385,9 +385,34 @@ export class ModelAdapter { let streamedFinishReason: string | undefined; let streamedRawFinishReason: string | undefined; let sawUnfinalizedPlaintextSummary = false; + const toolInputStates = new Map(); try { for await (const chunk of sdk.stream as AsyncIterable) { - 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' ||