From 154afc7cbc5227aa3b8b8bfbf00109ac3c0717f4 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Sun, 7 Dec 2025 18:22:40 +0100 Subject: [PATCH 01/14] chat messages transform hook and chat messages type --- packages/opencode/src/session/message-v2.ts | 204 ++++++++++++++++++++ packages/opencode/src/session/prompt.ts | 56 +++--- packages/plugin/src/index.ts | 23 +++ packages/sdk/js/src/index.ts | 1 + packages/sdk/js/src/types.ts | 49 +++++ 5 files changed, 302 insertions(+), 31 deletions(-) create mode 100644 packages/sdk/js/src/types.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index b0dc125494d..ca22d669090 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -3,6 +3,7 @@ import { Bus } from "../bus" import { NamedError } from "@opencode-ai/util/error" import { Message } from "./message" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" +import type { ChatMessage, ChatMessageFilePart } from "@opencode-ai/sdk" import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" @@ -671,6 +672,209 @@ export namespace MessageV2 { return convertToModelMessages(result.filter((msg) => msg.parts.length > 0)) } + export function toChatMessage(message: MessageV2.WithParts): ChatMessage { + const info = message.info + const parts = message.parts + const chatParts: ChatMessage["parts"] = [] + + if (info.role === "user") { + for (const part of parts) { + if (part.type === "text" && !part.ignored) { + chatParts.push({ + type: "text", + text: part.text, + }) + } + if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { + chatParts.push({ + type: "file", + url: part.url, + mediaType: part.mime, + filename: part.filename, + }) + } + if (part.type === "compaction") { + chatParts.push({ + type: "text", + text: "What did we do so far?", + }) + } + if (part.type === "subtask") { + chatParts.push({ + type: "text", + text: "The following tool was executed by the user", + }) + } + } + } + + if (info.role === "assistant") { + for (const part of parts) { + if (part.type === "text") { + chatParts.push({ + type: "text", + text: part.text, + providerMetadata: part.metadata, + }) + } + if (part.type === "step-start") { + chatParts.push({ + type: "step-start", + }) + } + if (part.type === "tool") { + if (part.state.status === "completed") { + chatParts.push({ + type: "tool", + toolName: part.tool, + toolCallId: part.callID, + state: "completed", + input: part.state.input, + output: part.state.output, + compacted: !!part.state.time.compacted, + callProviderMetadata: part.metadata, + attachments: part.state.attachments?.map((attachment) => ({ + type: "file" as const, + url: attachment.url, + mediaType: attachment.mime, + filename: attachment.filename, + })), + }) + } + if (part.state.status === "error") { + chatParts.push({ + type: "tool", + toolName: part.tool, + toolCallId: part.callID, + state: "error", + input: part.state.input, + error: part.state.error, + callProviderMetadata: part.metadata, + }) + } + } + if (part.type === "reasoning") { + chatParts.push({ + type: "reasoning", + text: part.text, + providerMetadata: part.metadata, + }) + } + } + } + + return { + id: info.id, + role: info.role, + parts: chatParts, + } + } + + export function chatMessagesToModelMessages(input: ChatMessage[]): ModelMessage[] { + const result: UIMessage[] = [] + + for (const msg of input) { + if (msg.parts.length === 0) continue + + if (msg.role === "user") { + const userMessage: UIMessage = { + id: msg.id, + role: "user", + parts: [], + } + result.push(userMessage) + for (const part of msg.parts) { + if (part.type === "text") { + userMessage.parts.push({ + type: "text", + text: part.text, + }) + } + if (part.type === "file") { + userMessage.parts.push({ + type: "file", + url: part.url, + mediaType: part.mediaType, + filename: part.filename, + }) + } + } + } + + if (msg.role === "assistant") { + const assistantMessage: UIMessage = { + id: msg.id, + role: "assistant", + parts: [], + } + result.push(assistantMessage) + for (const part of msg.parts) { + if (part.type === "text") { + assistantMessage.parts.push({ + type: "text", + text: part.text, + providerMetadata: part.providerMetadata as Record> | undefined, + }) + } + if (part.type === "step-start") { + assistantMessage.parts.push({ + type: "step-start", + }) + } + if (part.type === "tool") { + if (part.attachments?.length) { + result.push({ + id: Identifier.ascending("message"), + role: "user", + parts: [ + { + type: "text", + text: `Tool ${part.toolName} returned an attachment:`, + }, + ...part.attachments.map((attachment: ChatMessageFilePart) => ({ + type: "file" as const, + url: attachment.url, + mediaType: attachment.mediaType, + filename: attachment.filename, + })), + ], + }) + } + if (part.state === "completed") { + assistantMessage.parts.push({ + type: ("tool-" + part.toolName) as `tool-${string}`, + state: "output-available", + toolCallId: part.toolCallId, + input: part.input, + output: part.compacted ? "[Old tool result content cleared]" : (part.output ?? ""), + callProviderMetadata: part.callProviderMetadata as Record> | undefined, + }) + } + if (part.state === "error") { + assistantMessage.parts.push({ + type: ("tool-" + part.toolName) as `tool-${string}`, + state: "output-error", + toolCallId: part.toolCallId, + input: part.input, + errorText: part.error ?? "", + callProviderMetadata: part.callProviderMetadata as Record> | undefined, + }) + } + } + if (part.type === "reasoning") { + assistantMessage.parts.push({ + type: "reasoning", + text: part.text, + providerMetadata: part.providerMetadata as Record> | undefined, + }) + } + } + } + } + + return convertToModelMessages(result.filter((msg) => msg.parts.length > 0)) + } + export const stream = fn(Identifier.schema("session"), async function* (sessionID) { const list = await Array.fromAsync(await Storage.list(["message", sessionID])) for (let i = list.length - 1; i >= 0; i--) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d5010bc47d8..7c7221eac4a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -515,6 +515,30 @@ export namespace SessionPrompt { }) } + const chatMessages = msgs.map(m => { + return MessageV2.toChatMessage(m); + }); + + await Plugin.trigger("chat.messages.transform", {}, { messages: chatMessages }) + + const modelMessages: ModelMessage[] = [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...MessageV2.chatMessagesToModelMessages(chatMessages), + ...(isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), + ] + const result = await processor.process({ onError(error) { log.error("stream error", { @@ -567,37 +591,7 @@ export namespace SessionPrompt { temperature: params.temperature, topP: params.topP, toolChoice: isLastStep ? "none" : undefined, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - - return false - }), - ), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ], + messages: modelMessages, tools: model.capabilities.toolcall === false ? undefined : tools, model: wrapLanguageModel({ model: language, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 291d68b794d..1b519371069 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -9,6 +9,13 @@ import type { Part, Auth, Config, + ChatMessage, + ChatMessagePart, + ChatMessageTextPart, + ChatMessageFilePart, + ChatMessageStepStartPart, + ChatMessageToolPart, + ChatMessageReasoningPart, } from "@opencode-ai/sdk" import type { BunShell } from "./shell" @@ -16,6 +23,16 @@ import { type ToolDefinition } from "./tool" export * from "./tool" +export type { + ChatMessage, + ChatMessagePart, + ChatMessageTextPart, + ChatMessageFilePart, + ChatMessageStepStartPart, + ChatMessageToolPart, + ChatMessageReasoningPart, +} + export type ProviderContext = { source: "env" | "config" | "custom" | "api" info: Provider @@ -175,4 +192,10 @@ export interface Hooks { metadata: any }, ) => Promise + "chat.messages.transform"?: ( + input: {}, + output: { + messages: ChatMessage[] + }, + ) => Promise } diff --git a/packages/sdk/js/src/index.ts b/packages/sdk/js/src/index.ts index d044f5ad66e..18ebf6cd545 100644 --- a/packages/sdk/js/src/index.ts +++ b/packages/sdk/js/src/index.ts @@ -1,5 +1,6 @@ export * from "./client.js" export * from "./server.js" +export * from "./types.js" import { createOpencodeClient } from "./client.js" import { createOpencodeServer } from "./server.js" diff --git a/packages/sdk/js/src/types.ts b/packages/sdk/js/src/types.ts new file mode 100644 index 00000000000..24695846efd --- /dev/null +++ b/packages/sdk/js/src/types.ts @@ -0,0 +1,49 @@ +export interface ChatMessageTextPart { + type: "text" + text: string + providerMetadata?: Record +} + +export interface ChatMessageFilePart { + type: "file" + url: string + mediaType: string + filename?: string +} + +export interface ChatMessageStepStartPart { + type: "step-start" +} + +export interface ChatMessageToolPart { + type: "tool" + toolName: string + toolCallId: string + state: "completed" | "error" + input: Record + output?: string + error?: string + compacted?: boolean + callProviderMetadata?: Record + attachments?: ChatMessageFilePart[] +} + +export interface ChatMessageReasoningPart { + type: "reasoning" + text: string + providerMetadata?: Record +} + +export type ChatMessagePart = + | ChatMessageTextPart + | ChatMessageFilePart + | ChatMessageStepStartPart + | ChatMessageToolPart + | ChatMessageReasoningPart + +export interface ChatMessage { + id: string + role: "user" | "assistant" + parts: ChatMessagePart[] + altered?: boolean +} From f93b0ffaffaafb0a7fe8ae947abb8160df98b9c4 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Sun, 7 Dec 2025 18:34:20 +0100 Subject: [PATCH 02/14] fix typecheck errors --- packages/opencode/src/session/message-v2.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ca22d669090..3f02ea3c158 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -3,6 +3,7 @@ import { Bus } from "../bus" import { NamedError } from "@opencode-ai/util/error" import { Message } from "./message" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" +import type { SharedV2ProviderMetadata } from "@ai-sdk/provider" import type { ChatMessage, ChatMessageFilePart } from "@opencode-ai/sdk" import { Identifier } from "../id/id" import { LSP } from "../lsp" @@ -813,7 +814,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "text", text: part.text, - providerMetadata: part.providerMetadata as Record> | undefined, + providerMetadata: part.providerMetadata as SharedV2ProviderMetadata | undefined, }) } if (part.type === "step-start") { @@ -847,7 +848,7 @@ export namespace MessageV2 { toolCallId: part.toolCallId, input: part.input, output: part.compacted ? "[Old tool result content cleared]" : (part.output ?? ""), - callProviderMetadata: part.callProviderMetadata as Record> | undefined, + callProviderMetadata: part.callProviderMetadata as SharedV2ProviderMetadata | undefined, }) } if (part.state === "error") { @@ -857,7 +858,7 @@ export namespace MessageV2 { toolCallId: part.toolCallId, input: part.input, errorText: part.error ?? "", - callProviderMetadata: part.callProviderMetadata as Record> | undefined, + callProviderMetadata: part.callProviderMetadata as SharedV2ProviderMetadata | undefined, }) } } @@ -865,7 +866,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "reasoning", text: part.text, - providerMetadata: part.providerMetadata as Record> | undefined, + providerMetadata: part.providerMetadata as SharedV2ProviderMetadata | undefined, }) } } From 1f2ac0d4d8fcd42d61b8e8dff6c2f8320f6cc589 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen <56941036+jorgenwh@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:28:59 +0100 Subject: [PATCH 03/14] Update packages/opencode/src/session/prompt.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7c7221eac4a..f8cc9d5e289 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -517,7 +517,7 @@ export namespace SessionPrompt { const chatMessages = msgs.map(m => { return MessageV2.toChatMessage(m); - }); + const chatMessages = msgs.map((m) => MessageV2.toChatMessage(m)) await Plugin.trigger("chat.messages.transform", {}, { messages: chatMessages }) From ce47e3cf6aa0eb4ef49b5222ae108ceb6d1c640f Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Sun, 7 Dec 2025 20:31:44 +0100 Subject: [PATCH 04/14] fix github-actions bot bug --- packages/opencode/src/session/prompt.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f8cc9d5e289..579fd53b96c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -515,8 +515,6 @@ export namespace SessionPrompt { }) } - const chatMessages = msgs.map(m => { - return MessageV2.toChatMessage(m); const chatMessages = msgs.map((m) => MessageV2.toChatMessage(m)) await Plugin.trigger("chat.messages.transform", {}, { messages: chatMessages }) From 81efaf533b6a12994d3c29f3f10652a935740ba7 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Sun, 7 Dec 2025 20:37:47 +0100 Subject: [PATCH 05/14] add removed filter for messages --- packages/opencode/src/session/prompt.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 579fd53b96c..1e047f1ffa3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -515,7 +515,18 @@ export namespace SessionPrompt { }) } - const chatMessages = msgs.map((m) => MessageV2.toChatMessage(m)) + const chatMessages = msgs.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + return false + }).map((m) => MessageV2.toChatMessage(m)) await Plugin.trigger("chat.messages.transform", {}, { messages: chatMessages }) From 1b1ea7dd26560abb940e5036f185b1631bd282bf Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Mon, 8 Dec 2025 07:01:56 +0100 Subject: [PATCH 06/14] make hook experimental for now --- packages/plugin/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 9517b93c652..6655c0b8edc 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -192,7 +192,7 @@ export interface Hooks { metadata: any }, ) => Promise - "chat.messages.transform"?: ( + "experimental.chat.messages.transform"?: ( input: {}, output: { messages: ChatMessage[] From 12de5ec8095aa6630936a6d58e227ac9c72646a8 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Mon, 8 Dec 2025 07:03:36 +0100 Subject: [PATCH 07/14] make hook experimental for now --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1e047f1ffa3..f219efafd07 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -528,7 +528,7 @@ export namespace SessionPrompt { return false }).map((m) => MessageV2.toChatMessage(m)) - await Plugin.trigger("chat.messages.transform", {}, { messages: chatMessages }) + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: chatMessages }) const modelMessages: ModelMessage[] = [ ...system.map( From 183b8dee14e03c9ce33952a5e4c7734638bcf45e Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Wed, 10 Dec 2025 17:04:18 +0100 Subject: [PATCH 08/14] mimic messagev2 instead of vercel ai's model message --- packages/opencode/src/session/message-v2.ts | 193 ++++-------- packages/opencode/src/session/prompt.ts | 30 +- packages/plugin/src/index.ts | 66 +++- packages/sdk/js/src/types.ts | 322 ++++++++++++++++++-- 4 files changed, 417 insertions(+), 194 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e783397aba6..84c48d925b8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/util/error" import { Message } from "./message" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import type { SharedV2ProviderMetadata } from "@ai-sdk/provider" -import type { ChatMessage, ChatMessageFilePart } from "@opencode-ai/sdk" +import type { SessionMessage, SessionMessageFilePart } from "@opencode-ai/sdk" import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" @@ -533,138 +533,61 @@ export namespace MessageV2 { return convertToModelMessages(result.filter((msg) => msg.parts.length > 0)) } - export function toChatMessage(message: MessageV2.WithParts): ChatMessage { - const info = message.info - const parts = message.parts - const chatParts: ChatMessage["parts"] = [] - - if (info.role === "user") { - for (const part of parts) { - if (part.type === "text" && !part.ignored) { - chatParts.push({ - type: "text", - text: part.text, - }) - } - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - chatParts.push({ - type: "file", - url: part.url, - mediaType: part.mime, - filename: part.filename, - }) - } - if (part.type === "compaction") { - chatParts.push({ - type: "text", - text: "What did we do so far?", - }) - } - if (part.type === "subtask") { - chatParts.push({ - type: "text", - text: "The following tool was executed by the user", - }) - } - } - } - - if (info.role === "assistant") { - for (const part of parts) { - if (part.type === "text") { - chatParts.push({ - type: "text", - text: part.text, - providerMetadata: part.metadata, - }) - } - if (part.type === "step-start") { - chatParts.push({ - type: "step-start", - }) - } - if (part.type === "tool") { - if (part.state.status === "completed") { - chatParts.push({ - type: "tool", - toolName: part.tool, - toolCallId: part.callID, - state: "completed", - input: part.state.input, - output: part.state.output, - compacted: !!part.state.time.compacted, - callProviderMetadata: part.metadata, - attachments: part.state.attachments?.map((attachment) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - filename: attachment.filename, - })), - }) - } - if (part.state.status === "error") { - chatParts.push({ - type: "tool", - toolName: part.tool, - toolCallId: part.callID, - state: "error", - input: part.state.input, - error: part.state.error, - callProviderMetadata: part.metadata, - }) - } - } - if (part.type === "reasoning") { - chatParts.push({ - type: "reasoning", - text: part.text, - providerMetadata: part.metadata, - }) - } - } - } - + export function toSessionMessage(message: MessageV2.WithParts): SessionMessage { + // Lossless conversion - SessionMessage mirrors MessageV2.WithParts exactly return { - id: info.id, - role: info.role, - parts: chatParts, + info: message.info, + parts: message.parts, } } - export function chatMessagesToModelMessages(input: ChatMessage[]): ModelMessage[] { + export function sessionMessagesToModelMessages(input: SessionMessage[]): ModelMessage[] { const result: UIMessage[] = [] for (const msg of input) { if (msg.parts.length === 0) continue - if (msg.role === "user") { + if (msg.info.role === "user") { const userMessage: UIMessage = { - id: msg.id, + id: msg.info.id, role: "user", parts: [], } result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text") { + if (part.type === "text" && !part.ignored) { userMessage.parts.push({ type: "text", text: part.text, }) } - if (part.type === "file") { + // text/plain and directory files are converted into text parts, ignore them + if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { userMessage.parts.push({ type: "file", url: part.url, - mediaType: part.mediaType, + mediaType: part.mime, filename: part.filename, }) } + if (part.type === "compaction") { + userMessage.parts.push({ + type: "text", + text: "What did we do so far?", + }) + } + if (part.type === "subtask") { + userMessage.parts.push({ + type: "text", + text: "The following tool was executed by the user", + }) + } } } - if (msg.role === "assistant") { + if (msg.info.role === "assistant") { const assistantMessage: UIMessage = { - id: msg.id, + id: msg.info.id, role: "assistant", parts: [], } @@ -674,7 +597,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "text", text: part.text, - providerMetadata: part.providerMetadata as SharedV2ProviderMetadata | undefined, + providerMetadata: part.metadata as SharedV2ProviderMetadata | undefined, }) } if (part.type === "step-start") { @@ -683,42 +606,42 @@ export namespace MessageV2 { }) } if (part.type === "tool") { - if (part.attachments?.length) { - result.push({ - id: Identifier.ascending("message"), - role: "user", - parts: [ - { - type: "text", - text: `Tool ${part.toolName} returned an attachment:`, - }, - ...part.attachments.map((attachment: ChatMessageFilePart) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mediaType, - filename: attachment.filename, - })), - ], - }) - } - if (part.state === "completed") { + if (part.state.status === "completed") { + if (part.state.attachments?.length) { + result.push({ + id: Identifier.ascending("message"), + role: "user", + parts: [ + { + type: "text", + text: `Tool ${part.tool} returned an attachment:`, + }, + ...part.state.attachments.map((attachment: SessionMessageFilePart) => ({ + type: "file" as const, + url: attachment.url, + mediaType: attachment.mime, + filename: attachment.filename, + })), + ], + }) + } assistantMessage.parts.push({ - type: ("tool-" + part.toolName) as `tool-${string}`, + type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", - toolCallId: part.toolCallId, - input: part.input, - output: part.compacted ? "[Old tool result content cleared]" : (part.output ?? ""), - callProviderMetadata: part.callProviderMetadata as SharedV2ProviderMetadata | undefined, + toolCallId: part.callID, + input: part.state.input, + output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, + callProviderMetadata: part.metadata as SharedV2ProviderMetadata | undefined, }) } - if (part.state === "error") { + if (part.state.status === "error") { assistantMessage.parts.push({ - type: ("tool-" + part.toolName) as `tool-${string}`, + type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", - toolCallId: part.toolCallId, - input: part.input, - errorText: part.error ?? "", - callProviderMetadata: part.callProviderMetadata as SharedV2ProviderMetadata | undefined, + toolCallId: part.callID, + input: part.state.input, + errorText: part.state.error, + callProviderMetadata: part.metadata as SharedV2ProviderMetadata | undefined, }) } } @@ -726,7 +649,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "reasoning", text: part.text, - providerMetadata: part.providerMetadata as SharedV2ProviderMetadata | undefined, + providerMetadata: part.metadata as SharedV2ProviderMetadata | undefined, }) } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 15f5f31c240..93e90fbe1f0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -515,20 +515,22 @@ export namespace SessionPrompt { }) } - const chatMessages = msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - return false - }).map((m) => MessageV2.toChatMessage(m)) + const sessionMessages = msgs + .filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + return false + }) + .map((m) => MessageV2.toSessionMessage(m)) - await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: chatMessages }) + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) const modelMessages: ModelMessage[] = [ ...system.map( @@ -537,7 +539,7 @@ export namespace SessionPrompt { content: x, }), ), - ...MessageV2.chatMessagesToModelMessages(chatMessages), + ...MessageV2.sessionMessagesToModelMessages(sessionMessages), ...(isLastStep ? [ { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 6655c0b8edc..e263e71aba6 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -9,13 +9,31 @@ import type { Part, Auth, Config, - ChatMessage, - ChatMessagePart, - ChatMessageTextPart, - ChatMessageFilePart, - ChatMessageStepStartPart, - ChatMessageToolPart, - ChatMessageReasoningPart, + SessionMessage, + SessionMessageInfo, + SessionMessageUser, + SessionMessageAssistant, + SessionMessagePart, + SessionMessagePartBase, + SessionMessageTextPart, + SessionMessageReasoningPart, + SessionMessageFilePart, + SessionMessageToolPart, + SessionMessageStepStartPart, + SessionMessageStepFinishPart, + SessionMessageSnapshotPart, + SessionMessagePatchPart, + SessionMessageAgentPart, + SessionMessageCompactionPart, + SessionMessageSubtaskPart, + SessionMessageRetryPart, + SessionMessageToolState, + SessionMessageToolStatePending, + SessionMessageToolStateRunning, + SessionMessageToolStateCompleted, + SessionMessageToolStateError, + SessionMessageContentError, + SessionMessageFileDiff, } from "@opencode-ai/sdk" import type { BunShell } from "./shell" @@ -24,13 +42,31 @@ import { type ToolDefinition } from "./tool" export * from "./tool" export type { - ChatMessage, - ChatMessagePart, - ChatMessageTextPart, - ChatMessageFilePart, - ChatMessageStepStartPart, - ChatMessageToolPart, - ChatMessageReasoningPart, + SessionMessage, + SessionMessageInfo, + SessionMessageUser, + SessionMessageAssistant, + SessionMessagePart, + SessionMessagePartBase, + SessionMessageTextPart, + SessionMessageReasoningPart, + SessionMessageFilePart, + SessionMessageToolPart, + SessionMessageStepStartPart, + SessionMessageStepFinishPart, + SessionMessageSnapshotPart, + SessionMessagePatchPart, + SessionMessageAgentPart, + SessionMessageCompactionPart, + SessionMessageSubtaskPart, + SessionMessageRetryPart, + SessionMessageToolState, + SessionMessageToolStatePending, + SessionMessageToolStateRunning, + SessionMessageToolStateCompleted, + SessionMessageToolStateError, + SessionMessageContentError, + SessionMessageFileDiff, } export type ProviderContext = { @@ -195,7 +231,7 @@ export interface Hooks { "experimental.chat.messages.transform"?: ( input: {}, output: { - messages: ChatMessage[] + messages: SessionMessage[] }, ) => Promise "experimental.text.complete"?: ( diff --git a/packages/sdk/js/src/types.ts b/packages/sdk/js/src/types.ts index 24695846efd..b091a682484 100644 --- a/packages/sdk/js/src/types.ts +++ b/packages/sdk/js/src/types.ts @@ -1,49 +1,311 @@ -export interface ChatMessageTextPart { +export interface SessionMessagePartBase { + id: string + sessionID: string + messageID: string +} + +// File Part Source types +export interface SessionMessageFilePartSourceText { + value: string + start: number + end: number +} + +export interface SessionMessageFileSource { + type: "file" + path: string + text: SessionMessageFilePartSourceText +} + +export interface SessionMessageSymbolSource { + type: "symbol" + path: string + range: { + start: { line: number; character: number } + end: { line: number; character: number } + } + name: string + kind: number + text: SessionMessageFilePartSourceText +} + +export type SessionMessageFilePartSource = SessionMessageFileSource | SessionMessageSymbolSource + +// Part types +export interface SessionMessageSnapshotPart extends SessionMessagePartBase { + type: "snapshot" + snapshot: string +} + +export interface SessionMessagePatchPart extends SessionMessagePartBase { + type: "patch" + hash: string + files: string[] +} + +export interface SessionMessageTextPart extends SessionMessagePartBase { type: "text" text: string - providerMetadata?: Record + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: Record +} + +export interface SessionMessageReasoningPart extends SessionMessagePartBase { + type: "reasoning" + text: string + metadata?: Record + time: { + start: number + end?: number + } } -export interface ChatMessageFilePart { +export interface SessionMessageFilePart extends SessionMessagePartBase { type: "file" - url: string - mediaType: string + mime: string filename?: string + url: string + source?: SessionMessageFilePartSource +} + +export interface SessionMessageAgentPart extends SessionMessagePartBase { + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export interface SessionMessageCompactionPart extends SessionMessagePartBase { + type: "compaction" + auto: boolean +} + +export interface SessionMessageSubtaskPart extends SessionMessagePartBase { + type: "subtask" + prompt: string + description: string + agent: string +} + +export interface SessionMessageRetryPart extends SessionMessagePartBase { + type: "retry" + attempt: number + error: SessionMessageAPIError + time: { + created: number + } } -export interface ChatMessageStepStartPart { +export interface SessionMessageStepStartPart extends SessionMessagePartBase { type: "step-start" + snapshot?: string } -export interface ChatMessageToolPart { - type: "tool" - toolName: string - toolCallId: string - state: "completed" | "error" +export interface SessionMessageStepFinishPart extends SessionMessagePartBase { + type: "step-finish" + reason: string + snapshot?: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } +} + +// Tool state types +export interface SessionMessageToolStatePending { + status: "pending" input: Record - output?: string - error?: string - compacted?: boolean - callProviderMetadata?: Record - attachments?: ChatMessageFilePart[] + raw: string } -export interface ChatMessageReasoningPart { - type: "reasoning" - text: string - providerMetadata?: Record +export interface SessionMessageToolStateRunning { + status: "running" + input: Record + title?: string + metadata?: Record + time: { + start: number + } +} + +export interface SessionMessageToolStateCompleted { + status: "completed" + input: Record + output: string + title: string + metadata: Record + time: { + start: number + end: number + compacted?: number + } + attachments?: SessionMessageFilePart[] +} + +export interface SessionMessageToolStateError { + status: "error" + input: Record + error: string + metadata?: Record + time: { + start: number + end: number + } +} + +export type SessionMessageToolState = + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + +export interface SessionMessageToolPart extends SessionMessagePartBase { + type: "tool" + callID: string + tool: string + state: SessionMessageToolState + metadata?: Record +} + +export type SessionMessagePart = + | SessionMessageSnapshotPart + | SessionMessagePatchPart + | SessionMessageTextPart + | SessionMessageReasoningPart + | SessionMessageFilePart + | SessionMessageAgentPart + | SessionMessageCompactionPart + | SessionMessageSubtaskPart + | SessionMessageRetryPart + | SessionMessageStepStartPart + | SessionMessageStepFinishPart + | SessionMessageToolPart + +// Error types - uses NamedError structure { name, data } +export interface SessionMessageAPIError { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: Record + responseBody?: string + } } -export type ChatMessagePart = - | ChatMessageTextPart - | ChatMessageFilePart - | ChatMessageStepStartPart - | ChatMessageToolPart - | ChatMessageReasoningPart +export interface SessionMessageAuthError { + name: "ProviderAuthError" + data: { + providerID: string + message: string + } +} + +export interface SessionMessageOutputLengthError { + name: "MessageOutputLengthError" + data: Record +} + +export interface SessionMessageAbortedError { + name: "MessageAbortedError" + data: { + message: string + } +} + +export interface SessionMessageUnknownError { + name: "UnknownError" + data: { + message: string + } +} + +export type SessionMessageContentError = + | SessionMessageAPIError + | SessionMessageAuthError + | SessionMessageOutputLengthError + | SessionMessageAbortedError + | SessionMessageUnknownError -export interface ChatMessage { +// File diff for summary +export interface SessionMessageFileDiff { + file: string + before: string + after: string + additions: number + deletions: number +} + +// Message Info types +export interface SessionMessageUser { + id: string + sessionID: string + role: "user" + time: { + created: number + } + summary?: { + title?: string + body?: string + diffs: SessionMessageFileDiff[] + } + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: Record +} + +export interface SessionMessageAssistant { id: string - role: "user" | "assistant" - parts: ChatMessagePart[] - altered?: boolean + sessionID: string + role: "assistant" + time: { + created: number + completed?: number + } + error?: SessionMessageContentError + parentID: string + modelID: string + providerID: string + mode: string + path: { + cwd: string + root: string + } + summary?: boolean + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + finish?: string +} + +export type SessionMessageInfo = SessionMessageUser | SessionMessageAssistant + +export interface SessionMessage { + info: SessionMessageInfo + parts: SessionMessagePart[] } From 5441400b6fbc536cf7fced627837812eb6e7ba51 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Thu, 11 Dec 2025 02:08:58 +0100 Subject: [PATCH 09/14] cleanup --- packages/opencode/src/session/message-v2.ts | 1 - packages/opencode/src/session/prompt.ts | 32 ++++++++++++--------- packages/sdk/js/src/types.ts | 11 ++++--- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 84c48d925b8..47be5900c83 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -534,7 +534,6 @@ export namespace MessageV2 { } export function toSessionMessage(message: MessageV2.WithParts): SessionMessage { - // Lossless conversion - SessionMessage mirrors MessageV2.WithParts exactly return { info: message.info, parts: message.parts, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3fe3d3aa34b..31879ab6a9f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -520,20 +520,24 @@ export namespace SessionPrompt { }) } - const sessionMessages = msgs - .filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - return false - }) - .map((m) => MessageV2.toSessionMessage(m)) + const sessionMessages = JSON.parse( + JSON.stringify( + msgs + .filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + return false + }) + .map((m) => MessageV2.toSessionMessage(m)), + ), + ) await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) diff --git a/packages/sdk/js/src/types.ts b/packages/sdk/js/src/types.ts index b091a682484..a5ea5859aee 100644 --- a/packages/sdk/js/src/types.ts +++ b/packages/sdk/js/src/types.ts @@ -1,10 +1,16 @@ +/* + * SessionMessage type that mimics the MessageV2.WithParts structure. + * The SessionMessage type is exposed through the experimental.chat.messages.transform hook to allow + * plugins to transform messages before they are converted into Vercel AI's + * ModelMessage type. + * */ + export interface SessionMessagePartBase { id: string sessionID: string messageID: string } -// File Part Source types export interface SessionMessageFilePartSourceText { value: string start: number @@ -31,7 +37,6 @@ export interface SessionMessageSymbolSource { export type SessionMessageFilePartSource = SessionMessageFileSource | SessionMessageSymbolSource -// Part types export interface SessionMessageSnapshotPart extends SessionMessagePartBase { type: "snapshot" snapshot: string @@ -125,7 +130,6 @@ export interface SessionMessageStepFinishPart extends SessionMessagePartBase { } } -// Tool state types export interface SessionMessageToolStatePending { status: "pending" input: Record @@ -250,7 +254,6 @@ export interface SessionMessageFileDiff { deletions: number } -// Message Info types export interface SessionMessageUser { id: string sessionID: string From 7d9b083134d7a8b0d0213b67bf5cd6bbb1741538 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Thu, 11 Dec 2025 02:12:46 +0100 Subject: [PATCH 10/14] cleanup --- packages/opencode/src/session/message-v2.ts | 7 ----- packages/opencode/src/session/prompt.ts | 29 +++++++++++---------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 47be5900c83..eac40905a97 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -533,13 +533,6 @@ export namespace MessageV2 { return convertToModelMessages(result.filter((msg) => msg.parts.length > 0)) } - export function toSessionMessage(message: MessageV2.WithParts): SessionMessage { - return { - info: message.info, - parts: message.parts, - } - } - export function sessionMessagesToModelMessages(input: SessionMessage[]): ModelMessage[] { const result: UIMessage[] = [] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 31879ab6a9f..8749cceb3d4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -520,22 +520,23 @@ export namespace SessionPrompt { }) } + // Use JSON.stringify/parse to effectively make a deep copy of the message + // history, so that modifications made by plugins do not affect the original + // messages const sessionMessages = JSON.parse( JSON.stringify( - msgs - .filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - return false - }) - .map((m) => MessageV2.toSessionMessage(m)), + msgs.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + return false + }), ), ) From 65078de0c0d9be586135fdb83a2e579cf79a8205 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Thu, 11 Dec 2025 02:15:39 +0100 Subject: [PATCH 11/14] cleanup --- packages/sdk/js/src/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sdk/js/src/types.ts b/packages/sdk/js/src/types.ts index a5ea5859aee..f12dbbbeec2 100644 --- a/packages/sdk/js/src/types.ts +++ b/packages/sdk/js/src/types.ts @@ -199,7 +199,6 @@ export type SessionMessagePart = | SessionMessageStepFinishPart | SessionMessageToolPart -// Error types - uses NamedError structure { name, data } export interface SessionMessageAPIError { name: "APIError" data: { @@ -245,7 +244,6 @@ export type SessionMessageContentError = | SessionMessageAbortedError | SessionMessageUnknownError -// File diff for summary export interface SessionMessageFileDiff { file: string before: string From 9e52984aad5fb87818d8bb33d4b08349f55df345 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Thu, 11 Dec 2025 02:53:10 +0100 Subject: [PATCH 12/14] use MessageV2.WithParts directly --- packages/opencode/src/session/message-v2.ts | 120 +------- packages/opencode/src/session/prompt.ts | 6 +- packages/plugin/src/index.ts | 61 +--- packages/sdk/js/src/index.ts | 1 - packages/sdk/js/src/types.ts | 312 -------------------- 5 files changed, 11 insertions(+), 489 deletions(-) delete mode 100644 packages/sdk/js/src/types.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index eac40905a97..436286d1e40 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/util/error" import { Message } from "./message" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import type { SharedV2ProviderMetadata } from "@ai-sdk/provider" -import type { SessionMessage, SessionMessageFilePart } from "@opencode-ai/sdk" + import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" @@ -533,124 +533,6 @@ export namespace MessageV2 { return convertToModelMessages(result.filter((msg) => msg.parts.length > 0)) } - export function sessionMessagesToModelMessages(input: SessionMessage[]): ModelMessage[] { - const result: UIMessage[] = [] - - for (const msg of input) { - if (msg.parts.length === 0) continue - - if (msg.info.role === "user") { - const userMessage: UIMessage = { - id: msg.info.id, - role: "user", - parts: [], - } - result.push(userMessage) - for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) { - userMessage.parts.push({ - type: "text", - text: part.text, - }) - } - // text/plain and directory files are converted into text parts, ignore them - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - userMessage.parts.push({ - type: "file", - url: part.url, - mediaType: part.mime, - filename: part.filename, - }) - } - if (part.type === "compaction") { - userMessage.parts.push({ - type: "text", - text: "What did we do so far?", - }) - } - if (part.type === "subtask") { - userMessage.parts.push({ - type: "text", - text: "The following tool was executed by the user", - }) - } - } - } - - if (msg.info.role === "assistant") { - const assistantMessage: UIMessage = { - id: msg.info.id, - role: "assistant", - parts: [], - } - result.push(assistantMessage) - for (const part of msg.parts) { - if (part.type === "text") { - assistantMessage.parts.push({ - type: "text", - text: part.text, - providerMetadata: part.metadata as SharedV2ProviderMetadata | undefined, - }) - } - if (part.type === "step-start") { - assistantMessage.parts.push({ - type: "step-start", - }) - } - if (part.type === "tool") { - if (part.state.status === "completed") { - if (part.state.attachments?.length) { - result.push({ - id: Identifier.ascending("message"), - role: "user", - parts: [ - { - type: "text", - text: `Tool ${part.tool} returned an attachment:`, - }, - ...part.state.attachments.map((attachment: SessionMessageFilePart) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - filename: attachment.filename, - })), - ], - }) - } - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-available", - toolCallId: part.callID, - input: part.state.input, - output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, - callProviderMetadata: part.metadata as SharedV2ProviderMetadata | undefined, - }) - } - if (part.state.status === "error") { - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - callProviderMetadata: part.metadata as SharedV2ProviderMetadata | undefined, - }) - } - } - if (part.type === "reasoning") { - assistantMessage.parts.push({ - type: "reasoning", - text: part.text, - providerMetadata: part.metadata as SharedV2ProviderMetadata | undefined, - }) - } - } - } - } - - return convertToModelMessages(result.filter((msg) => msg.parts.length > 0)) - } - export const stream = fn(Identifier.schema("session"), async function* (sessionID) { const list = await Array.fromAsync(await Storage.list(["message", sessionID])) for (let i = list.length - 1; i >= 0; i--) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8749cceb3d4..746de8a2492 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -542,14 +542,14 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) - const modelMessages: ModelMessage[] = [ + const messages: ModelMessage[] = [ ...system.map( (x): ModelMessage => ({ role: "system", content: x, }), ), - ...MessageV2.sessionMessagesToModelMessages(sessionMessages), + ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep ? [ { @@ -612,7 +612,7 @@ export namespace SessionPrompt { temperature: params.temperature, topP: params.topP, toolChoice: isLastStep ? "none" : undefined, - messages: modelMessages, + messages, tools: model.capabilities.toolcall === false ? undefined : tools, model: wrapLanguageModel({ model: language, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e263e71aba6..68015687a72 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -6,69 +6,22 @@ import type { Provider, Permission, UserMessage, + Message, Part, Auth, Config, - SessionMessage, - SessionMessageInfo, - SessionMessageUser, - SessionMessageAssistant, - SessionMessagePart, - SessionMessagePartBase, - SessionMessageTextPart, - SessionMessageReasoningPart, - SessionMessageFilePart, - SessionMessageToolPart, - SessionMessageStepStartPart, - SessionMessageStepFinishPart, - SessionMessageSnapshotPart, - SessionMessagePatchPart, - SessionMessageAgentPart, - SessionMessageCompactionPart, - SessionMessageSubtaskPart, - SessionMessageRetryPart, - SessionMessageToolState, - SessionMessageToolStatePending, - SessionMessageToolStateRunning, - SessionMessageToolStateCompleted, - SessionMessageToolStateError, - SessionMessageContentError, - SessionMessageFileDiff, } from "@opencode-ai/sdk" +export type WithParts = { + info: Message + parts: Part[] +} + import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" export * from "./tool" -export type { - SessionMessage, - SessionMessageInfo, - SessionMessageUser, - SessionMessageAssistant, - SessionMessagePart, - SessionMessagePartBase, - SessionMessageTextPart, - SessionMessageReasoningPart, - SessionMessageFilePart, - SessionMessageToolPart, - SessionMessageStepStartPart, - SessionMessageStepFinishPart, - SessionMessageSnapshotPart, - SessionMessagePatchPart, - SessionMessageAgentPart, - SessionMessageCompactionPart, - SessionMessageSubtaskPart, - SessionMessageRetryPart, - SessionMessageToolState, - SessionMessageToolStatePending, - SessionMessageToolStateRunning, - SessionMessageToolStateCompleted, - SessionMessageToolStateError, - SessionMessageContentError, - SessionMessageFileDiff, -} - export type ProviderContext = { source: "env" | "config" | "custom" | "api" info: Provider @@ -231,7 +184,7 @@ export interface Hooks { "experimental.chat.messages.transform"?: ( input: {}, output: { - messages: SessionMessage[] + messages: WithParts[] }, ) => Promise "experimental.text.complete"?: ( diff --git a/packages/sdk/js/src/index.ts b/packages/sdk/js/src/index.ts index 18ebf6cd545..d044f5ad66e 100644 --- a/packages/sdk/js/src/index.ts +++ b/packages/sdk/js/src/index.ts @@ -1,6 +1,5 @@ export * from "./client.js" export * from "./server.js" -export * from "./types.js" import { createOpencodeClient } from "./client.js" import { createOpencodeServer } from "./server.js" diff --git a/packages/sdk/js/src/types.ts b/packages/sdk/js/src/types.ts deleted file mode 100644 index f12dbbbeec2..00000000000 --- a/packages/sdk/js/src/types.ts +++ /dev/null @@ -1,312 +0,0 @@ -/* - * SessionMessage type that mimics the MessageV2.WithParts structure. - * The SessionMessage type is exposed through the experimental.chat.messages.transform hook to allow - * plugins to transform messages before they are converted into Vercel AI's - * ModelMessage type. - * */ - -export interface SessionMessagePartBase { - id: string - sessionID: string - messageID: string -} - -export interface SessionMessageFilePartSourceText { - value: string - start: number - end: number -} - -export interface SessionMessageFileSource { - type: "file" - path: string - text: SessionMessageFilePartSourceText -} - -export interface SessionMessageSymbolSource { - type: "symbol" - path: string - range: { - start: { line: number; character: number } - end: { line: number; character: number } - } - name: string - kind: number - text: SessionMessageFilePartSourceText -} - -export type SessionMessageFilePartSource = SessionMessageFileSource | SessionMessageSymbolSource - -export interface SessionMessageSnapshotPart extends SessionMessagePartBase { - type: "snapshot" - snapshot: string -} - -export interface SessionMessagePatchPart extends SessionMessagePartBase { - type: "patch" - hash: string - files: string[] -} - -export interface SessionMessageTextPart extends SessionMessagePartBase { - type: "text" - text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: Record -} - -export interface SessionMessageReasoningPart extends SessionMessagePartBase { - type: "reasoning" - text: string - metadata?: Record - time: { - start: number - end?: number - } -} - -export interface SessionMessageFilePart extends SessionMessagePartBase { - type: "file" - mime: string - filename?: string - url: string - source?: SessionMessageFilePartSource -} - -export interface SessionMessageAgentPart extends SessionMessagePartBase { - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export interface SessionMessageCompactionPart extends SessionMessagePartBase { - type: "compaction" - auto: boolean -} - -export interface SessionMessageSubtaskPart extends SessionMessagePartBase { - type: "subtask" - prompt: string - description: string - agent: string -} - -export interface SessionMessageRetryPart extends SessionMessagePartBase { - type: "retry" - attempt: number - error: SessionMessageAPIError - time: { - created: number - } -} - -export interface SessionMessageStepStartPart extends SessionMessagePartBase { - type: "step-start" - snapshot?: string -} - -export interface SessionMessageStepFinishPart extends SessionMessagePartBase { - type: "step-finish" - reason: string - snapshot?: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } -} - -export interface SessionMessageToolStatePending { - status: "pending" - input: Record - raw: string -} - -export interface SessionMessageToolStateRunning { - status: "running" - input: Record - title?: string - metadata?: Record - time: { - start: number - } -} - -export interface SessionMessageToolStateCompleted { - status: "completed" - input: Record - output: string - title: string - metadata: Record - time: { - start: number - end: number - compacted?: number - } - attachments?: SessionMessageFilePart[] -} - -export interface SessionMessageToolStateError { - status: "error" - input: Record - error: string - metadata?: Record - time: { - start: number - end: number - } -} - -export type SessionMessageToolState = - | SessionMessageToolStatePending - | SessionMessageToolStateRunning - | SessionMessageToolStateCompleted - | SessionMessageToolStateError - -export interface SessionMessageToolPart extends SessionMessagePartBase { - type: "tool" - callID: string - tool: string - state: SessionMessageToolState - metadata?: Record -} - -export type SessionMessagePart = - | SessionMessageSnapshotPart - | SessionMessagePatchPart - | SessionMessageTextPart - | SessionMessageReasoningPart - | SessionMessageFilePart - | SessionMessageAgentPart - | SessionMessageCompactionPart - | SessionMessageSubtaskPart - | SessionMessageRetryPart - | SessionMessageStepStartPart - | SessionMessageStepFinishPart - | SessionMessageToolPart - -export interface SessionMessageAPIError { - name: "APIError" - data: { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: Record - responseBody?: string - } -} - -export interface SessionMessageAuthError { - name: "ProviderAuthError" - data: { - providerID: string - message: string - } -} - -export interface SessionMessageOutputLengthError { - name: "MessageOutputLengthError" - data: Record -} - -export interface SessionMessageAbortedError { - name: "MessageAbortedError" - data: { - message: string - } -} - -export interface SessionMessageUnknownError { - name: "UnknownError" - data: { - message: string - } -} - -export type SessionMessageContentError = - | SessionMessageAPIError - | SessionMessageAuthError - | SessionMessageOutputLengthError - | SessionMessageAbortedError - | SessionMessageUnknownError - -export interface SessionMessageFileDiff { - file: string - before: string - after: string - additions: number - deletions: number -} - -export interface SessionMessageUser { - id: string - sessionID: string - role: "user" - time: { - created: number - } - summary?: { - title?: string - body?: string - diffs: SessionMessageFileDiff[] - } - agent: string - model: { - providerID: string - modelID: string - } - system?: string - tools?: Record -} - -export interface SessionMessageAssistant { - id: string - sessionID: string - role: "assistant" - time: { - created: number - completed?: number - } - error?: SessionMessageContentError - parentID: string - modelID: string - providerID: string - mode: string - path: { - cwd: string - root: string - } - summary?: boolean - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - finish?: string -} - -export type SessionMessageInfo = SessionMessageUser | SessionMessageAssistant - -export interface SessionMessage { - info: SessionMessageInfo - parts: SessionMessagePart[] -} From 3c5089e215c2554127bf424aabb164d707e2230d Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Thu, 11 Dec 2025 02:53:47 +0100 Subject: [PATCH 13/14] remove unnecessary import --- packages/opencode/src/session/message-v2.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 436286d1e40..1f4fffaa66b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -4,8 +4,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Message } from "./message" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import type { SharedV2ProviderMetadata } from "@ai-sdk/provider" - import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" From 743870c3bee2a070fff371856d4dbe395c842f85 Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Thu, 11 Dec 2025 13:59:23 +0100 Subject: [PATCH 14/14] use remeda copy instead of ugly JSON trick --- packages/opencode/src/session/prompt.ts | 35 +++++++++++-------------- packages/plugin/src/index.ts | 10 +++---- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 746de8a2492..d311533313d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -29,7 +29,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { mergeDeep, pipe } from "remeda" +import { clone, mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" @@ -520,24 +520,21 @@ export namespace SessionPrompt { }) } - // Use JSON.stringify/parse to effectively make a deep copy of the message - // history, so that modifications made by plugins do not affect the original - // messages - const sessionMessages = JSON.parse( - JSON.stringify( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - return false - }), - ), + // Deep copy message history so that modifications made by plugins do not + // affect the original messages + const sessionMessages = clone( + msgs.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + return false + }), ) await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 68015687a72..57ca75d604f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -12,11 +12,6 @@ import type { Config, } from "@opencode-ai/sdk" -export type WithParts = { - info: Message - parts: Part[] -} - import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" @@ -184,7 +179,10 @@ export interface Hooks { "experimental.chat.messages.transform"?: ( input: {}, output: { - messages: WithParts[] + messages: { + info: Message + parts: Part[] + }[] }, ) => Promise "experimental.text.complete"?: (