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";