fix(server): dedupe opencode assistant timeline echoes
This commit is contained in:
@@ -53,6 +53,7 @@ const DEFAULT_MODES: AgentMode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
|
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
|
||||||
|
type OpenCodeMessageRole = "user" | "assistant";
|
||||||
|
|
||||||
type OpenCodeMcpConfig =
|
type OpenCodeMcpConfig =
|
||||||
| {
|
| {
|
||||||
@@ -214,6 +215,21 @@ async function findAvailablePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePartDedupeKey(
|
||||||
|
part: AgentMetadata,
|
||||||
|
partType: "text" | "reasoning"
|
||||||
|
): string | null {
|
||||||
|
const partId = part.id;
|
||||||
|
if (typeof partId === "string" && partId.trim().length > 0) {
|
||||||
|
return `${partType}:${partId}`;
|
||||||
|
}
|
||||||
|
const messageId = part.messageID;
|
||||||
|
if (typeof messageId === "string" && messageId.trim().length > 0) {
|
||||||
|
return `${partType}:message:${messageId}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenCodeServerManager {
|
export class OpenCodeServerManager {
|
||||||
private static instance: OpenCodeServerManager | null = null;
|
private static instance: OpenCodeServerManager | null = null;
|
||||||
private static exitHandlerRegistered = false;
|
private static exitHandlerRegistered = false;
|
||||||
@@ -523,6 +539,211 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpenCodeEventTranslationState = {
|
||||||
|
sessionId: string;
|
||||||
|
messageRoles: Map<string, OpenCodeMessageRole>;
|
||||||
|
accumulatedUsage: AgentUsage;
|
||||||
|
streamedPartKeys: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function translateOpenCodeEvent(
|
||||||
|
event: unknown,
|
||||||
|
state: OpenCodeEventTranslationState
|
||||||
|
): AgentStreamEvent[] {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
|
if (!event || typeof event !== "object") {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = event as { type?: string; properties?: AgentMetadata };
|
||||||
|
const type = e.type;
|
||||||
|
const props = e.properties ?? {};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "session.created":
|
||||||
|
case "session.updated": {
|
||||||
|
const sessionId = props.id as string | undefined;
|
||||||
|
if (sessionId === state.sessionId) {
|
||||||
|
events.push({
|
||||||
|
type: "thread_started",
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
provider: "opencode",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.updated": {
|
||||||
|
const info = props.info as AgentMetadata | undefined;
|
||||||
|
if (!info) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const messageId = info.id as string | undefined;
|
||||||
|
const messageSessionId = info.sessionID as string | undefined;
|
||||||
|
const role = info.role as OpenCodeMessageRole | undefined;
|
||||||
|
|
||||||
|
if (messageId && messageSessionId === state.sessionId && role) {
|
||||||
|
state.messageRoles.set(messageId, role);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = props.part as AgentMetadata | undefined;
|
||||||
|
const delta = props.delta as string | undefined;
|
||||||
|
if (!part) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partSessionId = part.sessionID as string | undefined;
|
||||||
|
if (partSessionId !== state.sessionId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (partType === "text") {
|
||||||
|
const partKey = resolvePartDedupeKey(part, "text");
|
||||||
|
if (messageRole === "user") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!messageRole && !delta) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (delta) {
|
||||||
|
if (partKey) {
|
||||||
|
state.streamedPartKeys.add(partKey);
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "assistant_message", text: delta },
|
||||||
|
});
|
||||||
|
} else if (partTime?.end) {
|
||||||
|
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const text = part.text as string | undefined;
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "assistant_message", text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (partType === "reasoning") {
|
||||||
|
const partKey = resolvePartDedupeKey(part, "reasoning");
|
||||||
|
if (delta) {
|
||||||
|
if (partKey) {
|
||||||
|
state.streamedPartKeys.add(partKey);
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "reasoning", text: delta },
|
||||||
|
});
|
||||||
|
} else if (partTime?.end) {
|
||||||
|
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const text = part.text as string | undefined;
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "reasoning", text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (partType === "tool") {
|
||||||
|
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||||
|
if (parsedToolPart.success && parsedToolPart.data) {
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: parsedToolPart.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (partType === "step-finish") {
|
||||||
|
const tokens = part.tokens as { input?: number; output?: number; reasoning?: number } | undefined;
|
||||||
|
const cost = part.cost as number | undefined;
|
||||||
|
|
||||||
|
if (tokens) {
|
||||||
|
state.accumulatedUsage.inputTokens = (state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
||||||
|
state.accumulatedUsage.outputTokens = (state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
||||||
|
}
|
||||||
|
if (cost !== undefined) {
|
||||||
|
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "permission.asked": {
|
||||||
|
const sessionId = props.sessionID as string | undefined;
|
||||||
|
if (sessionId !== state.sessionId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = props.id as string;
|
||||||
|
const permission = props.permission as string;
|
||||||
|
const metadata = props.metadata as AgentMetadata | undefined;
|
||||||
|
const patterns = props.patterns as string[] | undefined;
|
||||||
|
|
||||||
|
const permRequest: AgentPermissionRequest = {
|
||||||
|
id: requestId,
|
||||||
|
provider: "opencode",
|
||||||
|
name: permission,
|
||||||
|
kind: "tool",
|
||||||
|
title: permission,
|
||||||
|
description: patterns?.join(", "),
|
||||||
|
input: metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "opencode",
|
||||||
|
request: permRequest,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.idle": {
|
||||||
|
const sessionId = props.sessionID as string | undefined;
|
||||||
|
if (sessionId === state.sessionId) {
|
||||||
|
state.streamedPartKeys.clear();
|
||||||
|
events.push({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "opencode",
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.error": {
|
||||||
|
const sessionId = props.sessionID as string | undefined;
|
||||||
|
if (sessionId === state.sessionId) {
|
||||||
|
state.streamedPartKeys.clear();
|
||||||
|
const error = props.error as string | undefined;
|
||||||
|
events.push({
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: "opencode",
|
||||||
|
error: error ?? "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
class OpenCodeAgentSession implements AgentSession {
|
class OpenCodeAgentSession implements AgentSession {
|
||||||
readonly provider: "opencode" = "opencode";
|
readonly provider: "opencode" = "opencode";
|
||||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||||
@@ -537,7 +758,9 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private mcpConfigured = false;
|
private mcpConfigured = false;
|
||||||
private mcpSetupPromise: Promise<void> | null = null;
|
private mcpSetupPromise: Promise<void> | null = null;
|
||||||
/** Tracks the role of each message by ID to distinguish user from assistant messages */
|
/** Tracks the role of each message by ID to distinguish user from assistant messages */
|
||||||
private messageRoles = new Map<string, "user" | "assistant">();
|
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
||||||
|
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
|
||||||
|
private streamedPartKeys = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: OpenCodeAgentConfig,
|
config: OpenCodeAgentConfig,
|
||||||
@@ -886,197 +1109,23 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private translateEvent(event: unknown): AgentStreamEvent[] {
|
private translateEvent(event: unknown): AgentStreamEvent[] {
|
||||||
const events: AgentStreamEvent[] = [];
|
const translated = translateOpenCodeEvent(event, {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
messageRoles: this.messageRoles,
|
||||||
|
accumulatedUsage: this.accumulatedUsage,
|
||||||
|
streamedPartKeys: this.streamedPartKeys,
|
||||||
|
});
|
||||||
|
|
||||||
if (!event || typeof event !== "object") {
|
for (const translatedEvent of translated) {
|
||||||
return events;
|
if (translatedEvent.type === "permission_requested") {
|
||||||
}
|
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
||||||
|
|
||||||
const e = event as { type?: string; properties?: AgentMetadata };
|
|
||||||
const type = e.type;
|
|
||||||
const props = e.properties ?? {};
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "session.created":
|
|
||||||
case "session.updated": {
|
|
||||||
const sessionId = props.id as string | undefined;
|
|
||||||
if (sessionId === this.sessionId) {
|
|
||||||
events.push({
|
|
||||||
type: "thread_started",
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
provider: "opencode",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (translatedEvent.type === "turn_completed") {
|
||||||
case "message.updated": {
|
translatedEvent.usage = this.extractAndResetUsage();
|
||||||
// Track message roles by ID so we can distinguish user from assistant parts
|
|
||||||
const info = props.info as AgentMetadata | undefined;
|
|
||||||
if (!info) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const messageId = info.id as string | undefined;
|
|
||||||
const messageSessionId = info.sessionID as string | undefined;
|
|
||||||
const role = info.role as "user" | "assistant" | undefined;
|
|
||||||
|
|
||||||
if (messageId && messageSessionId === this.sessionId && role) {
|
|
||||||
this.messageRoles.set(messageId, role);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "message.part.updated": {
|
|
||||||
// Structure: { part: { id, sessionID, messageID, type, text?, ... }, delta?: string }
|
|
||||||
const part = props.part as AgentMetadata | undefined;
|
|
||||||
const delta = props.delta as string | undefined;
|
|
||||||
if (!part) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const partSessionId = part.sessionID as string | undefined;
|
|
||||||
if (partSessionId !== this.sessionId) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageId = part.messageID as string | undefined;
|
|
||||||
const messageRole = messageId ? this.messageRoles.get(messageId) : undefined;
|
|
||||||
const partType = part.type as string | undefined;
|
|
||||||
const partTime = part.time as { start?: number; end?: number } | undefined;
|
|
||||||
|
|
||||||
if (partType === "text") {
|
|
||||||
// Skip user messages - agent-manager emits user_message via recordUserMessage
|
|
||||||
if (messageRole === "user") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Skip if role unknown AND no delta (likely user message before role is known)
|
|
||||||
if (!messageRole && !delta) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Emit delta for streaming, or full text only when complete (has time.end)
|
|
||||||
if (delta) {
|
|
||||||
events.push({
|
|
||||||
type: "timeline",
|
|
||||||
provider: "opencode",
|
|
||||||
item: { type: "assistant_message", text: delta },
|
|
||||||
});
|
|
||||||
} else if (partTime?.end) {
|
|
||||||
// Final complete text - only emit if we haven't been streaming deltas
|
|
||||||
// (In practice, if delta was present, we've already streamed it all)
|
|
||||||
const text = part.text as string | undefined;
|
|
||||||
if (text) {
|
|
||||||
events.push({
|
|
||||||
type: "timeline",
|
|
||||||
provider: "opencode",
|
|
||||||
item: { type: "assistant_message", text },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (partType === "reasoning") {
|
|
||||||
// Emit delta for streaming reasoning
|
|
||||||
if (delta) {
|
|
||||||
events.push({
|
|
||||||
type: "timeline",
|
|
||||||
provider: "opencode",
|
|
||||||
item: { type: "reasoning", text: delta },
|
|
||||||
});
|
|
||||||
} else if (partTime?.end) {
|
|
||||||
const text = part.text as string | undefined;
|
|
||||||
if (text) {
|
|
||||||
events.push({
|
|
||||||
type: "timeline",
|
|
||||||
provider: "opencode",
|
|
||||||
item: { type: "reasoning", text },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (partType === "tool") {
|
|
||||||
// Tool parts: { tool: string, state: { status, input, output?, error? } }
|
|
||||||
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
|
||||||
if (parsedToolPart.success) {
|
|
||||||
if (parsedToolPart.data) {
|
|
||||||
events.push({
|
|
||||||
type: "timeline",
|
|
||||||
provider: "opencode",
|
|
||||||
item: parsedToolPart.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (partType === "step-finish") {
|
|
||||||
// Extract usage from step-finish parts
|
|
||||||
const tokens = part.tokens as { input?: number; output?: number; reasoning?: number } | undefined;
|
|
||||||
const cost = part.cost as number | undefined;
|
|
||||||
|
|
||||||
if (tokens) {
|
|
||||||
this.accumulatedUsage.inputTokens = (this.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
|
||||||
this.accumulatedUsage.outputTokens = (this.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
|
||||||
}
|
|
||||||
if (cost !== undefined) {
|
|
||||||
this.accumulatedUsage.totalCostUsd = (this.accumulatedUsage.totalCostUsd ?? 0) + cost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "permission.asked": {
|
|
||||||
// props IS the PermissionRequest directly
|
|
||||||
const sessionId = props.sessionID as string | undefined;
|
|
||||||
if (sessionId !== this.sessionId) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = props.id as string;
|
|
||||||
const permission = props.permission as string;
|
|
||||||
const metadata = props.metadata as AgentMetadata | undefined;
|
|
||||||
const patterns = props.patterns as string[] | undefined;
|
|
||||||
|
|
||||||
const permRequest: AgentPermissionRequest = {
|
|
||||||
id: requestId,
|
|
||||||
provider: "opencode",
|
|
||||||
name: permission,
|
|
||||||
kind: "tool",
|
|
||||||
title: permission,
|
|
||||||
description: patterns?.join(", "),
|
|
||||||
input: metadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pendingPermissions.set(requestId, permRequest);
|
|
||||||
events.push({
|
|
||||||
type: "permission_requested",
|
|
||||||
provider: "opencode",
|
|
||||||
request: permRequest,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "session.idle": {
|
|
||||||
const sessionId = props.sessionID as string | undefined;
|
|
||||||
if (sessionId === this.sessionId) {
|
|
||||||
const usage = this.extractAndResetUsage();
|
|
||||||
events.push({
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "opencode",
|
|
||||||
usage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "session.error": {
|
|
||||||
const sessionId = props.sessionID as string | undefined;
|
|
||||||
if (sessionId === this.sessionId) {
|
|
||||||
const error = props.error as string | undefined;
|
|
||||||
events.push({
|
|
||||||
type: "turn_failed",
|
|
||||||
provider: "opencode",
|
|
||||||
error: error ?? "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return events;
|
return translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractAndResetUsage(): AgentUsage | undefined {
|
private extractAndResetUsage(): AgentUsage | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
translateOpenCodeEvent,
|
||||||
|
type OpenCodeEventTranslationState,
|
||||||
|
} from "../opencode-agent.js";
|
||||||
|
|
||||||
|
function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
messageRoles: new Map(),
|
||||||
|
accumulatedUsage: {},
|
||||||
|
streamedPartKeys: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("translateOpenCodeEvent", () => {
|
||||||
|
it("does not duplicate assistant output when completed part echoes streamed delta", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "message-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
const streamed = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
delta: "hey! what can I help with?",
|
||||||
|
part: {
|
||||||
|
id: "part-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-1",
|
||||||
|
type: "text",
|
||||||
|
time: { start: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
const completed = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "part-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-1",
|
||||||
|
type: "text",
|
||||||
|
text: "hey! what can I help with?",
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
const assistantEvents = [...streamed, ...completed].filter(
|
||||||
|
(event) => event.type === "timeline" && event.item.type === "assistant_message"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantEvents).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "assistant_message", text: "hey! what can I help with?" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits completed assistant text when no delta was streamed", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "message-2",
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
const completed = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "part-2",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-2",
|
||||||
|
type: "text",
|
||||||
|
text: "final text",
|
||||||
|
time: { start: 3, end: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completed).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "assistant_message", text: "final text" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate reasoning output when completed part echoes streamed delta", () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
const streamed = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
delta: "The user said hello.",
|
||||||
|
part: {
|
||||||
|
id: "reasoning-part-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-3",
|
||||||
|
type: "reasoning",
|
||||||
|
time: { start: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
const completed = translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "reasoning-part-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "message-3",
|
||||||
|
type: "reasoning",
|
||||||
|
text: "The user said hello.",
|
||||||
|
time: { start: 10, end: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state
|
||||||
|
);
|
||||||
|
|
||||||
|
const reasoningEvents = [...streamed, ...completed].filter(
|
||||||
|
(event) => event.type === "timeline" && event.item.type === "reasoning"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reasoningEvents).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "opencode",
|
||||||
|
item: { type: "reasoning", text: "The user said hello." },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user