diff options
| author | Dax <[email protected]> | 2025-07-13 17:22:11 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-13 17:22:11 -0400 |
| commit | 90d6c4ab41bb097d7db354109e3616ff16778f0b (patch) | |
| tree | 303861ce5789f6e0e8e843cb8184dea829b4885d /packages | |
| parent | 736396fc70ab05204b886634ffbcd1318d82eca8 (diff) | |
| download | opencode-90d6c4ab41bb097d7db354109e3616ff16778f0b.tar.gz opencode-90d6c4ab41bb097d7db354109e3616ff16778f0b.zip | |
Part data model (#950)
Diffstat (limited to 'packages')
25 files changed, 1247 insertions, 946 deletions
diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 150afd887..c5ac8b4a4 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -42,7 +42,11 @@ export class SyncServer extends DurableObject<Env> { async publish(key: string, content: any) { const sessionID = await this.getSessionID() - if (!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/message/${sessionID}/`)) + if ( + !key.startsWith(`session/info/${sessionID}`) && + !key.startsWith(`session/message/${sessionID}/`) && + !key.startsWith(`session/part/${sessionID}/`) + ) return new Response("Error: Invalid key", { status: 400 }) // store message @@ -71,7 +75,7 @@ export class SyncServer extends DurableObject<Env> { } public async getData() { - const data = await this.ctx.storage.list() + const data = (await this.ctx.storage.list()) as Map<string, any> return Array.from(data.entries()) .filter(([key, _]) => key.startsWith("session/")) .map(([key, content]) => ({ key, content })) @@ -207,8 +211,13 @@ export default { return } if (type === "message") { - const [, messageID] = splits - messages[messageID] = d.content + messages[d.content.id] = { + parts: [], + ...d.content, + } + } + if (type === "part") { + messages[d.content.messageID].parts.push(d.content) } }) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c5b7e4ba9..6e61c1d82 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -33,6 +33,7 @@ "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", + "cli-markdown": "3.5.1", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0daa510d1..553bff818 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -9,6 +9,7 @@ import { Config } from "../../config/config" import { bootstrap } from "../bootstrap" import { MessageV2 } from "../../session/message-v2" import { Mode } from "../../session/mode" +import { Identifier } from "../../id/id" const TOOL: Record<string, [string, string]> = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -83,14 +84,9 @@ export const RunCommand = cmd({ return } - const isPiped = !process.stdout.isTTY - UI.empty() UI.println(UI.logo()) UI.empty() - const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message - UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage) - UI.empty() const cfg = await Config.get() if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) { @@ -120,8 +116,10 @@ export const RunCommand = cmd({ ) } + let text = "" Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - if (evt.properties.sessionID !== session.id) return + if (evt.properties.part.sessionID !== session.id) return + if (evt.properties.part.messageID === messageID) return const part = evt.properties.part if (part.type === "tool" && part.state.status === "completed") { @@ -130,13 +128,15 @@ export const RunCommand = cmd({ } if (part.type === "text") { - if (part.text.includes("\n")) { + text = part.text + + if (part.time?.end) { UI.empty() - UI.println(part.text) + UI.println(UI.markdown(text)) UI.empty() + text = "" return } - printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text) } }) @@ -156,8 +156,10 @@ export const RunCommand = cmd({ const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0]) + const messageID = Identifier.ascending("message") const result = await Session.chat({ sessionID: session.id, + messageID, ...(mode.model ? mode.model : { @@ -167,15 +169,19 @@ export const RunCommand = cmd({ mode: mode.name, parts: [ { + id: Identifier.ascending("part"), + sessionID: session.id, + messageID: messageID, type: "text", text: message, }, ], }) + const isPiped = !process.stdout.isTTY if (isPiped) { const match = result.parts.findLast((x) => x.type === "text") - if (match) process.stdout.write(match.text) + if (match) process.stdout.write(UI.markdown(match.text)) if (errorMsg) process.stdout.write(errorMsg) } UI.empty() diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 6c0b16003..39ae86ba0 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,7 +1,4 @@ -import { Storage } from "../../storage/storage" -import { MessageV2 } from "../../session/message-v2" import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" interface SessionStats { totalSessions: number @@ -27,87 +24,10 @@ interface SessionStats { export const StatsCommand = cmd({ command: "stats", - handler: async () => { - await bootstrap({ cwd: process.cwd() }, async () => { - const stats: SessionStats = { - totalSessions: 0, - totalMessages: 0, - totalCost: 0, - totalTokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - toolUsage: {}, - dateRange: { - earliest: Date.now(), - latest: 0, - }, - days: 0, - costPerDay: 0, - } - - const sessionMap = new Map<string, number>() - - try { - for await (const messagePath of Storage.list("session/message")) { - try { - const message = await Storage.readJSON<MessageV2.Info>(messagePath) - if (!message.parts.find((part) => part.type === "step-finish")) continue - - stats.totalMessages++ - - const sessionId = message.sessionID - sessionMap.set(sessionId, (sessionMap.get(sessionId) || 0) + 1) - - if (message.time.created < stats.dateRange.earliest) { - stats.dateRange.earliest = message.time.created - } - if (message.time.created > stats.dateRange.latest) { - stats.dateRange.latest = message.time.created - } - - if (message.role === "assistant") { - stats.totalCost += message.cost - stats.totalTokens.input += message.tokens.input - stats.totalTokens.output += message.tokens.output - stats.totalTokens.reasoning += message.tokens.reasoning - stats.totalTokens.cache.read += message.tokens.cache.read - stats.totalTokens.cache.write += message.tokens.cache.write - - for (const part of message.parts) { - if (part.type === "tool") { - stats.toolUsage[part.tool] = (stats.toolUsage[part.tool] || 0) + 1 - } - } - } - } catch (e) { - continue - } - } - } catch (e) { - console.error("Failed to read storage:", e) - return - } - - stats.totalSessions = sessionMap.size - - if (stats.dateRange.latest > 0) { - const daysDiff = (stats.dateRange.latest - stats.dateRange.earliest) / (1000 * 60 * 60 * 24) - stats.days = Math.max(1, Math.ceil(daysDiff)) - stats.costPerDay = stats.totalCost / stats.days - } - - displayStats(stats) - }) - }, + handler: async () => {}, }) -function displayStats(stats: SessionStats) { +export function displayStats(stats: SessionStats) { const width = 56 function renderRow(label: string, value: string): string { diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 9801b459e..1b6c3cace 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,8 @@ import { z } from "zod" import { EOL } from "os" import { NamedError } from "../util/error" +// @ts-ignore +import cliMarkdown from "cli-markdown" export namespace UI { const LOGO = [ @@ -76,4 +78,18 @@ export namespace UI { export function error(message: string) { println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) } + + export function markdown(text: string): string { + const rendered = cliMarkdown(text, { + width: process.stdout.columns || 80, + firstHeading: false, + tab: 0, + }).trim() + + // Remove leading space from each line + return rendered + .split("\n") + .map((line: string) => line.replace(/^ /, "")) + .join("\n") + } } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index b705ff2ce..6c1edd50d 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -6,6 +6,7 @@ export namespace Identifier { session: "ses", message: "msg", user: "usr", + part: "prt", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a958f48b6..6469b9bbc 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -269,6 +269,7 @@ export namespace Server { zValidator( "json", z.object({ + messageID: z.string(), providerID: z.string(), modelID: z.string(), }), @@ -405,7 +406,14 @@ export namespace Server { description: "List of messages", content: { "application/json": { - schema: resolver(MessageV2.Info.array()), + schema: resolver( + z + .object({ + info: MessageV2.Info, + parts: MessageV2.Part.array(), + }) + .array(), + ), }, }, }, @@ -446,10 +454,11 @@ export namespace Server { zValidator( "json", z.object({ + messageID: z.string(), providerID: z.string(), modelID: z.string(), mode: z.string(), - parts: MessageV2.UserPart.array(), + parts: z.union([MessageV2.FilePart, MessageV2.TextPart]).array(), }), ), async (c) => { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 16b9de9d3..44879e9b8 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -12,6 +12,7 @@ import { type ProviderMetadata, type ModelMessage, stepCountIs, + type StreamTextResult, } from "ai" import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" @@ -190,7 +191,10 @@ export namespace Session { await Storage.writeJSON<ShareInfo>("session/share/" + id, share) await Share.sync("session/info/" + id, session) for (const msg of await messages(id)) { - await Share.sync("session/message/" + id + "/" + msg.id, msg) + await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info) + for (const part of msg.parts) { + await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part) + } } return share } @@ -220,13 +224,19 @@ export namespace Session { } export async function messages(sessionID: string) { - const result = [] as MessageV2.Info[] + const result = [] as { + info: MessageV2.Info + parts: MessageV2.Part[] + }[] const list = Storage.list("session/message/" + sessionID) for await (const p of list) { const read = await Storage.readJSON<MessageV2.Info>(p) - result.push(read) + result.push({ + info: read, + parts: await parts(sessionID, read.id), + }) } - result.sort((a, b) => (a.id > b.id ? 1 : -1)) + result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1)) return result } @@ -234,6 +244,16 @@ export namespace Session { return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID) } + export async function parts(sessionID: string, messageID: string) { + const result = [] as MessageV2.Part[] + for await (const item of Storage.list("session/part/" + sessionID + "/" + messageID)) { + const read = await Storage.readJSON<MessageV2.Part>(item) + result.push(read) + } + result.sort((a, b) => (a.id > b.id ? 1 : -1)) + return result + } + export async function* list() { for await (const item of Storage.list("session/info")) { const sessionID = path.basename(item, ".json") @@ -289,12 +309,21 @@ export namespace Session { }) } + async function updatePart(part: MessageV2.Part) { + await Storage.writeJSON(["session", "part", part.sessionID, part.messageID, part.id].join("/"), part) + Bus.publish(MessageV2.Event.PartUpdated, { + part, + }) + return part + } + export async function chat(input: { sessionID: string + messageID: string providerID: string modelID: string mode?: string - parts: MessageV2.UserPart[] + parts: (MessageV2.TextPart | MessageV2.FilePart)[] }) { const l = log.clone().tag("session", input.sessionID) l.info("chatting") @@ -306,16 +335,19 @@ export namespace Session { if (session.revert) { const trimmed = [] for (const msg of msgs) { - if (msg.id > session.revert.messageID || (msg.id === session.revert.messageID && session.revert.part === 0)) { - await Storage.remove("session/message/" + input.sessionID + "/" + msg.id) + if ( + msg.info.id > session.revert.messageID || + (msg.info.id === session.revert.messageID && session.revert.part === 0) + ) { + await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id) await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, - messageID: msg.id, + messageID: msg.info.id, }) continue } - if (msg.id === session.revert.messageID) { + if (msg.info.id === session.revert.messageID) { if (session.revert.part === 0) break msg.parts = msg.parts.slice(0, session.revert.part) } @@ -327,7 +359,7 @@ export namespace Session { }) } - const previous = msgs.at(-1) as MessageV2.Assistant + const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX // auto summarize if too long @@ -346,12 +378,21 @@ export namespace Session { using abort = lock(input.sessionID) - const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true) - if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) + const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true) + if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id) + + const userMsg: MessageV2.Info = { + id: input.messageID, + role: "user", + sessionID: input.sessionID, + time: { + created: Date.now(), + }, + } const app = App.info() - input.parts = await Promise.all( - input.parts.map(async (part): Promise<MessageV2.UserPart[]> => { + const userParts = await Promise.all( + input.parts.map(async (part): Promise<MessageV2.Part[]> => { if (part.type === "file") { const url = new URL(part.url) switch (url.protocol) { @@ -406,11 +447,17 @@ export namespace Session { }) return [ { + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, { + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: input.sessionID, type: "text", synthetic: true, text: result.output, @@ -422,11 +469,17 @@ export namespace Session { FileTime.read(input.sessionID, filePath) return [ { + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: input.sessionID, type: "text", text: `Called the Read tool with the following input: {\"filePath\":\"${pathname}\"}`, synthetic: true, }, { + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: input.sessionID, type: "file", url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"), mime: part.mime, @@ -440,7 +493,10 @@ export namespace Session { ).then((x) => x.flat()) if (input.mode === "plan") - input.parts.push({ + userParts.push({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: input.sessionID, type: "text", text: PROMPT_PLAN, synthetic: true, @@ -459,13 +515,15 @@ export namespace Session { ), ...MessageV2.toModelMessage([ { - id: Identifier.ascending("message"), - role: "user", - sessionID: input.sessionID, - parts: input.parts, - time: { - created: Date.now(), + info: { + id: Identifier.ascending("message"), + role: "user", + sessionID: input.sessionID, + time: { + created: Date.now(), + }, }, + parts: userParts, }, ]), ], @@ -479,17 +537,11 @@ export namespace Session { }) .catch(() => {}) } - const msg: MessageV2.Info = { - id: Identifier.ascending("message"), - role: "user", - sessionID: input.sessionID, - parts: input.parts, - time: { - created: Date.now(), - }, + await updateMessage(userMsg) + for (const part of userParts) { + await updatePart(part) } - await updateMessage(msg) - msgs.push(msg) + msgs.push({ info: userMsg, parts: userParts }) const mode = await Mode.get(input.mode ?? "build") let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID) @@ -499,10 +551,9 @@ export namespace Session { const [first, ...rest] = system system = [first, rest.join("\n")] - const next: MessageV2.Info = { + const assistantMsg: MessageV2.Info = { id: Identifier.ascending("message"), role: "assistant", - parts: [], system, path: { cwd: app.path.cwd, @@ -522,7 +573,7 @@ export namespace Session { }, sessionID: input.sessionID, } - await updateMessage(next) + await updateMessage(assistantMsg) const tools: Record<string, AITool> = {} for (const item of await Provider.tools(input.providerID)) { @@ -531,20 +582,29 @@ export namespace Session { id: item.id as any, description: item.description, inputSchema: item.parameters as ZodSchema, - async execute(args, opts) { + async execute(args) { const result = await item.execute(args, { sessionID: input.sessionID, abort: abort.signal, - messageID: next.id, - metadata: async (val) => { - const match = next.parts.find( - (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === opts.toolCallId, - ) + messageID: assistantMsg.id, + metadata: async () => { + /* + const match = toolCalls[opts.toolCallId] if (match && match.state.status === "running") { - match.state.title = val.title - match.state.metadata = val.metadata + await updatePart({ + ...match, + state: { + title: val.title, + metadata: val.metadata, + status: "running", + input: args.input, + time: { + start: Date.now(), + }, + }, + }) } - await updateMessage(next) + */ }, }) return result @@ -582,10 +642,6 @@ export namespace Session { tools[key] = item } - let text: MessageV2.TextPart = { - type: "text", - text: "", - } const result = streamText({ onError() {}, maxRetries: 10, @@ -619,9 +675,20 @@ export namespace Session { ], }), }) + return processStream(assistantMsg, model.info, result) + } + + async function processStream( + assistantMsg: MessageV2.Assistant, + model: ModelsDev.Model, + stream: StreamTextResult<Record<string, AITool>, never>, + ) { try { - for await (const value of result.fullStream) { - l.info("part", { + let currentText: MessageV2.TextPart | undefined + const toolCalls: Record<string, MessageV2.ToolPart> = {} + + for await (const value of stream.fullStream) { + log.info("part", { type: value.type, }) switch (value.type) { @@ -629,88 +696,78 @@ export namespace Session { break case "tool-input-start": - next.parts.push({ + const part = await updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID: assistantMsg.sessionID, type: "tool", tool: value.toolName, - id: value.id, + callID: value.id, state: { status: "pending", }, }) - Bus.publish(MessageV2.Event.PartUpdated, { - part: next.parts[next.parts.length - 1], - sessionID: next.sessionID, - messageID: next.id, - }) + toolCalls[value.id] = part as MessageV2.ToolPart break case "tool-input-delta": break case "tool-call": { - const match = next.parts.find( - (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId, - ) + const match = toolCalls[value.toolCallId] if (match) { - match.state = { - status: "running", - input: value.input, - time: { - start: Date.now(), + const part = await updatePart({ + ...match, + state: { + status: "running", + input: value.input, + time: { + start: Date.now(), + }, }, - } - Bus.publish(MessageV2.Event.PartUpdated, { - part: match, - sessionID: next.sessionID, - messageID: next.id, }) + toolCalls[value.toolCallId] = part as MessageV2.ToolPart } break } case "tool-result": { - const match = next.parts.find( - (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId, - ) + const match = toolCalls[value.toolCallId] if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: value.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { - start: match.state.time.start, - end: Date.now(), + await updatePart({ + ...match, + state: { + status: "completed", + input: value.input, + output: value.output.output, + metadata: value.output.metadata, + title: value.output.title, + time: { + start: match.state.time.start, + end: Date.now(), + }, }, - } - Bus.publish(MessageV2.Event.PartUpdated, { - part: match, - sessionID: next.sessionID, - messageID: next.id, }) + delete toolCalls[value.toolCallId] } break } case "tool-error": { - const match = next.parts.find( - (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId, - ) + const match = toolCalls[value.toolCallId] if (match && match.state.status === "running") { - match.state = { - status: "error", - input: value.input, - error: (value.error as any).toString(), - time: { - start: match.state.time.start, - end: Date.now(), + await updatePart({ + ...match, + state: { + status: "error", + input: value.input, + error: (value.error as any).toString(), + time: { + start: match.state.time.start, + end: Date.now(), + }, }, - } - Bus.publish(MessageV2.Event.PartUpdated, { - part: match, - sessionID: next.sessionID, - messageID: next.id, }) + delete toolCalls[value.toolCallId] } break } @@ -719,53 +776,71 @@ export namespace Session { throw value.error case "start-step": - next.parts.push({ + await updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID: assistantMsg.sessionID, type: "step-start", }) break case "finish-step": - const usage = getUsage(model.info, value.usage, value.providerMetadata) - next.cost += usage.cost - next.tokens = usage.tokens - next.parts.push({ + const usage = getUsage(model, value.usage, value.providerMetadata) + assistantMsg.cost += usage.cost + assistantMsg.tokens = usage.tokens + await updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID: assistantMsg.sessionID, type: "step-finish", tokens: usage.tokens, cost: usage.cost, }) + await updateMessage(assistantMsg) break case "text-start": - text = { + currentText = { + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID: assistantMsg.sessionID, type: "text", text: "", + time: { + start: Date.now(), + }, } break case "text": - if (text.text === "") next.parts.push(text) - text.text += value.text + if (currentText) { + currentText.text += value.text + await updatePart(currentText) + } break case "text-end": - Bus.publish(MessageV2.Event.PartUpdated, { - part: text, - sessionID: next.sessionID, - messageID: next.id, - }) + if (currentText && currentText.text) { + currentText.time = { + start: Date.now(), + end: Date.now(), + } + await updatePart(currentText) + } + currentText = undefined break case "finish": - next.time.completed = Date.now() + assistantMsg.time.completed = Date.now() + await updateMessage(assistantMsg) break default: - l.info("unhandled", { + log.info("unhandled", { ...value, }) continue } - await updateMessage(next) } } catch (e) { log.error("", { @@ -773,7 +848,7 @@ export namespace Session { }) switch (true) { case e instanceof DOMException && e.name === "AbortError": - next.error = new MessageV2.AbortedError( + assistantMsg.error = new MessageV2.AbortedError( { message: e.message }, { cause: e, @@ -781,44 +856,48 @@ export namespace Session { ).toObject() break case MessageV2.OutputLengthError.isInstance(e): - next.error = e + assistantMsg.error = e break case LoadAPIKeyError.isInstance(e): - next.error = new Provider.AuthError( + assistantMsg.error = new Provider.AuthError( { - providerID: input.providerID, + providerID: model.id, message: e.message, }, { cause: e }, ).toObject() break case e instanceof Error: - next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() + assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() break default: - next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) + assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) } Bus.publish(Event.Error, { - sessionID: next.sessionID, - error: next.error, + sessionID: assistantMsg.sessionID, + error: assistantMsg.error, }) } - for (const part of next.parts) { + const p = await parts(assistantMsg.sessionID, assistantMsg.id) + for (const part of p) { if (part.type === "tool" && part.state.status !== "completed") { - part.state = { - status: "error", - error: "Tool execution aborted", - time: { - start: Date.now(), - end: Date.now(), + updatePart({ + ...part, + state: { + status: "error", + error: "Tool execution aborted", + time: { + start: Date.now(), + end: Date.now(), + }, + input: {}, }, - input: {}, - } + }) } } - next.time.completed = Date.now() - await updateMessage(next) - return next + assistantMsg.time.completed = Date.now() + await updateMessage(assistantMsg) + return { info: assistantMsg, parts: p } } export async function revert(_input: { sessionID: string; messageID: string; part: number }) { @@ -867,8 +946,8 @@ export namespace Session { export async function summarize(input: { sessionID: string; providerID: string; modelID: string }) { using abort = lock(input.sessionID) const msgs = await messages(input.sessionID) - const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)?.id - const filtered = msgs.filter((msg) => !lastSummary || msg.id >= lastSummary) + const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true) + const filtered = msgs.filter((msg) => !lastSummary || msg.info.id >= lastSummary.info.id) const model = await Provider.getModel(input.providerID, input.modelID) const app = App.info() const system = SystemPrompt.summarize(input.providerID) @@ -876,7 +955,6 @@ export namespace Session { const next: MessageV2.Info = { id: Identifier.ascending("message"), role: "assistant", - parts: [], sessionID: input.sessionID, system, path: { @@ -899,7 +977,6 @@ export namespace Session { } await updateMessage(next) - let text: MessageV2.TextPart | undefined const result = streamText({ abortSignal: abort.signal, model: model.language, @@ -921,81 +998,9 @@ export namespace Session { ], }, ], - onStepFinish: async (step) => { - const usage = getUsage(model.info, step.usage, step.providerMetadata) - next.cost += usage.cost - next.tokens = usage.tokens - await updateMessage(next) - if (text) { - Bus.publish(MessageV2.Event.PartUpdated, { - part: text, - messageID: next.id, - sessionID: next.sessionID, - }) - } - text = undefined - }, - async onFinish(input) { - const usage = getUsage(model.info, input.usage, input.providerMetadata) - next.cost += usage.cost - next.tokens = usage.tokens - next.time.completed = Date.now() - await updateMessage(next) - }, }) - try { - for await (const value of result.fullStream) { - switch (value.type) { - case "text": - if (!text) { - text = { - type: "text", - text: value.text, - } - next.parts.push(text) - } else text.text += value.text - await updateMessage(next) - break - } - } - } catch (e: any) { - log.error("summarize stream error", { - error: e, - }) - switch (true) { - case e instanceof DOMException && e.name === "AbortError": - next.error = new MessageV2.AbortedError( - { message: e.message }, - { - cause: e, - }, - ).toObject() - break - case MessageV2.OutputLengthError.isInstance(e): - next.error = e - break - case LoadAPIKeyError.isInstance(e): - next.error = new Provider.AuthError( - { - providerID: input.providerID, - message: e.message, - }, - { cause: e }, - ).toObject() - break - case e instanceof Error: - next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() - break - default: - next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() - } - Bus.publish(Event.Error, { - error: next.error, - }) - } - next.time.completed = Date.now() - await updateMessage(next) + return processStream(next, model.info, result) } function lock(sessionID: string) { @@ -1045,14 +1050,23 @@ export namespace Session { } } - export async function initialize(input: { sessionID: string; modelID: string; providerID: string }) { + export async function initialize(input: { + sessionID: string + modelID: string + providerID: string + messageID: string + }) { const app = App.info() await Session.chat({ sessionID: input.sessionID, + messageID: input.messageID, providerID: input.providerID, modelID: input.modelID, parts: [ { + id: Identifier.ascending("part"), + sessionID: input.sessionID, + messageID: input.messageID, type: "text", text: PROMPT_INITIALIZE.replace("${path}", app.path.root), }, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index aadc1a5e2..353fdcec3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -4,6 +4,7 @@ import { Provider } from "../provider/provider" import { NamedError } from "../util/error" import { Message } from "./message" import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai" +import { Identifier } from "../id/id" export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) @@ -72,67 +73,69 @@ export namespace MessageV2 { ref: "ToolState", }) - export const TextPart = z - .object({ - type: z.literal("text"), - text: z.string(), - synthetic: z.boolean().optional(), - }) - .openapi({ - ref: "TextPart", - }) + const PartBase = z.object({ + id: z.string(), + sessionID: z.string(), + messageID: z.string(), + }) + + export const TextPart = PartBase.extend({ + type: z.literal("text"), + text: z.string(), + synthetic: z.boolean().optional(), + time: z + .object({ + start: z.number(), + end: z.number().optional(), + }) + .optional(), + }).openapi({ + ref: "TextPart", + }) export type TextPart = z.infer<typeof TextPart> - export const ToolPart = z - .object({ - type: z.literal("tool"), - id: z.string(), - tool: z.string(), - state: ToolState, - }) - .openapi({ - ref: "ToolPart", - }) + export const ToolPart = PartBase.extend({ + type: z.literal("tool"), + callID: z.string(), + tool: z.string(), + state: ToolState, + }).openapi({ + ref: "ToolPart", + }) export type ToolPart = z.infer<typeof ToolPart> - export const FilePart = z - .object({ - type: z.literal("file"), - mime: z.string(), - filename: z.string().optional(), - url: z.string(), - }) - .openapi({ - ref: "FilePart", - }) + export const FilePart = PartBase.extend({ + type: z.literal("file"), + mime: z.string(), + filename: z.string().optional(), + url: z.string(), + }).openapi({ + ref: "FilePart", + }) export type FilePart = z.infer<typeof FilePart> - export const StepStartPart = z - .object({ - type: z.literal("step-start"), - }) - .openapi({ - ref: "StepStartPart", - }) + export const StepStartPart = PartBase.extend({ + type: z.literal("step-start"), + }).openapi({ + ref: "StepStartPart", + }) export type StepStartPart = z.infer<typeof StepStartPart> - export const StepFinishPart = z - .object({ - type: z.literal("step-finish"), - cost: z.number(), - tokens: z.object({ - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), + export const StepFinishPart = PartBase.extend({ + type: z.literal("step-finish"), + cost: z.number(), + tokens: z.object({ + input: z.number(), + output: z.number(), + reasoning: z.number(), + cache: z.object({ + read: z.number(), + write: z.number(), }), - }) - .openapi({ - ref: "StepFinishPart", - }) + }), + }).openapi({ + ref: "StepFinishPart", + }) export type StepFinishPart = z.infer<typeof StepFinishPart> const Base = z.object({ @@ -140,14 +143,8 @@ export namespace MessageV2 { sessionID: z.string(), }) - export const UserPart = z.discriminatedUnion("type", [TextPart, FilePart]).openapi({ - ref: "UserMessagePart", - }) - export type UserPart = z.infer<typeof UserPart> - export const User = Base.extend({ role: z.literal("user"), - parts: z.array(UserPart), time: z.object({ created: z.number(), }), @@ -156,16 +153,15 @@ export namespace MessageV2 { }) export type User = z.infer<typeof User> - export const AssistantPart = z - .discriminatedUnion("type", [TextPart, ToolPart, StepStartPart, StepFinishPart]) + export const Part = z + .discriminatedUnion("type", [TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart]) .openapi({ - ref: "AssistantMessagePart", + ref: "Part", }) - export type AssistantPart = z.infer<typeof AssistantPart> + export type Part = z.infer<typeof Part> export const Assistant = Base.extend({ role: z.literal("assistant"), - parts: z.array(AssistantPart), time: z.object({ created: z.number(), completed: z.number().optional(), @@ -223,16 +219,14 @@ export namespace MessageV2 { PartUpdated: Bus.event( "message.part.updated", z.object({ - part: AssistantPart, - sessionID: z.string(), - messageID: z.string(), + part: Part, }), ), } export function fromV1(v1: Message.Info) { if (v1.role === "assistant") { - const result: Assistant = { + const info: Assistant = { id: v1.id, sessionID: v1.metadata.sessionID, role: "assistant", @@ -248,109 +242,135 @@ export namespace MessageV2 { providerID: v1.metadata.assistant!.providerID, system: v1.metadata.assistant!.system, error: v1.metadata.error, - parts: v1.parts.flatMap((part): AssistantPart[] => { - if (part.type === "text") { - return [ - { - type: "text", - text: part.text, - }, - ] - } - if (part.type === "step-start") { - return [ - { - type: "step-start", - }, - ] - } - if (part.type === "tool-invocation") { - return [ - { - type: "tool", - id: part.toolInvocation.toolCallId, - tool: part.toolInvocation.toolName, - state: (() => { - if (part.toolInvocation.state === "partial-call") { - return { - status: "pending", - } + } + const parts = v1.parts.flatMap((part): Part[] => { + const base = { + id: Identifier.ascending("part"), + messageID: v1.id, + sessionID: v1.metadata.sessionID, + } + if (part.type === "text") { + return [ + { + ...base, + type: "text", + text: part.text, + }, + ] + } + if (part.type === "step-start") { + return [ + { + ...base, + type: "step-start", + }, + ] + } + if (part.type === "tool-invocation") { + return [ + { + ...base, + type: "tool", + callID: part.toolInvocation.toolCallId, + tool: part.toolInvocation.toolName, + state: (() => { + if (part.toolInvocation.state === "partial-call") { + return { + status: "pending", } + } - const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {} - if (part.toolInvocation.state === "call") { - return { - status: "running", - input: part.toolInvocation.args, - time: { - start: time?.start, - }, - } + const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {} + if (part.toolInvocation.state === "call") { + return { + status: "running", + input: part.toolInvocation.args, + time: { + start: time?.start, + }, } + } - if (part.toolInvocation.state === "result") { - return { - status: "completed", - input: part.toolInvocation.args, - output: part.toolInvocation.result, - title, - time, - metadata, - } + if (part.toolInvocation.state === "result") { + return { + status: "completed", + input: part.toolInvocation.args, + output: part.toolInvocation.result, + title, + time, + metadata, } - throw new Error("unknown tool invocation state") - })(), - }, - ] - } - return [] - }), + } + throw new Error("unknown tool invocation state") + })(), + }, + ] + } + return [] + }) + return { + info, + parts, } - return result } if (v1.role === "user") { - const result: User = { + const info: User = { id: v1.id, sessionID: v1.metadata.sessionID, role: "user", time: { created: v1.metadata.time.created, }, - parts: v1.parts.flatMap((part): UserPart[] => { - if (part.type === "text") { - return [ - { - type: "text", - text: part.text, - }, - ] - } - if (part.type === "file") { - return [ - { - type: "file", - mime: part.mediaType, - filename: part.filename, - url: part.url, - }, - ] - } - return [] - }), } - return result + const parts = v1.parts.flatMap((part): Part[] => { + const base = { + id: Identifier.ascending("part"), + messageID: v1.id, + sessionID: v1.metadata.sessionID, + } + if (part.type === "text") { + return [ + { + ...base, + type: "text", + text: part.text, + }, + ] + } + if (part.type === "file") { + return [ + { + ...base, + type: "file", + mime: part.mediaType, + filename: part.filename, + url: part.url, + }, + ] + } + return [] + }) + return { info, parts } } + + throw new Error("unknown message type") } - export function toModelMessage(input: Info[]): ModelMessage[] { + export function toModelMessage( + input: { + info: Info + parts: Part[] + }[], + ): ModelMessage[] { const result: UIMessage[] = [] for (const msg of input) { if (msg.parts.length === 0) continue - if (msg.role === "user") { + + if (msg.info.role === "user") { result.push({ - id: msg.id, + id: msg.info.id, role: "user", parts: msg.parts.flatMap((part): UIMessage["parts"] => { if (part.type === "text") @@ -374,9 +394,9 @@ export namespace MessageV2 { }) } - if (msg.role === "assistant") { + if (msg.info.role === "assistant") { result.push({ - id: msg.id, + id: msg.info.id, role: "assistant", parts: msg.parts.flatMap((part): UIMessage["parts"] => { if (part.type === "text") @@ -398,7 +418,7 @@ export namespace MessageV2 { { type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", - toolCallId: part.id, + toolCallId: part.callID, input: part.state.input, output: part.state.output, }, @@ -408,7 +428,7 @@ export namespace MessageV2 { { type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", - toolCallId: part.id, + toolCallId: part.callID, input: part.state.input, errorText: part.state.error, }, diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 001eee0a1..22876ee40 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,6 +5,7 @@ import path from "path" import z from "zod" import fs from "fs/promises" import { MessageV2 } from "../session/message-v2" +import { Identifier } from "../id/id" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -28,13 +29,49 @@ export namespace Storage { log.info("migrating to v2 message", { file }) try { const result = MessageV2.fromV1(content) - await Bun.write(file, JSON.stringify(result, null, 2)) + await Bun.write( + file, + JSON.stringify( + { + ...result.info, + parts: result.parts, + }, + null, + 2, + ), + ) } catch (e) { await fs.rename(file, file.replace("storage", "broken")) } } } catch {} }, + async (dir: string) => { + const files = new Bun.Glob("session/message/*/*.json").scanSync({ + cwd: dir, + absolute: true, + }) + for (const file of files) { + try { + const { parts, ...info } = await Bun.file(file).json() + if (!parts) continue + for (const part of parts) { + const id = Identifier.ascending("part") + await Bun.write( + [dir, "session", "part", info.sessionID, info.id, id + ".json"].join("/"), + JSON.stringify({ + ...part, + id, + sessionID: info.sessionID, + messageID: info.id, + ...(part.type === "tool" ? { callID: part.id } : {}), + }), + ) + } + await Bun.write(file, JSON.stringify(info, null, 2)) + } catch (e) {} + } + }, ] const state = App.state("storage", async () => { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 757398337..0d7808a3a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { z } from "zod" import { Session } from "../session" import { Bus } from "../bus" import { MessageV2 } from "../session/message-v2" +import { Identifier } from "../id/id" export const TaskTool = Tool.define({ id: "task", @@ -16,9 +17,10 @@ export const TaskTool = Tool.define({ const session = await Session.create(ctx.sessionID) const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant - function summary(input: MessageV2.Info) { + const parts: Record<string, MessageV2.Part> = {} + function summary(input: MessageV2.Part[]) { const result = [] - for (const part of input.parts) { + for (const part of input) { if (part.type === "tool" && part.state.status === "completed") { result.push(part) } @@ -26,12 +28,13 @@ export const TaskTool = Tool.define({ return result } - const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => { - if (evt.properties.info.sessionID !== session.id) return + const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + if (evt.properties.part.sessionID !== session.id) return + parts[evt.properties.part.id] = evt.properties.part ctx.metadata({ title: params.description, metadata: { - summary: summary(evt.properties.info), + summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)), }, }) }) @@ -39,12 +42,17 @@ export const TaskTool = Tool.define({ ctx.abort.addEventListener("abort", () => { Session.abort(session.id) }) + const messageID = Identifier.ascending("message") const result = await Session.chat({ + messageID, sessionID: session.id, modelID: msg.modelID, providerID: msg.providerID, parts: [ { + id: Identifier.ascending("part"), + messageID, + sessionID: session.id, type: "text", text: params.prompt, }, @@ -54,7 +62,7 @@ export const TaskTool = Tool.define({ return { title: params.description, metadata: { - summary: summary(result), + summary: summary(result.parts), }, output: result.parts.findLast((x) => x.type === "text")!.text, } diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 03eb361b7..fb8358d8c 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -16,11 +16,17 @@ import ( "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/toast" "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/id" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" ) +type Message struct { + Info opencode.MessageUnion + Parts []opencode.PartUnion +} + type App struct { Info opencode.App Modes []opencode.Mode @@ -35,7 +41,7 @@ type App struct { Provider *opencode.Provider Model *opencode.Model Session *opencode.Session - Messages []opencode.MessageUnion + Messages []Message Commands commands.CommandRegistry InitialModel *string InitialPrompt *string @@ -158,7 +164,7 @@ func New( ModeIndex: modeIndex, Mode: mode, Session: &opencode.Session{}, - Messages: []opencode.MessageUnion{}, + Messages: []Message{}, Commands: commands.LoadFromConfig(configInfo), InitialModel: initialModel, InitialPrompt: initialPrompt, @@ -351,7 +357,7 @@ func (a *App) IsBusy() bool { } lastMessage := a.Messages[len(a.Messages)-1] - if casted, ok := lastMessage.(opencode.AssistantMessage); ok { + if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok { return casted.Time.Completed == 0 } return false @@ -452,54 +458,67 @@ func (a *App) SendChatMessage( cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) } - optimisticParts := []opencode.UserMessagePart{{ - Type: opencode.UserMessagePartTypeText, - Text: text, + message := opencode.UserMessage{ + ID: id.Ascending(id.Message), + SessionID: a.Session.ID, + Role: opencode.UserMessageRoleUser, + Time: opencode.UserMessageTime{ + Created: float64(time.Now().UnixMilli()), + }, + } + + parts := []opencode.PartUnion{opencode.TextPart{ + ID: id.Ascending(id.Part), + MessageID: message.ID, + SessionID: a.Session.ID, + Type: opencode.TextPartTypeText, + Text: text, }} if len(attachments) > 0 { for _, attachment := range attachments { - optimisticParts = append(optimisticParts, opencode.UserMessagePart{ - Type: opencode.UserMessagePartTypeFile, - Filename: attachment.Filename.Value, - Mime: attachment.Mime.Value, - URL: attachment.URL.Value, + parts = append(parts, opencode.FilePart{ + ID: id.Ascending(id.Part), + MessageID: message.ID, + SessionID: a.Session.ID, + Type: opencode.FilePartTypeFile, + Filename: attachment.Filename.Value, + Mime: attachment.Mime.Value, + URL: attachment.URL.Value, }) } } - optimisticMessage := opencode.UserMessage{ - ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), - Role: opencode.UserMessageRoleUser, - Parts: optimisticParts, - SessionID: a.Session.ID, - Time: opencode.UserMessageTime{ - Created: float64(time.Now().Unix()), - }, - } - - a.Messages = append(a.Messages, optimisticMessage) - cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage})) + a.Messages = append(a.Messages, Message{Info: message, Parts: parts}) + cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: message})) cmds = append(cmds, func() tea.Msg { - parts := []opencode.UserMessagePartUnionParam{ - opencode.TextPartParam{ - Type: opencode.F(opencode.TextPartTypeText), - Text: opencode.F(text), - }, - } - if len(attachments) > 0 { - for _, attachment := range attachments { - parts = append(parts, opencode.FilePartParam{ - Mime: attachment.Mime, - Type: attachment.Type, - URL: attachment.URL, - Filename: attachment.Filename, + partsParam := []opencode.SessionChatParamsPartUnion{} + for _, part := range parts { + switch casted := part.(type) { + case opencode.TextPart: + partsParam = append(partsParam, opencode.TextPartParam{ + ID: opencode.F(casted.ID), + MessageID: opencode.F(casted.MessageID), + SessionID: opencode.F(casted.SessionID), + Type: opencode.F(casted.Type), + Text: opencode.F(casted.Text), + }) + case opencode.FilePart: + partsParam = append(partsParam, opencode.FilePartParam{ + ID: opencode.F(casted.ID), + Mime: opencode.F(casted.Mime), + MessageID: opencode.F(casted.MessageID), + SessionID: opencode.F(casted.SessionID), + Type: opencode.F(casted.Type), + URL: opencode.F(casted.URL), + Filename: opencode.F(casted.Filename), }) } } _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ - Parts: opencode.F(parts), + Parts: opencode.F(partsParam), + MessageID: opencode.F(message.ID), ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), Mode: opencode.F(a.Mode.Name), @@ -557,15 +576,25 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error { return nil } -func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) { +func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) { response, err := a.Client.Session.Messages(ctx, sessionId) if err != nil { return nil, err } if response == nil { - return []opencode.Message{}, nil + return []Message{}, nil + } + messages := []Message{} + for _, message := range *response { + msg := Message{ + Info: message.Info.AsUnion(), + Parts: []opencode.PartUnion{}, + } + for _, part := range message.Parts { + msg.Parts = append(msg.Parts, part.AsUnion()) + } + messages = append(messages, msg) } - messages := *response return messages, nil } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 7ecd9b21f..191432cc3 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -106,6 +106,13 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.GotoBottom() } } + case opencode.EventListResponseEventMessagePartUpdated: + if msg.Properties.Part.SessionID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } + } } viewport, cmd := m.viewport.Update(msg) @@ -131,16 +138,16 @@ func (m *messagesComponent) renderView(width int) { var content string var cached bool - switch casted := message.(type) { + switch casted := message.Info.(type) { case opencode.UserMessage: userLoop: - for partIndex, part := range casted.Parts { - switch part := part.AsUnion().(type) { + for partIndex, part := range message.Parts { + switch part := part.(type) { case opencode.TextPart: - remainingParts := casted.Parts[partIndex+1:] + remainingParts := message.Parts[partIndex+1:] fileParts := make([]opencode.FilePart, 0) for _, part := range remainingParts { - switch part := part.AsUnion().(type) { + switch part := part.(type) { case opencode.FilePart: fileParts = append(fileParts, part) } @@ -181,7 +188,7 @@ func (m *messagesComponent) renderView(width int) { if !cached { content = renderText( m.app, - message, + message.Info, part.Text, m.app.Info.User, m.showToolDetails, @@ -202,12 +209,12 @@ func (m *messagesComponent) renderView(width int) { case opencode.AssistantMessage: hasTextPart := false - for partIndex, p := range casted.Parts { - switch part := p.AsUnion().(type) { + for partIndex, p := range message.Parts { + switch part := p.(type) { case opencode.TextPart: hasTextPart = true finished := casted.Time.Completed > 0 - remainingParts := casted.Parts[partIndex+1:] + remainingParts := message.Parts[partIndex+1:] toolCallParts := make([]opencode.ToolPart, 0) // sometimes tool calls happen without an assistant message @@ -222,7 +229,7 @@ func (m *messagesComponent) renderView(width int) { if !remaining { break } - switch part := part.AsUnion().(type) { + switch part := part.(type) { case opencode.TextPart: // we only want tool calls associated with the current text part. // if we hit another text part, we're done. @@ -238,13 +245,13 @@ func (m *messagesComponent) renderView(width int) { } if finished { - key := m.cache.GenerateKey(casted.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount) + key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( m.app, - message, - p.Text, + message.Info, + part.Text, casted.ModelID, m.showToolDetails, m.partCount == m.selectedPart, @@ -257,8 +264,8 @@ func (m *messagesComponent) renderView(width int) { } else { content = renderText( m.app, - message, - p.Text, + message.Info, + part.Text, casted.ModelID, m.showToolDetails, m.partCount == m.selectedPart, @@ -268,7 +275,7 @@ func (m *messagesComponent) renderView(width int) { ) } if content != "" { - m = m.updateSelected(content, p.Text) + m = m.updateSelected(content, part.Text) blocks = append(blocks, content) } case opencode.ToolPart: @@ -314,7 +321,7 @@ func (m *messagesComponent) renderView(width int) { } error := "" - if assistant, ok := message.(opencode.AssistantMessage); ok { + if assistant, ok := message.Info.(opencode.AssistantMessage); ok { switch err := assistant.Error.AsUnion().(type) { case nil: case opencode.AssistantMessageErrorMessageOutputLengthError: @@ -386,7 +393,7 @@ func (m *messagesComponent) header(width int) string { contextWindow := m.app.Model.Limit.Context for _, message := range m.app.Messages { - if assistant, ok := message.(opencode.AssistantMessage); ok { + if assistant, ok := message.Info.(opencode.AssistantMessage); ok { cost += assistant.Cost usage := assistant.Tokens if usage.Output > 0 { diff --git a/packages/tui/internal/id/id.go b/packages/tui/internal/id/id.go new file mode 100644 index 000000000..0490b8f20 --- /dev/null +++ b/packages/tui/internal/id/id.go @@ -0,0 +1,96 @@ +package id + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" +) + +const ( + PrefixSession = "ses" + PrefixMessage = "msg" + PrefixUser = "usr" + PrefixPart = "prt" +) + +const length = 26 + +var ( + lastTimestamp int64 + counter int64 + mu sync.Mutex +) + +type Prefix string + +const ( + Session Prefix = PrefixSession + Message Prefix = PrefixMessage + User Prefix = PrefixUser + Part Prefix = PrefixPart +) + +func ValidatePrefix(id string, prefix Prefix) bool { + return strings.HasPrefix(id, string(prefix)) +} + +func Ascending(prefix Prefix, given ...string) string { + return generateID(prefix, false, given...) +} + +func Descending(prefix Prefix, given ...string) string { + return generateID(prefix, true, given...) +} + +func generateID(prefix Prefix, descending bool, given ...string) string { + if len(given) > 0 && given[0] != "" { + if !strings.HasPrefix(given[0], string(prefix)) { + panic(fmt.Sprintf("ID %s does not start with %s", given[0], string(prefix))) + } + return given[0] + } + + return generateNewID(prefix, descending) +} + +func randomBase62(length int) string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + result := make([]byte, length) + bytes := make([]byte, length) + rand.Read(bytes) + + for i := 0; i < length; i++ { + result[i] = chars[bytes[i]%62] + } + + return string(result) +} + +func generateNewID(prefix Prefix, descending bool) string { + mu.Lock() + defer mu.Unlock() + + currentTimestamp := time.Now().UnixMilli() + + if currentTimestamp != lastTimestamp { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + now := uint64(currentTimestamp)*0x1000 + uint64(counter) + + if descending { + now = ^now + } + + timeBytes := make([]byte, 6) + for i := 0; i < 6; i++ { + timeBytes[i] = byte((now >> (40 - 8*i)) & 0xff) + } + + return string(prefix) + "_" + hex.EncodeToString(timeBytes) + randomBase62(length-12) +}
\ No newline at end of file diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 389dd64f1..0ebdd35ab 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "os/exec" + "slices" "strings" "time" @@ -364,55 +365,76 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case opencode.EventListResponseEventSessionDeleted: if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID { a.app.Session = &opencode.Session{} - a.app.Messages = []opencode.MessageUnion{} + a.app.Messages = []app.Message{} } return a, toast.NewSuccessToast("Session deleted successfully") case opencode.EventListResponseEventSessionUpdated: if msg.Properties.Info.ID == a.app.Session.ID { a.app.Session = &msg.Properties.Info } - case opencode.EventListResponseEventMessageUpdated: - if msg.Properties.Info.SessionID == a.app.Session.ID { - exists := false - optimisticReplaced := false - - // First check if this is replacing an optimistic message - if msg.Properties.Info.Role == opencode.MessageRoleUser { - // Look for optimistic messages to replace - for i, m := range a.app.Messages { - switch m := m.(type) { - case opencode.UserMessage: - if strings.HasPrefix(m.ID, "optimistic-") && m.Role == opencode.UserMessageRoleUser { - // Replace the optimistic message with the real one - a.app.Messages[i] = msg.Properties.Info.AsUnion() - exists = true - optimisticReplaced = true - break - } + case opencode.EventListResponseEventMessagePartUpdated: + slog.Info("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID) + if msg.Properties.Part.SessionID == a.app.Session.ID { + messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { + switch casted := m.Info.(type) { + case opencode.UserMessage: + return casted.ID == msg.Properties.Part.MessageID + case opencode.AssistantMessage: + return casted.ID == msg.Properties.Part.MessageID + } + return false + }) + if messageIndex > -1 { + message := a.app.Messages[messageIndex] + partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool { + switch casted := p.(type) { + case opencode.TextPart: + return casted.ID == msg.Properties.Part.ID + case opencode.FilePart: + return casted.ID == msg.Properties.Part.ID + case opencode.ToolPart: + return casted.ID == msg.Properties.Part.ID + case opencode.StepStartPart: + return casted.ID == msg.Properties.Part.ID + case opencode.StepFinishPart: + return casted.ID == msg.Properties.Part.ID } + return false + }) + if partIndex > -1 { + message.Parts[partIndex] = msg.Properties.Part.AsUnion() } + if partIndex == -1 { + message.Parts = append(message.Parts, msg.Properties.Part.AsUnion()) + } + a.app.Messages[messageIndex] = message } - - // If not replacing optimistic, check for existing message with same ID - if !optimisticReplaced { - for i, m := range a.app.Messages { - var id string - switch m := m.(type) { - case opencode.UserMessage: - id = m.ID - case opencode.AssistantMessage: - id = m.ID - } - if id == msg.Properties.Info.ID { - a.app.Messages[i] = msg.Properties.Info.AsUnion() - exists = true - break - } + } + case opencode.EventListResponseEventMessageUpdated: + if msg.Properties.Info.SessionID == a.app.Session.ID { + matchIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { + switch casted := m.Info.(type) { + case opencode.UserMessage: + return casted.ID == msg.Properties.Info.ID + case opencode.AssistantMessage: + return casted.ID == msg.Properties.Info.ID + } + return false + }) + + if matchIndex > -1 { + match := a.app.Messages[matchIndex] + a.app.Messages[matchIndex] = app.Message{ + Info: msg.Properties.Info.AsUnion(), + Parts: match.Parts, } } - if !exists { - a.app.Messages = append(a.app.Messages, msg.Properties.Info.AsUnion()) + if matchIndex == -1 { + a.app.Messages = append(a.app.Messages, app.Message{ + Info: msg.Properties.Info.AsUnion(), + Parts: []opencode.PartUnion{}, + }) } } case opencode.EventListResponseEventSessionError: @@ -473,10 +495,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast("Failed to open session") } a.app.Session = msg - a.app.Messages = make([]opencode.MessageUnion, 0) - for _, message := range messages { - a.app.Messages = append(a.app.Messages, message.AsUnion()) - } + a.app.Messages = messages return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.ModelSelectedMsg: a.app.Provider = &msg.Provider @@ -837,7 +856,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return a, nil } a.app.Session = &opencode.Session{} - a.app.Messages = []opencode.MessageUnion{} + a.app.Messages = []app.Message{} cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{})) case commands.SessionListCommand: sessionDialog := dialog.NewSessionDialog(a.app) diff --git a/packages/tui/sdk/.stats.yml b/packages/tui/sdk/.stats.yml index 4449c4d95..61d2fa3b7 100644 --- a/packages/tui/sdk/.stats.yml +++ b/packages/tui/sdk/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 22 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-eb25bb3673f94d0e98a7036e2a2b0ed7ad63d1598665f2d5e091ec0835273798.yml -openapi_spec_hash: 62f6a8a06aaa4f4ae13e85d56652724f -config_hash: 589ec6a935a43a3c49a325ece86cbda2 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-352994eb17f76d9472b0f0176efacf77a200a6fab2db28d1cfcd29451b211d7a.yml +openapi_spec_hash: f01cd3de8c7cf0c9fd513896e81986de +config_hash: 3695cfc829cfaae14490850b4a1ed282 diff --git a/packages/tui/sdk/README.md b/packages/tui/sdk/README.md index 2b5782347..38840b28a 100644 --- a/packages/tui/sdk/README.md +++ b/packages/tui/sdk/README.md @@ -49,11 +49,14 @@ import ( func main() { client := opencode.NewClient() - events, err := client.Event.List(context.TODO()) + stream := client.Event.ListStreaming(context.TODO()) + for stream.Next() { + fmt.Printf("%+v\n", stream.Current()) + } + err := stream.Err() if err != nil { panic(err.Error()) } - fmt.Printf("%+v\n", events) } ``` @@ -171,14 +174,14 @@ When the API returns a non-success status code, we return an error with type To handle errors, we recommend that you use the `errors.As` pattern: ```go -_, err := client.Event.List(context.TODO()) -if err != nil { +stream := client.Event.ListStreaming(context.TODO()) +if stream.Err() != nil { var apierr *opencode.Error - if errors.As(err, &apierr) { + if errors.As(stream.Err(), &apierr) { println(string(apierr.DumpRequest(true))) // Prints the serialized HTTP request println(string(apierr.DumpResponse(true))) // Prints the serialized HTTP response } - panic(err.Error()) // GET "/event": 400 Bad Request { ... } + panic(stream.Err().Error()) // GET "/event": 400 Bad Request { ... } } ``` @@ -196,7 +199,7 @@ To set a per-retry timeout, use `option.WithRequestTimeout()`. // This sets the timeout for the request, including all the retries. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() -client.Event.List( +client.Event.ListStreaming( ctx, // This sets the per-retry timeout option.WithRequestTimeout(20*time.Second), @@ -231,7 +234,7 @@ client := opencode.NewClient( ) // Override per-request: -client.Event.List(context.TODO(), option.WithMaxRetries(5)) +client.Event.ListStreaming(context.TODO(), option.WithMaxRetries(5)) ``` ### Accessing raw response data (e.g. response headers) @@ -242,8 +245,8 @@ you need to examine response headers, status codes, or other details. ```go // Create a variable to store the HTTP response var response *http.Response -events, err := client.Event.List(context.TODO(), option.WithResponseInto(&response)) -if err != nil { +stream := client.Event.ListStreaming(context.TODO(), option.WithResponseInto(&response)) +if stream.Err() != nil { // handle error } fmt.Printf("%+v\n", events) diff --git a/packages/tui/sdk/api.md b/packages/tui/sdk/api.md index 15bdcfa93..a48e6d7f1 100644 --- a/packages/tui/sdk/api.md +++ b/packages/tui/sdk/api.md @@ -77,15 +77,15 @@ Params Types: - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePartParam">FilePartParam</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TextPartParam">TextPartParam</a> -- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessagePartUnionParam">UserMessagePartUnionParam</a> Response Types: - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AssistantMessage">AssistantMessage</a> -- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AssistantMessagePart">AssistantMessagePart</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FilePart">FilePart</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Message">Message</a> +- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Part">Part</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a> +- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepFinishPart">StepFinishPart</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepStartPart">StepStartPart</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TextPart">TextPart</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolPart">ToolPart</a> @@ -94,7 +94,7 @@ Response Types: - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStatePending">ToolStatePending</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ToolStateRunning">ToolStateRunning</a> - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessage">UserMessage</a> -- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#UserMessagePart">UserMessagePart</a> +- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessagesResponse">SessionMessagesResponse</a> Methods: @@ -104,7 +104,7 @@ Methods: - <code title="post /session/{id}/abort">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Abort">Abort</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> - <code title="post /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Chat">Chat</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatParams">SessionChatParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AssistantMessage">AssistantMessage</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> - <code title="post /session/{id}/init">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionInitParams">SessionInitParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> -- <code title="get /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Messages">Messages</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Message">Message</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> +- <code title="get /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Messages">Messages</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessagesResponse">SessionMessagesResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> - <code title="post /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Share">Share</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> - <code title="post /session/{id}/summarize">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Summarize">Summarize</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionSummarizeParams">SessionSummarizeParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> - <code title="delete /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unshare">Unshare</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code> diff --git a/packages/tui/sdk/client_test.go b/packages/tui/sdk/client_test.go index e75d64925..62222a465 100644 --- a/packages/tui/sdk/client_test.go +++ b/packages/tui/sdk/client_test.go @@ -38,7 +38,7 @@ func TestUserAgentHeader(t *testing.T) { }, }), ) - client.Event.List(context.Background()) + client.Event.ListStreaming(context.Background()) if userAgent != fmt.Sprintf("Opencode/Go %s", internal.PackageVersion) { t.Errorf("Expected User-Agent to be correct, but got: %#v", userAgent) } @@ -61,7 +61,11 @@ func TestRetryAfter(t *testing.T) { }, }), ) - _, err := client.Event.List(context.Background()) + stream := client.Event.ListStreaming(context.Background()) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("Expected there to be a cancel error") } @@ -95,7 +99,11 @@ func TestDeleteRetryCountHeader(t *testing.T) { }), option.WithHeaderDel("X-Stainless-Retry-Count"), ) - _, err := client.Event.List(context.Background()) + stream := client.Event.ListStreaming(context.Background()) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("Expected there to be a cancel error") } @@ -124,7 +132,11 @@ func TestOverwriteRetryCountHeader(t *testing.T) { }), option.WithHeader("X-Stainless-Retry-Count", "42"), ) - _, err := client.Event.List(context.Background()) + stream := client.Event.ListStreaming(context.Background()) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("Expected there to be a cancel error") } @@ -152,7 +164,11 @@ func TestRetryAfterMs(t *testing.T) { }, }), ) - _, err := client.Event.List(context.Background()) + stream := client.Event.ListStreaming(context.Background()) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("Expected there to be a cancel error") } @@ -174,7 +190,11 @@ func TestContextCancel(t *testing.T) { ) cancelCtx, cancel := context.WithCancel(context.Background()) cancel() - _, err := client.Event.List(cancelCtx) + stream := client.Event.ListStreaming(cancelCtx) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("Expected there to be a cancel error") } @@ -193,7 +213,11 @@ func TestContextCancelDelay(t *testing.T) { ) cancelCtx, cancel := context.WithTimeout(context.Background(), 2*time.Millisecond) defer cancel() - _, err := client.Event.List(cancelCtx) + stream := client.Event.ListStreaming(cancelCtx) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("expected there to be a cancel error") } @@ -218,7 +242,11 @@ func TestContextDeadline(t *testing.T) { }, }), ) - _, err := client.Event.List(deadlineCtx) + stream := client.Event.ListStreaming(deadlineCtx) + for stream.Next() { + // ... + } + err := stream.Err() if err == nil { t.Error("expected there to be a deadline error") } diff --git a/packages/tui/sdk/event.go b/packages/tui/sdk/event.go index 8bbf636c3..9002d2aac 100644 --- a/packages/tui/sdk/event.go +++ b/packages/tui/sdk/event.go @@ -610,18 +610,14 @@ func (r eventListResponseEventMessagePartUpdatedJSON) RawJSON() string { func (r EventListResponseEventMessagePartUpdated) implementsEventListResponse() {} type EventListResponseEventMessagePartUpdatedProperties struct { - MessageID string `json:"messageID,required"` - Part AssistantMessagePart `json:"part,required"` - SessionID string `json:"sessionID,required"` - JSON eventListResponseEventMessagePartUpdatedPropertiesJSON `json:"-"` + Part Part `json:"part,required"` + JSON eventListResponseEventMessagePartUpdatedPropertiesJSON `json:"-"` } // eventListResponseEventMessagePartUpdatedPropertiesJSON contains the JSON // metadata for the struct [EventListResponseEventMessagePartUpdatedProperties] type eventListResponseEventMessagePartUpdatedPropertiesJSON struct { - MessageID apijson.Field Part apijson.Field - SessionID apijson.Field raw string ExtraFields map[string]apijson.Field } diff --git a/packages/tui/sdk/scripts/lint b/packages/tui/sdk/scripts/lint index 9f37abf28..7e03a7beb 100755 --- a/packages/tui/sdk/scripts/lint +++ b/packages/tui/sdk/scripts/lint @@ -5,7 +5,7 @@ set -e cd "$(dirname "$0")/.." echo "==> Running Go build" -go build . +go build ./... echo "==> Checking tests compile" -go test -run=^$ . +go test -run=^$ ./... diff --git a/packages/tui/sdk/session.go b/packages/tui/sdk/session.go index 6321d1ff6..e76ab7f90 100644 --- a/packages/tui/sdk/session.go +++ b/packages/tui/sdk/session.go @@ -101,7 +101,7 @@ func (r *SessionService) Init(ctx context.Context, id string, body SessionInitPa } // List messages for a session -func (r *SessionService) Messages(ctx context.Context, id string, opts ...option.RequestOption) (res *[]Message, err error) { +func (r *SessionService) Messages(ctx context.Context, id string, opts ...option.RequestOption) (res *[]SessionMessagesResponse, err error) { opts = append(r.Options[:], opts...) if id == "" { err = errors.New("missing required id parameter") @@ -152,7 +152,6 @@ type AssistantMessage struct { ID string `json:"id,required"` Cost float64 `json:"cost,required"` ModelID string `json:"modelID,required"` - Parts []AssistantMessagePart `json:"parts,required"` Path AssistantMessagePath `json:"path,required"` ProviderID string `json:"providerID,required"` Role AssistantMessageRole `json:"role,required"` @@ -171,7 +170,6 @@ type assistantMessageJSON struct { ID apijson.Field Cost apijson.Field ModelID apijson.Field - Parts apijson.Field Path apijson.Field ProviderID apijson.Field Role apijson.Field @@ -435,211 +433,23 @@ func (r AssistantMessageErrorName) IsKnown() bool { return false } -type AssistantMessagePart struct { - Type AssistantMessagePartType `json:"type,required"` - ID string `json:"id"` - Cost float64 `json:"cost"` - // This field can have the runtime type of [ToolPartState]. - State interface{} `json:"state"` - Synthetic bool `json:"synthetic"` - Text string `json:"text"` - // This field can have the runtime type of - // [AssistantMessagePartStepFinishPartTokens]. - Tokens interface{} `json:"tokens"` - Tool string `json:"tool"` - JSON assistantMessagePartJSON `json:"-"` - union AssistantMessagePartUnion -} - -// assistantMessagePartJSON contains the JSON metadata for the struct -// [AssistantMessagePart] -type assistantMessagePartJSON struct { - Type apijson.Field - ID apijson.Field - Cost apijson.Field - State apijson.Field - Synthetic apijson.Field - Text apijson.Field - Tokens apijson.Field - Tool apijson.Field - raw string - ExtraFields map[string]apijson.Field -} - -func (r assistantMessagePartJSON) RawJSON() string { - return r.raw -} - -func (r *AssistantMessagePart) UnmarshalJSON(data []byte) (err error) { - *r = AssistantMessagePart{} - err = apijson.UnmarshalRoot(data, &r.union) - if err != nil { - return err - } - return apijson.Port(r.union, &r) -} - -// AsUnion returns a [AssistantMessagePartUnion] interface which you can cast to -// the specific types for more type safety. -// -// Possible runtime types of the union are [TextPart], [ToolPart], [StepStartPart], -// [AssistantMessagePartStepFinishPart]. -func (r AssistantMessagePart) AsUnion() AssistantMessagePartUnion { - return r.union -} - -// Union satisfied by [TextPart], [ToolPart], [StepStartPart] or -// [AssistantMessagePartStepFinishPart]. -type AssistantMessagePartUnion interface { - implementsAssistantMessagePart() -} - -func init() { - apijson.RegisterUnion( - reflect.TypeOf((*AssistantMessagePartUnion)(nil)).Elem(), - "type", - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(TextPart{}), - DiscriminatorValue: "text", - }, - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(ToolPart{}), - DiscriminatorValue: "tool", - }, - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(StepStartPart{}), - DiscriminatorValue: "step-start", - }, - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(AssistantMessagePartStepFinishPart{}), - DiscriminatorValue: "step-finish", - }, - ) -} - -type AssistantMessagePartStepFinishPart struct { - Cost float64 `json:"cost,required"` - Tokens AssistantMessagePartStepFinishPartTokens `json:"tokens,required"` - Type AssistantMessagePartStepFinishPartType `json:"type,required"` - JSON assistantMessagePartStepFinishPartJSON `json:"-"` -} - -// assistantMessagePartStepFinishPartJSON contains the JSON metadata for the struct -// [AssistantMessagePartStepFinishPart] -type assistantMessagePartStepFinishPartJSON struct { - Cost apijson.Field - Tokens apijson.Field - Type apijson.Field - raw string - ExtraFields map[string]apijson.Field -} - -func (r *AssistantMessagePartStepFinishPart) UnmarshalJSON(data []byte) (err error) { - return apijson.UnmarshalRoot(data, r) -} - -func (r assistantMessagePartStepFinishPartJSON) RawJSON() string { - return r.raw -} - -func (r AssistantMessagePartStepFinishPart) implementsAssistantMessagePart() {} - -type AssistantMessagePartStepFinishPartTokens struct { - Cache AssistantMessagePartStepFinishPartTokensCache `json:"cache,required"` - Input float64 `json:"input,required"` - Output float64 `json:"output,required"` - Reasoning float64 `json:"reasoning,required"` - JSON assistantMessagePartStepFinishPartTokensJSON `json:"-"` -} - -// assistantMessagePartStepFinishPartTokensJSON contains the JSON metadata for the -// struct [AssistantMessagePartStepFinishPartTokens] -type assistantMessagePartStepFinishPartTokensJSON struct { - Cache apijson.Field - Input apijson.Field - Output apijson.Field - Reasoning apijson.Field - raw string - ExtraFields map[string]apijson.Field -} - -func (r *AssistantMessagePartStepFinishPartTokens) UnmarshalJSON(data []byte) (err error) { - return apijson.UnmarshalRoot(data, r) -} - -func (r assistantMessagePartStepFinishPartTokensJSON) RawJSON() string { - return r.raw -} - -type AssistantMessagePartStepFinishPartTokensCache struct { - Read float64 `json:"read,required"` - Write float64 `json:"write,required"` - JSON assistantMessagePartStepFinishPartTokensCacheJSON `json:"-"` -} - -// assistantMessagePartStepFinishPartTokensCacheJSON contains the JSON metadata for -// the struct [AssistantMessagePartStepFinishPartTokensCache] -type assistantMessagePartStepFinishPartTokensCacheJSON struct { - Read apijson.Field - Write apijson.Field - raw string - ExtraFields map[string]apijson.Field -} - -func (r *AssistantMessagePartStepFinishPartTokensCache) UnmarshalJSON(data []byte) (err error) { - return apijson.UnmarshalRoot(data, r) -} - -func (r assistantMessagePartStepFinishPartTokensCacheJSON) RawJSON() string { - return r.raw -} - -type AssistantMessagePartStepFinishPartType string - -const ( - AssistantMessagePartStepFinishPartTypeStepFinish AssistantMessagePartStepFinishPartType = "step-finish" -) - -func (r AssistantMessagePartStepFinishPartType) IsKnown() bool { - switch r { - case AssistantMessagePartStepFinishPartTypeStepFinish: - return true - } - return false -} - -type AssistantMessagePartType string - -const ( - AssistantMessagePartTypeText AssistantMessagePartType = "text" - AssistantMessagePartTypeTool AssistantMessagePartType = "tool" - AssistantMessagePartTypeStepStart AssistantMessagePartType = "step-start" - AssistantMessagePartTypeStepFinish AssistantMessagePartType = "step-finish" -) - -func (r AssistantMessagePartType) IsKnown() bool { - switch r { - case AssistantMessagePartTypeText, AssistantMessagePartTypeTool, AssistantMessagePartTypeStepStart, AssistantMessagePartTypeStepFinish: - return true - } - return false -} - type FilePart struct { - Mime string `json:"mime,required"` - Type FilePartType `json:"type,required"` - URL string `json:"url,required"` - Filename string `json:"filename"` - JSON filePartJSON `json:"-"` + ID string `json:"id,required"` + MessageID string `json:"messageID,required"` + Mime string `json:"mime,required"` + SessionID string `json:"sessionID,required"` + Type FilePartType `json:"type,required"` + URL string `json:"url,required"` + Filename string `json:"filename"` + JSON filePartJSON `json:"-"` } // filePartJSON contains the JSON metadata for the struct [FilePart] type filePartJSON struct { + ID apijson.Field + MessageID apijson.Field Mime apijson.Field + SessionID apijson.Field Type apijson.Field URL apijson.Field Filename apijson.Field @@ -655,7 +465,7 @@ func (r filePartJSON) RawJSON() string { return r.raw } -func (r FilePart) implementsUserMessagePart() {} +func (r FilePart) implementsPart() {} type FilePartType string @@ -672,23 +482,23 @@ func (r FilePartType) IsKnown() bool { } type FilePartParam struct { - Mime param.Field[string] `json:"mime,required"` - Type param.Field[FilePartType] `json:"type,required"` - URL param.Field[string] `json:"url,required"` - Filename param.Field[string] `json:"filename"` + ID param.Field[string] `json:"id,required"` + MessageID param.Field[string] `json:"messageID,required"` + Mime param.Field[string] `json:"mime,required"` + SessionID param.Field[string] `json:"sessionID,required"` + Type param.Field[FilePartType] `json:"type,required"` + URL param.Field[string] `json:"url,required"` + Filename param.Field[string] `json:"filename"` } func (r FilePartParam) MarshalJSON() (data []byte, err error) { return apijson.MarshalRoot(r) } -func (r FilePartParam) implementsUserMessagePartUnionParam() {} +func (r FilePartParam) implementsSessionChatParamsPartUnion() {} type Message struct { - ID string `json:"id,required"` - // This field can have the runtime type of [[]UserMessagePart], - // [[]AssistantMessagePart]. - Parts interface{} `json:"parts,required"` + ID string `json:"id,required"` Role MessageRole `json:"role,required"` SessionID string `json:"sessionID,required"` // This field can have the runtime type of [UserMessageTime], @@ -713,7 +523,6 @@ type Message struct { // messageJSON contains the JSON metadata for the struct [Message] type messageJSON struct { ID apijson.Field - Parts apijson.Field Role apijson.Field SessionID apijson.Field Time apijson.Field @@ -787,6 +596,128 @@ func (r MessageRole) IsKnown() bool { return false } +type Part struct { + ID string `json:"id,required"` + MessageID string `json:"messageID,required"` + SessionID string `json:"sessionID,required"` + Type PartType `json:"type,required"` + CallID string `json:"callID"` + Cost float64 `json:"cost"` + Filename string `json:"filename"` + Mime string `json:"mime"` + // This field can have the runtime type of [ToolPartState]. + State interface{} `json:"state"` + Synthetic bool `json:"synthetic"` + Text string `json:"text"` + // This field can have the runtime type of [TextPartTime]. + Time interface{} `json:"time"` + // This field can have the runtime type of [StepFinishPartTokens]. + Tokens interface{} `json:"tokens"` + Tool string `json:"tool"` + URL string `json:"url"` + JSON partJSON `json:"-"` + union PartUnion +} + +// partJSON contains the JSON metadata for the struct [Part] +type partJSON struct { + ID apijson.Field + MessageID apijson.Field + SessionID apijson.Field + Type apijson.Field + CallID apijson.Field + Cost apijson.Field + Filename apijson.Field + Mime apijson.Field + State apijson.Field + Synthetic apijson.Field + Text apijson.Field + Time apijson.Field + Tokens apijson.Field + Tool apijson.Field + URL apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r partJSON) RawJSON() string { + return r.raw +} + +func (r *Part) UnmarshalJSON(data []byte) (err error) { + *r = Part{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [PartUnion] interface which you can cast to the specific types +// for more type safety. +// +// Possible runtime types of the union are [TextPart], [FilePart], [ToolPart], +// [StepStartPart], [StepFinishPart]. +func (r Part) AsUnion() PartUnion { + return r.union +} + +// Union satisfied by [TextPart], [FilePart], [ToolPart], [StepStartPart] or +// [StepFinishPart]. +type PartUnion interface { + implementsPart() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*PartUnion)(nil)).Elem(), + "type", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(TextPart{}), + DiscriminatorValue: "text", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(FilePart{}), + DiscriminatorValue: "file", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ToolPart{}), + DiscriminatorValue: "tool", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(StepStartPart{}), + DiscriminatorValue: "step-start", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(StepFinishPart{}), + DiscriminatorValue: "step-finish", + }, + ) +} + +type PartType string + +const ( + PartTypeText PartType = "text" + PartTypeFile PartType = "file" + PartTypeTool PartType = "tool" + PartTypeStepStart PartType = "step-start" + PartTypeStepFinish PartType = "step-finish" +) + +func (r PartType) IsKnown() bool { + switch r { + case PartTypeText, PartTypeFile, PartTypeTool, PartTypeStepStart, PartTypeStepFinish: + return true + } + return false +} + type Session struct { ID string `json:"id,required"` Time SessionTime `json:"time,required"` @@ -885,13 +816,115 @@ func (r sessionShareJSON) RawJSON() string { return r.raw } +type StepFinishPart struct { + ID string `json:"id,required"` + Cost float64 `json:"cost,required"` + MessageID string `json:"messageID,required"` + SessionID string `json:"sessionID,required"` + Tokens StepFinishPartTokens `json:"tokens,required"` + Type StepFinishPartType `json:"type,required"` + JSON stepFinishPartJSON `json:"-"` +} + +// stepFinishPartJSON contains the JSON metadata for the struct [StepFinishPart] +type stepFinishPartJSON struct { + ID apijson.Field + Cost apijson.Field + MessageID apijson.Field + SessionID apijson.Field + Tokens apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *StepFinishPart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r stepFinishPartJSON) RawJSON() string { + return r.raw +} + +func (r StepFinishPart) implementsPart() {} + +type StepFinishPartTokens struct { + Cache StepFinishPartTokensCache `json:"cache,required"` + Input float64 `json:"input,required"` + Output float64 `json:"output,required"` + Reasoning float64 `json:"reasoning,required"` + JSON stepFinishPartTokensJSON `json:"-"` +} + +// stepFinishPartTokensJSON contains the JSON metadata for the struct +// [StepFinishPartTokens] +type stepFinishPartTokensJSON struct { + Cache apijson.Field + Input apijson.Field + Output apijson.Field + Reasoning apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *StepFinishPartTokens) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r stepFinishPartTokensJSON) RawJSON() string { + return r.raw +} + +type StepFinishPartTokensCache struct { + Read float64 `json:"read,required"` + Write float64 `json:"write,required"` + JSON stepFinishPartTokensCacheJSON `json:"-"` +} + +// stepFinishPartTokensCacheJSON contains the JSON metadata for the struct +// [StepFinishPartTokensCache] +type stepFinishPartTokensCacheJSON struct { + Read apijson.Field + Write apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *StepFinishPartTokensCache) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r stepFinishPartTokensCacheJSON) RawJSON() string { + return r.raw +} + +type StepFinishPartType string + +const ( + StepFinishPartTypeStepFinish StepFinishPartType = "step-finish" +) + +func (r StepFinishPartType) IsKnown() bool { + switch r { + case StepFinishPartTypeStepFinish: + return true + } + return false +} + type StepStartPart struct { - Type StepStartPartType `json:"type,required"` - JSON stepStartPartJSON `json:"-"` + ID string `json:"id,required"` + MessageID string `json:"messageID,required"` + SessionID string `json:"sessionID,required"` + Type StepStartPartType `json:"type,required"` + JSON stepStartPartJSON `json:"-"` } // stepStartPartJSON contains the JSON metadata for the struct [StepStartPart] type stepStartPartJSON struct { + ID apijson.Field + MessageID apijson.Field + SessionID apijson.Field Type apijson.Field raw string ExtraFields map[string]apijson.Field @@ -905,7 +938,7 @@ func (r stepStartPartJSON) RawJSON() string { return r.raw } -func (r StepStartPart) implementsAssistantMessagePart() {} +func (r StepStartPart) implementsPart() {} type StepStartPartType string @@ -922,17 +955,25 @@ func (r StepStartPartType) IsKnown() bool { } type TextPart struct { + ID string `json:"id,required"` + MessageID string `json:"messageID,required"` + SessionID string `json:"sessionID,required"` Text string `json:"text,required"` Type TextPartType `json:"type,required"` Synthetic bool `json:"synthetic"` + Time TextPartTime `json:"time"` JSON textPartJSON `json:"-"` } // textPartJSON contains the JSON metadata for the struct [TextPart] type textPartJSON struct { + ID apijson.Field + MessageID apijson.Field + SessionID apijson.Field Text apijson.Field Type apijson.Field Synthetic apijson.Field + Time apijson.Field raw string ExtraFields map[string]apijson.Field } @@ -945,9 +986,7 @@ func (r textPartJSON) RawJSON() string { return r.raw } -func (r TextPart) implementsAssistantMessagePart() {} - -func (r TextPart) implementsUserMessagePart() {} +func (r TextPart) implementsPart() {} type TextPartType string @@ -963,29 +1002,70 @@ func (r TextPartType) IsKnown() bool { return false } +type TextPartTime struct { + Start float64 `json:"start,required"` + End float64 `json:"end"` + JSON textPartTimeJSON `json:"-"` +} + +// textPartTimeJSON contains the JSON metadata for the struct [TextPartTime] +type textPartTimeJSON struct { + Start apijson.Field + End apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *TextPartTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r textPartTimeJSON) RawJSON() string { + return r.raw +} + type TextPartParam struct { - Text param.Field[string] `json:"text,required"` - Type param.Field[TextPartType] `json:"type,required"` - Synthetic param.Field[bool] `json:"synthetic"` + ID param.Field[string] `json:"id,required"` + MessageID param.Field[string] `json:"messageID,required"` + SessionID param.Field[string] `json:"sessionID,required"` + Text param.Field[string] `json:"text,required"` + Type param.Field[TextPartType] `json:"type,required"` + Synthetic param.Field[bool] `json:"synthetic"` + Time param.Field[TextPartTimeParam] `json:"time"` } func (r TextPartParam) MarshalJSON() (data []byte, err error) { return apijson.MarshalRoot(r) } -func (r TextPartParam) implementsUserMessagePartUnionParam() {} +func (r TextPartParam) implementsSessionChatParamsPartUnion() {} + +type TextPartTimeParam struct { + Start param.Field[float64] `json:"start,required"` + End param.Field[float64] `json:"end"` +} + +func (r TextPartTimeParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} type ToolPart struct { - ID string `json:"id,required"` - State ToolPartState `json:"state,required"` - Tool string `json:"tool,required"` - Type ToolPartType `json:"type,required"` - JSON toolPartJSON `json:"-"` + ID string `json:"id,required"` + CallID string `json:"callID,required"` + MessageID string `json:"messageID,required"` + SessionID string `json:"sessionID,required"` + State ToolPartState `json:"state,required"` + Tool string `json:"tool,required"` + Type ToolPartType `json:"type,required"` + JSON toolPartJSON `json:"-"` } // toolPartJSON contains the JSON metadata for the struct [ToolPart] type toolPartJSON struct { ID apijson.Field + CallID apijson.Field + MessageID apijson.Field + SessionID apijson.Field State apijson.Field Tool apijson.Field Type apijson.Field @@ -1001,7 +1081,7 @@ func (r toolPartJSON) RawJSON() string { return r.raw } -func (r ToolPart) implementsAssistantMessagePart() {} +func (r ToolPart) implementsPart() {} type ToolPartState struct { Status ToolPartStateStatus `json:"status,required"` @@ -1357,18 +1437,16 @@ func (r toolStateRunningTimeJSON) RawJSON() string { } type UserMessage struct { - ID string `json:"id,required"` - Parts []UserMessagePart `json:"parts,required"` - Role UserMessageRole `json:"role,required"` - SessionID string `json:"sessionID,required"` - Time UserMessageTime `json:"time,required"` - JSON userMessageJSON `json:"-"` + ID string `json:"id,required"` + Role UserMessageRole `json:"role,required"` + SessionID string `json:"sessionID,required"` + Time UserMessageTime `json:"time,required"` + JSON userMessageJSON `json:"-"` } // userMessageJSON contains the JSON metadata for the struct [UserMessage] type userMessageJSON struct { ID apijson.Field - Parts apijson.Field Role apijson.Field SessionID apijson.Field Time apijson.Field @@ -1420,119 +1498,82 @@ func (r userMessageTimeJSON) RawJSON() string { return r.raw } -type UserMessagePart struct { - Type UserMessagePartType `json:"type,required"` - Filename string `json:"filename"` - Mime string `json:"mime"` - Synthetic bool `json:"synthetic"` - Text string `json:"text"` - URL string `json:"url"` - JSON userMessagePartJSON `json:"-"` - union UserMessagePartUnion +type SessionMessagesResponse struct { + Info Message `json:"info,required"` + Parts []Part `json:"parts,required"` + JSON sessionMessagesResponseJSON `json:"-"` } -// userMessagePartJSON contains the JSON metadata for the struct [UserMessagePart] -type userMessagePartJSON struct { - Type apijson.Field - Filename apijson.Field - Mime apijson.Field - Synthetic apijson.Field - Text apijson.Field - URL apijson.Field +// sessionMessagesResponseJSON contains the JSON metadata for the struct +// [SessionMessagesResponse] +type sessionMessagesResponseJSON struct { + Info apijson.Field + Parts apijson.Field raw string ExtraFields map[string]apijson.Field } -func (r userMessagePartJSON) RawJSON() string { +func (r *SessionMessagesResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionMessagesResponseJSON) RawJSON() string { return r.raw } -func (r *UserMessagePart) UnmarshalJSON(data []byte) (err error) { - *r = UserMessagePart{} - err = apijson.UnmarshalRoot(data, &r.union) - if err != nil { - return err - } - return apijson.Port(r.union, &r) +type SessionChatParams struct { + MessageID param.Field[string] `json:"messageID,required"` + Mode param.Field[string] `json:"mode,required"` + ModelID param.Field[string] `json:"modelID,required"` + Parts param.Field[[]SessionChatParamsPartUnion] `json:"parts,required"` + ProviderID param.Field[string] `json:"providerID,required"` } -// AsUnion returns a [UserMessagePartUnion] interface which you can cast to the -// specific types for more type safety. -// -// Possible runtime types of the union are [TextPart], [FilePart]. -func (r UserMessagePart) AsUnion() UserMessagePartUnion { - return r.union +func (r SessionChatParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) } -// Union satisfied by [TextPart] or [FilePart]. -type UserMessagePartUnion interface { - implementsUserMessagePart() +type SessionChatParamsPart struct { + ID param.Field[string] `json:"id,required"` + MessageID param.Field[string] `json:"messageID,required"` + SessionID param.Field[string] `json:"sessionID,required"` + Type param.Field[SessionChatParamsPartsType] `json:"type,required"` + Filename param.Field[string] `json:"filename"` + Mime param.Field[string] `json:"mime"` + Synthetic param.Field[bool] `json:"synthetic"` + Text param.Field[string] `json:"text"` + Time param.Field[interface{}] `json:"time"` + URL param.Field[string] `json:"url"` } -func init() { - apijson.RegisterUnion( - reflect.TypeOf((*UserMessagePartUnion)(nil)).Elem(), - "type", - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(TextPart{}), - DiscriminatorValue: "text", - }, - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(FilePart{}), - DiscriminatorValue: "file", - }, - ) +func (r SessionChatParamsPart) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) } -type UserMessagePartType string +func (r SessionChatParamsPart) implementsSessionChatParamsPartUnion() {} + +// Satisfied by [FilePartParam], [TextPartParam], [SessionChatParamsPart]. +type SessionChatParamsPartUnion interface { + implementsSessionChatParamsPartUnion() +} + +type SessionChatParamsPartsType string const ( - UserMessagePartTypeText UserMessagePartType = "text" - UserMessagePartTypeFile UserMessagePartType = "file" + SessionChatParamsPartsTypeFile SessionChatParamsPartsType = "file" + SessionChatParamsPartsTypeText SessionChatParamsPartsType = "text" ) -func (r UserMessagePartType) IsKnown() bool { +func (r SessionChatParamsPartsType) IsKnown() bool { switch r { - case UserMessagePartTypeText, UserMessagePartTypeFile: + case SessionChatParamsPartsTypeFile, SessionChatParamsPartsTypeText: return true } return false } -type UserMessagePartParam struct { - Type param.Field[UserMessagePartType] `json:"type,required"` - Filename param.Field[string] `json:"filename"` - Mime param.Field[string] `json:"mime"` - Synthetic param.Field[bool] `json:"synthetic"` - Text param.Field[string] `json:"text"` - URL param.Field[string] `json:"url"` -} - -func (r UserMessagePartParam) MarshalJSON() (data []byte, err error) { - return apijson.MarshalRoot(r) -} - -func (r UserMessagePartParam) implementsUserMessagePartUnionParam() {} - -// Satisfied by [TextPartParam], [FilePartParam], [UserMessagePartParam]. -type UserMessagePartUnionParam interface { - implementsUserMessagePartUnionParam() -} - -type SessionChatParams struct { - Mode param.Field[string] `json:"mode,required"` - ModelID param.Field[string] `json:"modelID,required"` - Parts param.Field[[]UserMessagePartUnionParam] `json:"parts,required"` - ProviderID param.Field[string] `json:"providerID,required"` -} - -func (r SessionChatParams) MarshalJSON() (data []byte, err error) { - return apijson.MarshalRoot(r) -} - type SessionInitParams struct { + MessageID param.Field[string] `json:"messageID,required"` ModelID param.Field[string] `json:"modelID,required"` ProviderID param.Field[string] `json:"providerID,required"` } diff --git a/packages/tui/sdk/session_test.go b/packages/tui/sdk/session_test.go index 4ff2818c5..c74a4a385 100644 --- a/packages/tui/sdk/session_test.go +++ b/packages/tui/sdk/session_test.go @@ -117,12 +117,17 @@ func TestSessionChat(t *testing.T) { context.TODO(), "id", opencode.SessionChatParams{ - Mode: opencode.F("mode"), - ModelID: opencode.F("modelID"), - Parts: opencode.F([]opencode.UserMessagePartUnionParam{opencode.TextPartParam{ - Text: opencode.F("text"), - Type: opencode.F(opencode.TextPartTypeText), - Synthetic: opencode.F(true), + MessageID: opencode.F("messageID"), + Mode: opencode.F("mode"), + ModelID: opencode.F("modelID"), + Parts: opencode.F([]opencode.SessionChatParamsPartUnion{opencode.FilePartParam{ + ID: opencode.F("id"), + MessageID: opencode.F("messageID"), + Mime: opencode.F("mime"), + SessionID: opencode.F("sessionID"), + Type: opencode.F(opencode.FilePartTypeFile), + URL: opencode.F("url"), + Filename: opencode.F("filename"), }}), ProviderID: opencode.F("providerID"), }, @@ -152,6 +157,7 @@ func TestSessionInit(t *testing.T) { context.TODO(), "id", opencode.SessionInitParams{ + MessageID: opencode.F("messageID"), ModelID: opencode.F("modelID"), ProviderID: opencode.F("providerID"), }, diff --git a/packages/tui/sdk/usage_test.go b/packages/tui/sdk/usage_test.go index 0e261a7aa..5e8f44c7c 100644 --- a/packages/tui/sdk/usage_test.go +++ b/packages/tui/sdk/usage_test.go @@ -23,10 +23,13 @@ func TestUsage(t *testing.T) { client := opencode.NewClient( option.WithBaseURL(baseURL), ) - events, err := client.Event.List(context.TODO()) + stream := client.Event.ListStreaming(context.TODO()) + for stream.Next() { + t.Logf("%+v\n", stream.Current()) + } + err := stream.Err() if err != nil { t.Error(err) return } - t.Logf("%+v\n", events) } diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index ad23f188b..56f218fd8 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -1,6 +1,6 @@ -import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList } from "solid-js" +import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList, createEffect } from "solid-js" import { DateTime } from "luxon" -import { createStore, reconcile } from "solid-js/store" +import { createStore, reconcile, unwrap } from "solid-js/store" import { IconArrowDown } from "./icons" import { IconOpencode } from "./icons/custom" import styles from "./share.module.css" @@ -9,6 +9,8 @@ import type { Message } from "opencode/session/message" import type { Session } from "opencode/session/index" import { Part, ProviderIcon } from "./share/part" +type MessageWithParts = MessageV2.Info & { parts: MessageV2.Part[] } + type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting" function scrollToAnchor(id: string) { @@ -39,7 +41,7 @@ export default function Share(props: { id: string api: string info: Session.Info - messages: Record<string, MessageV2.Info> + messages: Record<string, MessageWithParts> }) { let lastScrollY = 0 let hasScrolledToAnchor = false @@ -57,10 +59,13 @@ export default function Share(props: { const [store, setStore] = createStore<{ info?: Session.Info - messages: Record<string, MessageV2.Info | Message.Info> + messages: Record<string, MessageWithParts> }>({ info: props.info, messages: props.messages }) const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))) const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"]) + createEffect(() => { + console.log(unwrap(store)) + }) onMount(() => { const apiUrl = props.api @@ -115,8 +120,22 @@ export default function Share(props: { } if (type === "message") { const [, messageID] = splits + if ("metadata" in d.content) { + d.content = fromV1(d.content) + } + d.content.parts = d.content.parts ?? store.messages[messageID]?.parts ?? [] setStore("messages", messageID, reconcile(d.content)) } + if (type === "part") { + setStore("messages", d.content.messageID, "parts", arr => { + const index = arr.findIndex((x) => x.id === d.content.id) + if (index === -1) + arr.push(d.content) + if (index > -1) + arr[index] = d.content + return [...arr] + }) + } } catch (error) { console.error("Error parsing WebSocket message:", error) } @@ -233,7 +252,7 @@ export default function Share(props: { rootDir: undefined as string | undefined, created: undefined as number | undefined, completed: undefined as number | undefined, - messages: [] as MessageV2.Info[], + messages: [] as MessageWithParts[], models: {} as Record<string, string[]>, cost: 0, tokens: { @@ -247,7 +266,7 @@ export default function Share(props: { const msgs = messages() for (let i = 0; i < msgs.length; i++) { - const msg = "metadata" in msgs[i] ? fromV1(msgs[i] as Message.Info) : (msgs[i] as MessageV2.Info) + const msg = msgs[i] result.messages.push(msg) @@ -464,9 +483,9 @@ export default function Share(props: { ) } -export function fromV1(v1: Message.Info): MessageV2.Info { +export function fromV1(v1: Message.Info): MessageWithParts { if (v1.role === "assistant") { - const result: MessageV2.Assistant = { + return { id: v1.id, sessionID: v1.metadata.sessionID, role: "assistant", @@ -482,10 +501,16 @@ export function fromV1(v1: Message.Info): MessageV2.Info { providerID: v1.metadata.assistant!.providerID, system: v1.metadata.assistant!.system, error: v1.metadata.error, - parts: v1.parts.flatMap((part): MessageV2.AssistantPart[] => { + parts: v1.parts.flatMap((part, index): MessageV2.Part[] => { + const base = { + id: index.toString(), + messageID: v1.id, + sessionID: v1.metadata.sessionID, + } if (part.type === "text") { return [ { + ...base, type: "text", text: part.text, }, @@ -494,6 +519,7 @@ export function fromV1(v1: Message.Info): MessageV2.Info { if (part.type === "step-start") { return [ { + ...base, type: "step-start", }, ] @@ -501,8 +527,9 @@ export function fromV1(v1: Message.Info): MessageV2.Info { if (part.type === "tool-invocation") { return [ { + ...base, type: "tool", - id: part.toolInvocation.toolCallId, + callID: part.toolInvocation.toolCallId, tool: part.toolInvocation.toolName, state: (() => { if (part.toolInvocation.state === "partial-call") { @@ -540,21 +567,26 @@ export function fromV1(v1: Message.Info): MessageV2.Info { return [] }), } - return result } if (v1.role === "user") { - const result: MessageV2.User = { + return { id: v1.id, sessionID: v1.metadata.sessionID, role: "user", time: { created: v1.metadata.time.created, }, - parts: v1.parts.flatMap((part): MessageV2.UserPart[] => { + parts: v1.parts.flatMap((part, index): MessageV2.Part[] => { + const base = { + id: index.toString(), + messageID: v1.id, + sessionID: v1.metadata.sessionID, + } if (part.type === "text") { return [ { + ...base, type: "text", text: part.text, }, @@ -563,6 +595,7 @@ export function fromV1(v1: Message.Info): MessageV2.Info { if (part.type === "file") { return [ { + ...base, type: "file", mime: part.mediaType, filename: part.filename, @@ -573,7 +606,6 @@ export function fromV1(v1: Message.Info): MessageV2.Info { return [] }), } - return result } throw new Error("unknown message type") |
