From 5cf8b7549d70db289c2daa02e00daefeb74d6d1c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 13:35:42 +0700 Subject: [PATCH] fix(opencode): prevent reasoning content from duplicating as assistant text OpenCode's message.part.delta events use field="text" for all parts including reasoning, because "text" is the property name being updated. Track part types from message.part.updated events and use them to correctly classify deltas for known reasoning parts. Also set PASEO_SUPERVISED=0 in vitest setup to prevent process.send() conflicts with vitest's fork pool. --- .../server/agent/providers/opencode-agent.ts | 46 ++++++++----- .../opencode-reasoning-dedup.real.e2e.test.ts | 68 +++++++++++++++++++ .../opencode/event-translator.test.ts | 65 ++++++++++++++++++ .../server/src/test-utils/vitest-setup.ts | 1 + 4 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c5214e8aa..f8a087f5b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -580,6 +580,8 @@ export type OpenCodeEventTranslationState = { accumulatedUsage: AgentUsage; streamedPartKeys: Set; emittedStructuredMessageIds: Set; + /** Tracks the type of each part by ID, learned from message.part.updated events. */ + partTypes: Map; }; function stringifyStructuredAssistantMessage(value: unknown): string | null { @@ -705,11 +707,16 @@ export function translateOpenCodeEvent( break; } + const partId = part.id as string | undefined; const messageId = part.messageID as string | undefined; const messageRole = messageId ? state.messageRoles.get(messageId) : undefined; const partType = part.type as string | undefined; const partTime = part.time as { start?: number; end?: number } | undefined; + if (partId && partType) { + state.partTypes.set(partId, partType); + } + if (partType === "text") { const partKey = resolvePartDedupeKey(part, "text"); if (messageRole === "user") { @@ -809,22 +816,11 @@ export function translateOpenCodeEvent( break; } - if (deltaField === "text") { - if (deltaMessageRole === "user") { - break; - } - const partId = props.partID as string | undefined; - const partKey = partId ? `text:${partId}` : null; - if (partKey) { - state.streamedPartKeys.add(partKey); - } - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "assistant_message", text: deltaText }, - }); - } else if (deltaField === "reasoning") { - const partId = props.partID as string | undefined; + const partId = props.partID as string | undefined; + const knownPartType = partId ? state.partTypes.get(partId) : undefined; + const isReasoning = knownPartType === "reasoning" || deltaField === "reasoning"; + + if (isReasoning) { const partKey = partId ? `reasoning:${partId}` : null; if (partKey) { state.streamedPartKeys.add(partKey); @@ -834,6 +830,19 @@ export function translateOpenCodeEvent( provider: "opencode", item: { type: "reasoning", text: deltaText }, }); + } else if (deltaField === "text") { + if (deltaMessageRole === "user") { + break; + } + const partKey = partId ? `text:${partId}` : null; + if (partKey) { + state.streamedPartKeys.add(partKey); + } + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: deltaText }, + }); } break; } @@ -925,6 +934,7 @@ export function translateOpenCodeEvent( const sessionId = props.sessionID as string | undefined; if (sessionId === state.sessionId) { state.streamedPartKeys.clear(); + state.partTypes.clear(); events.push({ type: "turn_completed", provider: "opencode", @@ -938,6 +948,7 @@ export function translateOpenCodeEvent( const sessionId = props.sessionID as string | undefined; if (sessionId === state.sessionId) { state.streamedPartKeys.clear(); + state.partTypes.clear(); const error = props.error as string | undefined; events.push({ type: "turn_failed", @@ -971,6 +982,8 @@ class OpenCodeAgentSession implements AgentSession { private streamedPartKeys = new Set(); /** Tracks assistant messages already emitted from structured payloads. */ private emittedStructuredMessageIds = new Set(); + /** Tracks the type of each part by ID, learned from message.part.updated events. */ + private partTypes = new Map(); private availableModesCache: AgentMode[] | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; @@ -1485,6 +1498,7 @@ class OpenCodeAgentSession implements AgentSession { accumulatedUsage: this.accumulatedUsage, streamedPartKeys: this.streamedPartKeys, emittedStructuredMessageIds: this.emittedStructuredMessageIds, + partTypes: this.partTypes, }); for (const translatedEvent of translated) { diff --git a/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts new file mode 100644 index 000000000..b99030046 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-reasoning-dedup.real.e2e.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; + +describe("OpenCode reasoning dedup", () => { + test.runIf(isProviderAvailable("opencode"))( + "reasoning content is not duplicated as assistant_message", + async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "opencode/gpt-5-nano", + modeId: "build", + }); + + const streamedEvents: AgentStreamEvent[] = []; + session.subscribe((event) => { + streamedEvents.push(event); + }); + + const result = await session.run("What is 2+2? Think step by step."); + + const reasoningTexts: string[] = []; + const assistantTexts: string[] = []; + + for (const event of streamedEvents) { + if (event.type === "timeline") { + if (event.item.type === "reasoning") { + reasoningTexts.push(event.item.text); + } else if (event.item.type === "assistant_message") { + assistantTexts.push(event.item.text); + } + } + } + + const fullReasoningText = reasoningTexts.join(""); + const fullAssistantText = assistantTexts.join(""); + + // The model should produce reasoning + expect(reasoningTexts.length).toBeGreaterThan(0); + expect(fullReasoningText.length).toBeGreaterThan(0); + + // The assistant text should be the response, not the reasoning + expect(assistantTexts.length).toBeGreaterThan(0); + + // Reasoning text must NOT appear in the assistant text + const reasoningPrefix = fullReasoningText.slice(0, 50); + if (reasoningPrefix.length > 10) { + expect(fullAssistantText).not.toContain(reasoningPrefix); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120_000, + ); +}); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index ccc0cc9f8..b89673385 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -9,6 +9,7 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState { accumulatedUsage: {}, streamedPartKeys: new Set(), emittedStructuredMessageIds: new Set(), + partTypes: new Map(), }; } @@ -253,6 +254,70 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("emits reasoning (not assistant_message) when delta field is 'text' for a known reasoning part", () => { + const state = createState(); + + // Part created as reasoning (message.part.updated fires before deltas) + translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "rp-2", + sessionID: "session-1", + messageID: "msg-r2", + type: "reasoning", + time: { start: 1 }, + }, + }, + }, + state, + ); + + // Deltas arrive with field="text" (the field name on ReasoningPart) + const delta1 = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + messageID: "msg-r2", + partID: "rp-2", + field: "text", + delta: "Thinking about this...", + }, + }, + state, + ); + + // Completed reasoning part should be deduped + const completed = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "rp-2", + sessionID: "session-1", + messageID: "msg-r2", + type: "reasoning", + text: "Thinking about this...", + time: { start: 1, end: 2 }, + }, + }, + }, + state, + ); + + const allEvents = [...delta1, ...completed]; + + expect(allEvents).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text: "Thinking about this..." }, + }, + ]); + }); + it("deduplicates when message.part.delta is followed by completed message.part.updated", () => { const state = createState(); diff --git a/packages/server/src/test-utils/vitest-setup.ts b/packages/server/src/test-utils/vitest-setup.ts index 8b4be14e1..2d038ec8a 100644 --- a/packages/server/src/test-utils/vitest-setup.ts +++ b/packages/server/src/test-utils/vitest-setup.ts @@ -7,6 +7,7 @@ const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ". dotenv.config({ path: path.resolve(serverRoot, ".env.test"), override: true }); dotenv.config({ path: path.resolve(serverRoot, "../.env") }); +process.env.PASEO_SUPERVISED = "0"; process.env.GIT_TERMINAL_PROMPT = "0"; process.env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes"; process.env.SSH_ASKPASS = "/usr/bin/false";