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.
This commit is contained in:
@@ -580,6 +580,8 @@ export type OpenCodeEventTranslationState = {
|
||||
accumulatedUsage: AgentUsage;
|
||||
streamedPartKeys: Set<string>;
|
||||
emittedStructuredMessageIds: Set<string>;
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
partTypes: Map<string, string>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
/** Tracks assistant messages already emitted from structured payloads. */
|
||||
private emittedStructuredMessageIds = new Set<string>();
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
private partTypes = new Map<string, string>();
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user