diff --git a/CHANGELOG.md b/CHANGELOG.md index 5477a9d70b2..0c937c8d44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Plugins: reuse gateway-bindable plugin loader cache entries for later default-mode loads without serving default-built registries to gateway-bound requests, reducing repeated plugin registration during dispatch. Refs #61756. Thanks @DmitryPogodaev. - Gateway/secrets: include the caught error message in `secrets.reload` and `secrets.resolve` warning logs while keeping RPC errors generic, so operators can diagnose reload and permission failures. Thanks @davidangularme. +- Anthropic-compatible streams: recover text deltas that arrive before their matching content block, so Kimi Code and similar providers do not finish as empty `incomplete_result` replies. Fixes #76007. Thanks @vliuyt. - fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987. - MCP/OpenAI: normalize parameter-free tool schemas whose top-level object `properties` is missing, null, or invalid before sending tools to OpenAI, so MCP tools without params stay usable. Fixes #75362. Thanks @tolkonepiu and @SymbolStar. - TTS: honor explicit short `[[tts:text]]...[[/tts:text]]` blocks while keeping untagged short auto-TTS suppressed, so tagged voice replies are synthesized instead of being dropped as empty voice-only payloads. Fixes #73758. Thanks @yfge. diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index d309c629621..4e3c533ae57 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -533,6 +533,65 @@ describe("anthropic transport stream", () => { expect(result.usage.output).toBe(9); }); + it("recovers orphan text deltas when an Anthropic-compatible provider omits block start", async () => { + guardedFetchMock.mockResolvedValueOnce( + createSseResponse([ + { + type: "message_start", + message: { id: "msg_1", usage: { input_tokens: 6, output_tokens: 0 } }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "你好" }, + }, + { + type: "content_block_stop", + index: 0, + }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { input_tokens: 6, output_tokens: 1 }, + }, + ]), + ); + const streamFn = createAnthropicMessagesTransportStreamFn(); + const stream = await Promise.resolve( + streamFn( + makeAnthropicTransportModel({ + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding/", + }), + { + messages: [{ role: "user", content: "hello" }], + } as Parameters[1], + { + apiKey: "kimi-key", + } as Parameters[2], + ), + ); + const events: Array<{ type?: string; delta?: string; content?: string }> = []; + for await (const event of stream as AsyncIterable<{ + type?: string; + delta?: string; + content?: string; + }>) { + events.push(event); + } + const result = await stream.result(); + + expect(result.content).toEqual([{ type: "text", text: "你好" }]); + expect(result.stopReason).toBe("stop"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text_start" }), + expect.objectContaining({ type: "text_delta", delta: "你好" }), + expect.objectContaining({ type: "text_end", content: "你好" }), + ]), + ); + }); + it("skips malformed tools when building Anthropic payloads", async () => { await runTransportStream( makeAnthropicTransportModel(), diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 7c0675386cb..5df9491d6af 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -1018,9 +1018,20 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { continue; } if (event.type === "content_block_delta") { - const index = blocks.findIndex((block) => block.index === event.index); - const block = blocks[index]; const delta = event.delta as Record | undefined; + let index = blocks.findIndex((block) => block.index === event.index); + let block = blocks[index]; + if (!block && delta?.type === "text_delta" && typeof delta.text === "string") { + const recoveredIndex = typeof event.index === "number" ? event.index : blocks.length; + block = { type: "text", text: "", index: recoveredIndex }; + output.content.push(block); + index = output.content.length - 1; + stream.push({ + type: "text_start", + contentIndex: index, + partial: output as never, + }); + } if ( block?.type === "text" && delta?.type === "text_delta" &&