Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
154afc7
chat messages transform hook and chat messages type
jorgenwh Dec 7, 2025
f93b0ff
fix typecheck errors
jorgenwh Dec 7, 2025
1f2ac0d
Update packages/opencode/src/session/prompt.ts
jorgenwh Dec 7, 2025
ce47e3c
fix github-actions bot bug
jorgenwh Dec 7, 2025
81efaf5
add removed filter for messages
jorgenwh Dec 7, 2025
5cedfa9
Merge branch 'dev' of https://github.com/sst/opencode into chat-messa…
jorgenwh Dec 8, 2025
22b0cab
Merge branch 'dev' of https://github.com/sst/opencode into chat-messa…
jorgenwh Dec 8, 2025
1b1ea7d
make hook experimental for now
jorgenwh Dec 8, 2025
12de5ec
make hook experimental for now
jorgenwh Dec 8, 2025
e9e96d3
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 8, 2025
5e4b9cc
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 8, 2025
b7e5b5b
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 9, 2025
c770f75
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 9, 2025
877b9ec
pull dev
jorgenwh Dec 10, 2025
183b8de
mimic messagev2 instead of vercel ai's model message
jorgenwh Dec 10, 2025
e90ff06
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 10, 2025
268f47d
pulled dev
jorgenwh Dec 10, 2025
6e06cee
pulled dev
jorgenwh Dec 11, 2025
f5b009b
Merge branch 'chat-messages-transform-hook' of github.com:jorgenwh/op…
jorgenwh Dec 11, 2025
5441400
cleanup
jorgenwh Dec 11, 2025
7d9b083
cleanup
jorgenwh Dec 11, 2025
65078de
cleanup
jorgenwh Dec 11, 2025
9e52984
use MessageV2.WithParts directly
jorgenwh Dec 11, 2025
3c5089e
remove unnecessary import
jorgenwh Dec 11, 2025
ee7ad25
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 11, 2025
c52baf7
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 11, 2025
0a73c56
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 11, 2025
4f4f65b
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 11, 2025
743870c
use remeda copy instead of ugly JSON trick
jorgenwh Dec 11, 2025
47acc80
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 11, 2025
388a442
Merge branch 'dev' into chat-messages-transform-hook
jorgenwh Dec 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ 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"
import { Snapshot } from "@/snapshot"
Expand Down Expand Up @@ -671,6 +673,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 SharedV2ProviderMetadata | 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 SharedV2ProviderMetadata | 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 SharedV2ProviderMetadata | undefined,
})
}
}
if (part.type === "reasoning") {
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.providerMetadata 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--) {
Expand Down
65 changes: 34 additions & 31 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,39 @@ 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))

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", {
Expand Down Expand Up @@ -567,37 +600,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,
Expand Down
23 changes: 23 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,30 @@ import type {
Part,
Auth,
Config,
ChatMessage,
ChatMessagePart,
ChatMessageTextPart,
ChatMessageFilePart,
ChatMessageStepStartPart,
ChatMessageToolPart,
ChatMessageReasoningPart,
} from "@opencode-ai/sdk"

import type { BunShell } from "./shell"
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
Expand Down Expand Up @@ -175,6 +192,12 @@ export interface Hooks {
metadata: any
},
) => Promise<void>
"chat.messages.transform"?: (
Comment thread
jorgenwh marked this conversation as resolved.
Outdated
input: {},
output: {
messages: ChatMessage[]
},
) => Promise<void>
Comment thread
jorgenwh marked this conversation as resolved.
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
49 changes: 49 additions & 0 deletions packages/sdk/js/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export interface ChatMessageTextPart {
type: "text"
text: string
providerMetadata?: Record<string, unknown>
}

export interface ChatMessageFilePart {
type: "file"
url: string
mediaType: string
filename?: string
}

export interface ChatMessageStepStartPart {
type: "step-start"
Comment thread
jorgenwh marked this conversation as resolved.
Outdated
}

export interface ChatMessageToolPart {
type: "tool"
toolName: string
toolCallId: string
state: "completed" | "error"
input: Record<string, unknown>
output?: string
error?: string
compacted?: boolean
callProviderMetadata?: Record<string, unknown>
attachments?: ChatMessageFilePart[]
}

export interface ChatMessageReasoningPart {
type: "reasoning"
text: string
providerMetadata?: Record<string, unknown>
}

export type ChatMessagePart =
| ChatMessageTextPart
| ChatMessageFilePart
| ChatMessageStepStartPart
| ChatMessageToolPart
| ChatMessageReasoningPart

export interface ChatMessage {
id: string
role: "user" | "assistant"
parts: ChatMessagePart[]
altered?: boolean
}
Loading