diff --git a/package.json b/package.json index 2931f4b..32d30ae 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "bun test", "test:unit": "bun test tests/unit", "test:integration": "bun test tests/integration", - "test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/competitive/edge.test.ts", + "test:ci:unit": "bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/tools/sdk-executor.test.ts tests/tools/mcp-executor.test.ts tests/tools/skills.test.ts tests/tools/registry.test.ts tests/unit/cli/model-discovery.test.ts tests/unit/proxy/prompt-builder.test.ts tests/unit/proxy/tool-loop.test.ts tests/unit/provider-boundary.test.ts tests/unit/provider-runtime-interception.test.ts tests/unit/provider-tool-schema-compat.test.ts tests/unit/provider-tool-loop-guard.test.ts tests/unit/plugin.test.ts tests/unit/plugin-tools-hook.test.ts tests/unit/plugin-tool-resolution.test.ts tests/unit/plugin-config.test.ts tests/unit/plugin-stream-extraction.test.ts tests/unit/auth.test.ts tests/unit/streaming/line-buffer.test.ts tests/unit/streaming/parser.test.ts tests/unit/streaming/types.test.ts tests/unit/streaming/delta-tracker.test.ts tests/unit/streaming/openai-sse.test.ts tests/unit/streaming/ai-sdk-parts.test.ts tests/competitive/edge.test.ts", "test:ci:integration": "bun test tests/integration/comprehensive.test.ts tests/integration/tools-router.integration.test.ts tests/integration/stream-router.integration.test.ts tests/integration/opencode-loop.integration.test.ts", "discover": "bun run src/cli/discover.ts", "prepublishOnly": "bun run build" diff --git a/src/plugin.ts b/src/plugin.ts index ad3b2b2..2b5d5e3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -362,11 +362,12 @@ function createChatCompletionChunk(id: string, created: number, model: string, d }; } -function extractCompletionFromStream(output: string): { assistantText: string; reasoningText: string } { +export function extractCompletionFromStream(output: string): { assistantText: string; reasoningText: string } { const lines = output.split("\n"); let assistantText = ""; let reasoningText = ""; let sawAssistantPartials = false; + let sawThinkingPartials = false; for (const line of lines) { const event = parseStreamJsonLine(line); @@ -390,7 +391,13 @@ function extractCompletionFromStream(output: string): { assistantText: string; r if (isThinking(event)) { const thinking = extractThinking(event); if (thinking) { - reasoningText += thinking; + const isPartial = typeof (event as any).timestamp_ms === "number"; + if (isPartial) { + reasoningText += thinking; + sawThinkingPartials = true; + } else if (!sawThinkingPartials) { + reasoningText += thinking; + } } } } diff --git a/tests/unit/plugin-stream-extraction.test.ts b/tests/unit/plugin-stream-extraction.test.ts new file mode 100644 index 0000000..3b80c89 --- /dev/null +++ b/tests/unit/plugin-stream-extraction.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; + +import { extractCompletionFromStream } from "../../src/plugin"; + +describe("extractCompletionFromStream", () => { + it("does not duplicate assistant text when partial events are followed by final accumulated event", () => { + const output = [ + JSON.stringify({ + type: "assistant", + timestamp_ms: 1, + message: { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + }, + }), + JSON.stringify({ + type: "assistant", + timestamp_ms: 2, + message: { + role: "assistant", + content: [{ type: "text", text: " world" }], + }, + }), + JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello world" }], + }, + }), + JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello world" }], + }, + }), + ].join("\n"); + + expect(extractCompletionFromStream(output)).toEqual({ + assistantText: "Hello world", + reasoningText: "", + }); + }); + + it("does not duplicate thinking text when partial events are followed by final accumulated event", () => { + const output = [ + JSON.stringify({ + type: "thinking", + subtype: "delta", + timestamp_ms: 1, + text: "Plan", + }), + JSON.stringify({ + type: "thinking", + subtype: "delta", + timestamp_ms: 2, + text: " more", + }), + JSON.stringify({ + type: "thinking", + text: "Plan more", + }), + ].join("\n"); + + expect(extractCompletionFromStream(output)).toEqual({ + assistantText: "", + reasoningText: "Plan more", + }); + }); +});