From f435049d36810b142f7b3ab8de24c8fd72af34f9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 22 Oct 2025 18:49:57 -0400 Subject: [PATCH 01/21] sync --- packages/opencode/src/session/summary.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index c5fd8c123..3b0807997 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -4,7 +4,6 @@ import z from "zod" import { Session } from "." import { generateText } from "ai" import { MessageV2 } from "./message-v2" -import SUMMARIZE_TURN from "./prompt/summarize-turn.txt" import { Flag } from "@/flag/flag" export namespace MessageSummary { @@ -26,12 +25,17 @@ export namespace MessageSummary { const result = await generateText({ model: small.language, + maxOutputTokens: 100, messages: [ { - role: "system", - content: SUMMARIZE_TURN, + role: "user", + content: ` + Summarize the following conversation into 2 sentences MAX explaining what happened and why + + ${JSON.stringify(MessageV2.toModelMessage(messages))} + + `, }, - ...MessageV2.toModelMessage(messages), ], }) From 3c3d2f5a6e235870bf5634925b1715b9477b266a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:51:09 -0500 Subject: [PATCH 02/21] wip: desktop work --- packages/desktop/src/pages/index.tsx | 20 ++++++++++++++------ packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/select.css | 2 ++ packages/ui/src/components/select.tsx | 2 +- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 58d479111..5dbec6439 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -315,19 +315,27 @@ export default function Page() { x} onOpenChange={(open) => setStore("fileSelectOpen", open)} onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)} > {(i) => ( -
-
+
+
- {getFilename(i)} - - {getDirectory(i)} - +
+ + {getDirectory(i)}/ + + {getFilename(i)} +
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 8d63bf0f8..36aa99a33 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -134,6 +134,7 @@ const newIcons = { "plus-small": ``, "chevron-down": ``, "arrow-up": ``, + "check-small": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 0eb7cea15..ed10cbf14 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -87,6 +87,8 @@ } [data-slot="item-indicator"] { margin-left: auto; + width: 16px; + height: 16px; } &:focus { outline: none; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 111608e28..806d7be12 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -52,7 +52,7 @@ export function Select(props: SelectProps & ButtonProps) { {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} - + )} From c2ef930d2ae05f0fc8222ad1c9a04bef233d4ecf Mon Sep 17 00:00:00 2001 From: geril07 <62308020+geril07@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:51:46 +0300 Subject: [PATCH 03/21] add option to allow agent switches to not change model (#3356) --- packages/tui/internal/app/app.go | 66 +++++++++++++------------------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 4a891f282..708b92577 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -253,22 +253,14 @@ func SetClipboard(text string) tea.Cmd { return tea.Sequence(cmds...) } -func (a *App) cycleMode(forward bool) (*App, tea.Cmd) { - if forward { - a.AgentIndex++ - if a.AgentIndex >= len(a.Agents) { - a.AgentIndex = 0 - } - } else { - a.AgentIndex-- - if a.AgentIndex < 0 { - a.AgentIndex = len(a.Agents) - 1 - } - } - if a.Agent().Mode == "subagent" { - return a.cycleMode(forward) - } +func (a *App) updateModelForNewAgent() { + singleModelEnv := os.Getenv("OPENCODE_AGENTS_SWITCH_SINGLE_MODEL") + isSingleModel := singleModelEnv == "1" || singleModelEnv == "true" + if isSingleModel { + return + } + // Set up model for the new agent modelID := a.Agent().Model.ModelID providerID := a.Agent().Model.ProviderID if modelID == "" { @@ -292,6 +284,25 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) { } } } +} + +func (a *App) cycleMode(forward bool) (*App, tea.Cmd) { + if forward { + a.AgentIndex++ + if a.AgentIndex >= len(a.Agents) { + a.AgentIndex = 0 + } + } else { + a.AgentIndex-- + if a.AgentIndex < 0 { + a.AgentIndex = len(a.Agents) - 1 + } + } + if a.Agent().Mode == "subagent" { + return a.cycleMode(forward) + } + + a.updateModelForNewAgent() a.State.Agent = a.Agent().Name a.State.UpdateAgentUsage(a.Agent().Name) @@ -380,30 +391,7 @@ func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) { } } - // Set up model for the new agent - modelID := a.Agent().Model.ModelID - providerID := a.Agent().Model.ProviderID - if modelID == "" { - if model, ok := a.State.AgentModel[a.Agent().Name]; ok { - modelID = model.ModelID - providerID = model.ProviderID - } - } - - if modelID != "" { - for _, provider := range a.Providers { - if provider.ID == providerID { - a.Provider = &provider - for _, model := range provider.Models { - if model.ID == modelID { - a.Model = &model - break - } - } - break - } - } - } + a.updateModelForNewAgent() a.State.Agent = a.Agent().Name a.State.UpdateAgentUsage(agentName) From 9def7cff2da5708f43a2bd73853297601d1cad67 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 22 Oct 2025 19:03:08 -0400 Subject: [PATCH 04/21] summary tweaks --- packages/opencode/src/session/summary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 3b0807997..f796f40e3 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -30,7 +30,7 @@ export namespace MessageSummary { { role: "user", content: ` - Summarize the following conversation into 2 sentences MAX explaining what happened and why + Summarize the following conversation into 2 sentences MAX explaining what the assistant did and why. Do not explain the user's input. ${JSON.stringify(MessageV2.toModelMessage(messages))} From 7c7ebb0a9d05ecc900595aa96f3cb83cc7f0258a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:31:36 -0500 Subject: [PATCH 05/21] feat: retry parts (#3369) --- packages/opencode/src/session/compaction.ts | 258 ++++++++++------- packages/opencode/src/session/message-v2.ts | 64 ++++- packages/opencode/src/session/prompt.ts | 275 +++++++++++-------- packages/opencode/src/session/retry.ts | 57 ++++ packages/opencode/test/session/retry.test.ts | 47 ++++ 5 files changed, 487 insertions(+), 214 deletions(-) create mode 100644 packages/opencode/src/session/retry.ts create mode 100644 packages/opencode/test/session/retry.test.ts diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 2c9349ea6..b23fa7cdd 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,4 +1,4 @@ -import { streamText, type ModelMessage, LoadAPIKeyError } from "ai" +import { streamText, type ModelMessage, LoadAPIKeyError, type StreamTextResult, type Tool as AITool } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" @@ -14,8 +14,8 @@ import { Flag } from "../flag/flag" import { Token } from "../util/token" import { Log } from "../util/log" import { SessionLock } from "./lock" -import { NamedError } from "../util/error" import { ProviderTransform } from "@/provider/transform" +import { SessionRetry } from "./retry" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -41,6 +41,7 @@ export namespace SessionCompaction { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 + const MAX_RETRIES = 10 // goes backwards through parts until there are 40_000 tokens worth of tool // calls. then erases output of previous tool calls. idea is to throw away old @@ -142,112 +143,173 @@ export namespace SessionCompaction { }, })) as MessageV2.TextPart - const stream = streamText({ - maxRetries: 10, - model: model.language, - providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options), - abortSignal: signal, - onError(error) { - log.error("stream error", { - error, - }) - }, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage(toSummarize), - { - role: "user", - content: [ - { - type: "text", - text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.", - }, - ], + const doStream = () => + streamText({ + // set to 0, we handle loop + maxRetries: 0, + model: model.language, + providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options), + abortSignal: signal, + onError(error) { + log.error("stream error", { + error, + }) }, - ], - }) + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...MessageV2.toModelMessage(toSummarize), + { + role: "user", + content: [ + { + type: "text", + text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.", + }, + ], + }, + ], + }) - try { - for await (const value of stream.fullStream) { - signal.throwIfAborted() - switch (value.type) { - case "text-delta": - part.text += value.text - if (value.providerMetadata) part.metadata = value.providerMetadata - if (part.text) await Session.updatePart(part) - continue - case "text-end": { - part.text = part.text.trimEnd() - part.time = { - start: Date.now(), - end: Date.now(), + // TODO: reduce duplication between compaction.ts & prompt.ts + const process = async ( + stream: StreamTextResult, never>, + retries: { count: number; max: number }, + ) => { + let shouldRetry = false + try { + for await (const value of stream.fullStream) { + signal.throwIfAborted() + switch (value.type) { + case "text-delta": + part.text += value.text + if (value.providerMetadata) part.metadata = value.providerMetadata + if (part.text) await Session.updatePart(part) + continue + case "text-end": { + part.text = part.text.trimEnd() + part.time = { + start: Date.now(), + end: Date.now(), + } + if (value.providerMetadata) part.metadata = value.providerMetadata + await Session.updatePart(part) + continue } - if (value.providerMetadata) part.metadata = value.providerMetadata - await Session.updatePart(part) - continue + case "finish-step": { + const usage = Session.getUsage({ + model: model.info, + usage: value.usage, + metadata: value.providerMetadata, + }) + msg.cost += usage.cost + msg.tokens = usage.tokens + await Session.updateMessage(msg) + continue + } + case "error": + throw value.error + default: + continue } - case "finish-step": { - const usage = Session.getUsage({ - model: model.info, - usage: value.usage, - metadata: value.providerMetadata, - }) - msg.cost += usage.cost - msg.tokens = usage.tokens - await Session.updateMessage(msg) - continue - } - case "error": - throw value.error - default: - continue + } + } catch (e) { + log.error("compaction error", { + error: e, + }) + const error = MessageV2.fromError(e, { providerID: input.providerID }) + if (retries.count < retries.max && MessageV2.APIError.isInstance(error) && error.data.isRetryable) { + shouldRetry = true + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID: msg.sessionID, + type: "retry", + attempt: retries.count + 1, + time: { + created: Date.now(), + }, + error, + }) + } else { + msg.error = error + Bus.publish(Session.Event.Error, { + sessionID: msg.sessionID, + error: msg.error, + }) } } - } catch (e) { - log.error("compaction error", { - error: e, - }) - switch (true) { - case e instanceof DOMException && e.name === "AbortError": - msg.error = new MessageV2.AbortedError( - { message: e.message }, - { - cause: e, - }, - ).toObject() - break - case MessageV2.OutputLengthError.isInstance(e): - msg.error = e - break - case LoadAPIKeyError.isInstance(e): - msg.error = new MessageV2.AuthError( - { - providerID: model.providerID, - message: e.message, - }, - { cause: e }, - ).toObject() - break - case e instanceof Error: - msg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() - break - default: - msg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) + + const parts = await Session.getParts(msg.id) + return { + info: msg, + parts, + shouldRetry, + } + } + + let stream = doStream() + let result = await process(stream, { + count: 0, + max: MAX_RETRIES, + }) + if (result.shouldRetry) { + for (let retry = 1; retry < MAX_RETRIES; retry++) { + const lastRetryPart = result.parts.findLast((p) => p.type === "retry") + + if (lastRetryPart) { + const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry) + + log.info("retrying with backoff", { + attempt: retry, + delayMs, + }) + + const stop = await SessionRetry.sleep(delayMs, signal) + .then(() => false) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") { + const err = new MessageV2.AbortedError( + { message: error.message }, + { + cause: error, + }, + ).toObject() + result.info.error = err + Bus.publish(Session.Event.Error, { + sessionID: result.info.sessionID, + error: result.info.error, + }) + return true + } + throw error + }) + + if (stop) break + } + + stream = doStream() + result = await process(stream, { + count: retry, + max: MAX_RETRIES, + }) + if (!result.shouldRetry) { + break + } } - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: msg.error, - }) } msg.time.completed = Date.now() - if (!msg.error || MessageV2.AbortedError.isInstance(msg.error)) { + if ( + !msg.error || + (MessageV2.AbortedError.isInstance(msg.error) && + result.parts.some((part) => part.type === "text" && part.text.length > 0)) + ) { msg.summary = true Bus.publish(Event.Compacted, { sessionID: input.sessionID, @@ -257,7 +319,7 @@ export namespace SessionCompaction { return { info: msg, - parts: [part], + parts: result.parts, } } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d0f25181f..bd3691881 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -2,7 +2,7 @@ import z from "zod/v4" import { Bus } from "../bus" import { NamedError } from "../util/error" import { Message } from "./message" -import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai" +import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" @@ -18,6 +18,17 @@ export namespace MessageV2 { message: z.string(), }), ) + export const APIError = NamedError.create( + "APIError", + z.object({ + message: z.string(), + statusCode: z.number().optional(), + isRetryable: z.boolean(), + responseHeaders: z.record(z.string(), z.string()).optional(), + responseBody: z.string().optional(), + }), + ) + export type APIError = z.infer const PartBase = z.object({ id: z.string(), @@ -130,6 +141,18 @@ export namespace MessageV2 { }) export type AgentPart = z.infer + export const RetryPart = PartBase.extend({ + type: z.literal("retry"), + attempt: z.number(), + error: APIError.Schema, + time: z.object({ + created: z.number(), + }), + }).meta({ + ref: "RetryPart", + }) + export type RetryPart = z.infer + export const StepStartPart = PartBase.extend({ type: z.literal("step-start"), snapshot: z.string().optional(), @@ -265,6 +288,7 @@ export namespace MessageV2 { SnapshotPart, PatchPart, AgentPart, + RetryPart, ]) .meta({ ref: "Part", @@ -283,6 +307,7 @@ export namespace MessageV2 { NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema, + APIError.Schema, ]) .optional(), system: z.string().array(), @@ -610,4 +635,41 @@ export namespace MessageV2 { if (i === -1) return msgs.slice() return msgs.slice(i) } + + export function fromError(e: unknown, ctx: { providerID: string }) { + switch (true) { + case e instanceof DOMException && e.name === "AbortError": + return new MessageV2.AbortedError( + { message: e.message }, + { + cause: e, + }, + ).toObject() + case MessageV2.OutputLengthError.isInstance(e): + return e + case LoadAPIKeyError.isInstance(e): + return new MessageV2.AuthError( + { + providerID: ctx.providerID, + message: e.message, + }, + { cause: e }, + ).toObject() + case APICallError.isInstance(e): + return new MessageV2.APIError( + { + message: e.message, + statusCode: e.statusCode, + isRetryable: e.isRetryable, + responseHeaders: e.responseHeaders, + responseBody: e.responseBody, + }, + { cause: e }, + ).toObject() + case e instanceof Error: + return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() + default: + return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) + } + } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bcc19005d..b49b22a52 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -17,7 +17,6 @@ import { tool, wrapLanguageModel, type StreamTextResult, - LoadAPIKeyError, stepCountIs, jsonSchema, } from "ai" @@ -28,6 +27,7 @@ import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { Plugin } from "../plugin" +import { SessionRetry } from "./retry" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" @@ -44,7 +44,6 @@ import { TaskTool } from "../tool/task" import { FileTime } from "../file/time" import { Permission } from "../permission" import { Snapshot } from "../snapshot" -import { NamedError } from "../util/error" import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" @@ -55,6 +54,7 @@ import { MessageSummary } from "./summary" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = 32_000 + const MAX_RETRIES = 10 export const Event = { Idle: Bus.event( @@ -240,93 +240,145 @@ export namespace SessionPrompt { await using _ = defer(async () => { await processor.end() }) - const stream = streamText({ - onError(error) { - log.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(input) { - const lower = input.toolCall.toolName.toLowerCase() - if (lower !== input.toolCall.toolName && tools[lower]) { - log.info("repairing tool call", { - tool: input.toolCall.toolName, - repaired: lower, + const doStream = () => + streamText({ + onError(error) { + log.error("stream error", { + error, }) + }, + async experimental_repairToolCall(input) { + const lower = input.toolCall.toolName.toLowerCase() + if (lower !== input.toolCall.toolName && tools[lower]) { + log.info("repairing tool call", { + tool: input.toolCall.toolName, + repaired: lower, + }) + return { + ...input.toolCall, + toolName: lower, + } + } return { ...input.toolCall, - toolName: lower, + input: JSON.stringify({ + tool: input.toolCall.toolName, + error: input.error.message, + }), + toolName: "invalid", } - } - return { - ...input.toolCall, - input: JSON.stringify({ - tool: input.toolCall.toolName, - error: input.error.message, - }), - toolName: "invalid", - } - }, - headers: - model.providerID === "opencode" - ? { - "x-opencode-session": input.sessionID, - "x-opencode-request": userMsg.info.id, - } - : undefined, - maxRetries: 10, - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - maxOutputTokens: ProviderTransform.maxOutputTokens( - model.providerID, - params.options, - model.info.limit.output, - OUTPUT_TOKEN_MAX, - ), - abortSignal: abort.signal, - providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options), - stopWhen: stepCountIs(1), - temperature: params.temperature, - topP: params.topP, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - - return false - }), - ), - ], - tools: model.info.tool_call === false ? undefined : tools, - model: wrapLanguageModel({ - model: model.language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) + }, + headers: + model.providerID === "opencode" + ? { + "x-opencode-session": input.sessionID, + "x-opencode-request": userMsg.info.id, } - return args.params - }, - }, + : undefined, + // set to 0, we handle loop + maxRetries: 0, + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + maxOutputTokens: ProviderTransform.maxOutputTokens( + model.providerID, + params.options, + model.info.limit.output, + OUTPUT_TOKEN_MAX, + ), + abortSignal: abort.signal, + providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options), + stopWhen: stepCountIs(1), + temperature: params.temperature, + topP: params.topP, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...MessageV2.toModelMessage( + msgs.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + + return false + }), + ), ], - }), + tools: model.info.tool_call === false ? undefined : tools, + model: wrapLanguageModel({ + model: model.language, + middleware: [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) + } + return args.params + }, + }, + ], + }), + }) + + let stream = doStream() + let result = await processor.process(stream, { + count: 0, + max: MAX_RETRIES, }) - const result = await processor.process(stream) + if (result.shouldRetry) { + for (let retry = 1; retry < MAX_RETRIES; retry++) { + const lastRetryPart = result.parts.findLast((p) => p.type === "retry") + + if (lastRetryPart) { + const delayMs = SessionRetry.getRetryDelayInMs(lastRetryPart.error, retry) + + log.info("retrying with backoff", { + attempt: retry, + delayMs, + }) + + const stop = await SessionRetry.sleep(delayMs, abort.signal) + .then(() => false) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") { + const err = new MessageV2.AbortedError( + { message: error.message }, + { + cause: error, + }, + ).toObject() + result.info.error = err + Bus.publish(Session.Event.Error, { + sessionID: result.info.sessionID, + error: result.info.error, + }) + return true + } + throw error + }) + + if (stop) break + } + + stream = doStream() + result = await processor.process(stream, { + count: retry, + max: MAX_RETRIES, + }) + if (!result.shouldRetry) { + break + } + } + } await processor.end() const queued = state().queued.get(input.sessionID) ?? [] @@ -959,9 +1011,10 @@ export namespace SessionPrompt { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(stream: StreamTextResult, never>) { + async process(stream: StreamTextResult, never>, retries: { count: number; max: number }) { log.info("process") if (!assistantMsg) throw new Error("call next() first before processing") + let shouldRetry = false try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} @@ -1314,37 +1367,27 @@ export namespace SessionPrompt { log.error("process", { error: e, }) - switch (true) { - case e instanceof DOMException && e.name === "AbortError": - assistantMsg.error = new MessageV2.AbortedError( - { message: e.message }, - { - cause: e, - }, - ).toObject() - break - case MessageV2.OutputLengthError.isInstance(e): - assistantMsg.error = e - break - case LoadAPIKeyError.isInstance(e): - assistantMsg.error = new MessageV2.AuthError( - { - providerID: input.providerID, - message: e.message, - }, - { cause: e }, - ).toObject() - break - case e instanceof Error: - assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() - break - default: - assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) + const error = MessageV2.fromError(e, { providerID: input.providerID }) + if (retries.count < retries.max && MessageV2.APIError.isInstance(error) && error.data.isRetryable) { + shouldRetry = true + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID: assistantMsg.sessionID, + type: "retry", + attempt: retries.count + 1, + time: { + created: Date.now(), + }, + error, + }) + } else { + assistantMsg.error = error + Bus.publish(Session.Event.Error, { + sessionID: assistantMsg.sessionID, + error: assistantMsg.error, + }) } - Bus.publish(Session.Event.Error, { - sessionID: assistantMsg.sessionID, - error: assistantMsg.error, - }) } const p = await Session.getParts(assistantMsg.id) for (const part of p) { @@ -1363,9 +1406,11 @@ export namespace SessionPrompt { }) } } - assistantMsg.time.completed = Date.now() + if (!shouldRetry) { + assistantMsg.time.completed = Date.now() + } await Session.updateMessage(assistantMsg) - return { info: assistantMsg, parts: p, blocked } + return { info: assistantMsg, parts: p, blocked, shouldRetry } }, } return result diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts new file mode 100644 index 000000000..30f2035b4 --- /dev/null +++ b/packages/opencode/src/session/retry.ts @@ -0,0 +1,57 @@ +import { MessageV2 } from "./message-v2" + +export namespace SessionRetry { + export const RETRY_INITIAL_DELAY = 2000 + export const RETRY_BACKOFF_FACTOR = 2 + + export async function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms) + signal.addEventListener( + "abort", + () => { + clearTimeout(timeout) + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + }) + } + + export function getRetryDelayInMs(error: MessageV2.APIError, attempt: number): number { + const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) + const headers = error.data.responseHeaders + if (!headers) return base + + const retryAfterMs = headers["retry-after-ms"] + if (retryAfterMs) { + const parsed = Number.parseFloat(retryAfterMs) + const normalized = normalizeDelay({ base, candidate: parsed }) + if (normalized != null) return normalized + } + + const retryAfter = headers["retry-after"] + if (!retryAfter) return base + + const seconds = Number.parseFloat(retryAfter) + if (!Number.isNaN(seconds)) { + const normalized = normalizeDelay({ base, candidate: seconds * 1000 }) + if (normalized != null) return normalized + return base + } + + const dateMs = Date.parse(retryAfter) - Date.now() + const normalized = normalizeDelay({ base, candidate: dateMs }) + if (normalized != null) return normalized + + return base + } + + function normalizeDelay(input: { base: number; candidate: number }): number | undefined { + if (Number.isNaN(input.candidate)) return undefined + if (input.candidate < 0) return undefined + if (input.candidate < 60_000) return input.candidate + if (input.candidate < input.base) return input.candidate + return undefined + } +} diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts new file mode 100644 index 000000000..edce412c2 --- /dev/null +++ b/packages/opencode/test/session/retry.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { SessionRetry } from "../../src/session/retry" +import { MessageV2 } from "../../src/session/message-v2" + +function apiError(headers?: Record): MessageV2.APIError { + return new MessageV2.APIError({ + message: "boom", + isRetryable: true, + responseHeaders: headers, + }).toObject() as MessageV2.APIError +} + +describe("session.retry.getRetryDelayInMs", () => { + test("doubles delay on each attempt when headers missing", () => { + const error = apiError() + const delays = Array.from({ length: 7 }, (_, index) => SessionRetry.getRetryDelayInMs(error, index + 1)) + expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 32000, 64000, 128000]) + }) + + test("prefers retry-after-ms when shorter than exponential", () => { + const error = apiError({ "retry-after-ms": "1500" }) + expect(SessionRetry.getRetryDelayInMs(error, 4)).toBe(1500) + }) + + test("uses retry-after seconds when reasonable", () => { + const error = apiError({ "retry-after": "30" }) + expect(SessionRetry.getRetryDelayInMs(error, 3)).toBe(30000) + }) + + test("falls back to exponential when server delay is long", () => { + const error = apiError({ "retry-after": "120" }) + expect(SessionRetry.getRetryDelayInMs(error, 2)).toBe(4000) + }) + + test("accepts http-date retry-after values", () => { + const date = new Date(Date.now() + 20000).toUTCString() + const error = apiError({ "retry-after": date }) + const delay = SessionRetry.getRetryDelayInMs(error, 1) + expect(delay).toBeGreaterThanOrEqual(19000) + expect(delay).toBeLessThanOrEqual(20000) + }) + + test("ignores invalid retry hints", () => { + const error = apiError({ "retry-after": "not-a-number" }) + expect(SessionRetry.getRetryDelayInMs(error, 1)).toBe(2000) + }) +}) From 61899d4fa7ad76431853bcf21ef0a88f73ec35a6 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 22 Oct 2025 23:00:03 -0500 Subject: [PATCH 06/21] regen sdk --- packages/sdk/js/src/gen/types.gen.ts | 35 ++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 169f7bb4a..46aa2475e 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -598,6 +598,10 @@ export type UserMessage = { time: { created: number } + summary?: { + diffs: Array + text: string + } } export type ProviderAuthError = { @@ -629,6 +633,19 @@ export type MessageAbortedError = { } } +export type ApiError = { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + } +} + export type AssistantMessage = { id: string sessionID: string @@ -637,8 +654,9 @@ export type AssistantMessage = { created: number completed?: number } - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError system: Array + parentID: string modelID: string providerID: string mode: string @@ -857,6 +875,18 @@ export type AgentPart = { } } +export type RetryPart = { + id: string + sessionID: string + messageID: string + type: "retry" + attempt: number + error: ApiError + time: { + created: number + } +} + export type Part = | TextPart | ReasoningPart @@ -867,6 +897,7 @@ export type Part = | SnapshotPart | PatchPart | AgentPart + | RetryPart export type TextPartInput = { id?: string @@ -1178,7 +1209,7 @@ export type EventSessionError = { type: "session.error" properties: { sessionID?: string - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError } } From f81e28c6736f9d3b84103fc72de6bde792f3f5c6 Mon Sep 17 00:00:00 2001 From: Yesh Yendamuri Date: Thu, 23 Oct 2025 01:43:28 -0400 Subject: [PATCH 07/21] feat: add model management to ACP sessions (#3358) --- packages/opencode/src/acp/agent.ts | 69 ++++++++++++++++++++++++++-- packages/opencode/src/acp/session.ts | 37 ++++++++++++++- packages/opencode/src/acp/types.ts | 4 ++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 130cbdce6..4f2f6dc46 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -12,6 +12,8 @@ import type { NewSessionResponse, PromptRequest, PromptResponse, + SetSessionModelRequest, + SetSessionModelResponse, } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" @@ -55,10 +57,16 @@ export class OpenCodeAgent implements Agent { async newSession(params: NewSessionRequest): Promise { this.log.info("newSession", { cwd: params.cwd, mcpServers: params.mcpServers.length }) - const session = await this.sessionManager.create(params.cwd, params.mcpServers) + const model = await this.defaultModel() + const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const availableModels = await this.availableModels() return { sessionId: session.id, + models: { + currentModelId: `${model.providerID}/${model.modelID}`, + availableModels, + }, _meta: {}, } } @@ -66,13 +74,64 @@ export class OpenCodeAgent implements Agent { async loadSession(params: LoadSessionRequest): Promise { this.log.info("loadSession", { sessionId: params.sessionId, cwd: params.cwd }) - await this.sessionManager.load(params.sessionId, params.cwd, params.mcpServers) + const defaultModel = await this.defaultModel() + const session = await this.sessionManager.load(params.sessionId, params.cwd, params.mcpServers, defaultModel) + const availableModels = await this.availableModels() + + return { + models: { + currentModelId: `${session.model.providerID}/${session.model.modelID}`, + availableModels, + }, + _meta: {}, + } + } + + async setSessionModel(params: SetSessionModelRequest): Promise { + this.log.info("setSessionModel", { sessionId: params.sessionId, modelId: params.modelId }) + + const session = this.sessionManager.get(params.sessionId) + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`) + } + + const parsed = Provider.parseModel(params.modelId) + const model = await Provider.getModel(parsed.providerID, parsed.modelID) + + this.sessionManager.setModel(session.id, { + providerID: model.providerID, + modelID: model.modelID, + }) return { _meta: {}, } } + private async defaultModel() { + const configured = this.config.defaultModel + if (configured) return configured + return Provider.defaultModel() + } + + private async availableModels() { + const providers = await Provider.list() + const entries = Object.entries(providers).sort((a, b) => { + const nameA = a[1].info.name.toLowerCase() + const nameB = b[1].info.name.toLowerCase() + if (nameA < nameB) return -1 + if (nameA > nameB) return 1 + return 0 + }) + return entries.flatMap(([providerID, provider]) => { + const models = Provider.sort(Object.values(provider.info.models)) + return models.map((model) => ({ + modelId: `${providerID}/${model.id}`, + name: `${provider.info.name}/${model.name}`, + })) + }) + } + async prompt(params: PromptRequest): Promise { this.log.info("prompt", { sessionId: params.sessionId, @@ -84,7 +143,11 @@ export class OpenCodeAgent implements Agent { throw new Error(`Session not found: ${params.sessionId}`) } - const model = this.config.defaultModel || (await Provider.defaultModel()) + const current = acpSession.model + const model = current ?? (await this.defaultModel()) + if (!current) { + this.sessionManager.setModel(acpSession.id, model) + } const parts = params.prompt.map((content) => { if (content.type === "text") { diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index c0a6db04b..3a7972590 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,14 +1,20 @@ import type { McpServer } from "@agentclientprotocol/sdk" import { Identifier } from "../id/id" import { Session } from "../session" +import { Provider } from "../provider/provider" import type { ACPSessionState } from "./types" export class ACPSessionManager { private sessions = new Map() - async create(cwd: string, mcpServers: McpServer[]): Promise { + async create( + cwd: string, + mcpServers: McpServer[], + model?: ACPSessionState["model"], + ): Promise { const sessionId = `acp_${Identifier.ascending("session")}` const openCodeSession = await Session.create({ title: `ACP Session ${sessionId}` }) + const resolvedModel = model ?? (await Provider.defaultModel()) const state: ACPSessionState = { id: sessionId, @@ -16,6 +22,7 @@ export class ACPSessionManager { mcpServers, openCodeSessionId: openCodeSession.id, createdAt: new Date(), + model: resolvedModel, } this.sessions.set(sessionId, state) @@ -38,13 +45,24 @@ export class ACPSessionManager { return this.sessions.has(sessionId) } - async load(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise { + async load( + sessionId: string, + cwd: string, + mcpServers: McpServer[], + model?: ACPSessionState["model"], + ): Promise { const existing = this.sessions.get(sessionId) if (existing) { + if (!existing.model) { + const resolved = model ?? (await Provider.defaultModel()) + existing.model = resolved + this.sessions.set(sessionId, existing) + } return existing } const openCodeSession = await Session.create({ title: `ACP Session ${sessionId} (loaded)` }) + const resolvedModel = model ?? (await Provider.defaultModel()) const state: ACPSessionState = { id: sessionId, @@ -52,9 +70,24 @@ export class ACPSessionManager { mcpServers, openCodeSessionId: openCodeSession.id, createdAt: new Date(), + model: resolvedModel, } this.sessions.set(sessionId, state) return state } + + getModel(sessionId: string) { + const session = this.sessions.get(sessionId) + if (!session) return + return session.model + } + + setModel(sessionId: string, model: ACPSessionState["model"]) { + const session = this.sessions.get(sessionId) + if (!session) return + session.model = model + this.sessions.set(sessionId, session) + return session + } } diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 3f0768b8c..1bffa0197 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -6,6 +6,10 @@ export interface ACPSessionState { mcpServers: McpServer[] openCodeSessionId: string createdAt: Date + model: { + providerID: string + modelID: string + } } export interface ACPConfig { From 5e69bdbef466c17fb93d6d00b383c438409c09d8 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 23 Oct 2025 05:55:28 +0000 Subject: [PATCH 08/21] release: v0.15.14 --- bun.lock | 22 +++++++++++----------- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 14 files changed, 24 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index 057e46920..dbbeb3fbc 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -64,7 +64,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -88,7 +88,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -109,7 +109,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -148,7 +148,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -164,7 +164,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "0.15.13", + "version": "0.15.14", "bin": { "opencode": "./bin/opencode", }, @@ -227,7 +227,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -247,7 +247,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "0.15.13", + "version": "0.15.14", "devDependencies": { "@hey-api/openapi-ts": "0.81.0", "@tsconfig/node22": "catalog:", @@ -258,7 +258,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -271,7 +271,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@kobalte/core": "catalog:", "@pierre/precision-diffs": "0.0.2-alpha.1-1", @@ -294,7 +294,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index c4c4d4ef7..d941e06f0 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -7,7 +7,7 @@ "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", "build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json", "start": "vinxi start", - "version": "0.15.13" + "version": "0.15.14" }, "dependencies": { "@ibm/plex": "6.4.1", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 8deda28d3..fb5f0683a 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "0.15.13", + "version": "0.15.14", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0657c73b8..7bd55da49 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "0.15.13", + "version": "0.15.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index c029f9731..4feb117d9 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "0.15.13", + "version": "0.15.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index aacd2da34..a42925189 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "0.15.13", + "version": "0.15.14", "description": "", "type": "module", "scripts": { diff --git a/packages/function/package.json b/packages/function/package.json index b2f7deaf6..53fa245ab 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "0.15.13", + "version": "0.15.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c7c06aa2d..a1a6e76a8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.15.13", + "version": "0.15.14", "name": "opencode", "type": "module", "private": true, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0b8b6d837..31426132f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "0.15.13", + "version": "0.15.14", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1198cf64f..4534cae3f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "0.15.13", + "version": "0.15.14", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/slack/package.json b/packages/slack/package.json index 98fd50f2c..b882704c1 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "0.15.13", + "version": "0.15.14", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index c32bfb7ee..f3cbb9724 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "0.15.13", + "version": "0.15.14", "type": "module", "exports": { ".": "./src/components/index.ts", diff --git a/packages/web/package.json b/packages/web/package.json index c3be2f0a9..15e9266fe 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "0.15.13", + "version": "0.15.14", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index bca270ecd..caf4eec7d 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "0.15.13", + "version": "0.15.14", "publisher": "sst-dev", "repository": { "type": "git", From c2cf6fb90404715cd6e1a4e787e0dc0c5da43c43 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 23 Oct 2025 12:04:55 +0000 Subject: [PATCH 09/21] ignore: update download stats 2025-10-23 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 901c3fd3f..d034d2419 100644 --- a/STATS.md +++ b/STATS.md @@ -116,3 +116,4 @@ | 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) | | 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) | | 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) | +| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) | From 9ab4414aefb379c202dee59a657da52e73075dc7 Mon Sep 17 00:00:00 2001 From: Mani Sundararajan <10191300+itsrainingmani@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:25:42 -0400 Subject: [PATCH 10/21] docs: rm winget as a recommended installation method under windows (#3382) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- README.md | 2 +- packages/web/src/content/docs/index.mdx | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index f32817549..ccfee4450 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ curl -fsSL https://opencode.ai/install | bash # Package managers npm i -g opencode-ai@latest # or bun/pnpm/yarn scoop bucket add extras; scoop install extras/opencode # Windows -winget install opencode # Windows +choco install opencode # Windows brew install sst/tap/opencode # macOS and Linux paru -S opencode-bin # Arch Linux ``` diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index 05447716e..dab62d8bc 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -84,12 +84,6 @@ You can also install it with the following commands: choco install opencode ``` -- **Using WinGet** - - ```bash - winget install opencode - ``` - - **Using Scoop** ```bash From 3c7b229d8bf33cf12b6373bf5ab86072c5f5fa67 Mon Sep 17 00:00:00 2001 From: Andrew Pashynnyk <30318777+kynnyhsap@users.noreply.github.com> Date: Thu, 23 Oct 2025 18:38:55 +0300 Subject: [PATCH 11/21] fix: allow `tool.execute.after` hook to modify MCP tool output (#3381) --- packages/opencode/src/session/prompt.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b49b22a52..ee8e36775 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -588,10 +588,7 @@ export namespace SessionPrompt { }, ) const result = await execute(args, opts) - const output = result.content - .filter((x: any) => x.type === "text") - .map((x: any) => x.text) - .join("\n\n") + await Plugin.trigger( "tool.execute.after", { @@ -602,6 +599,11 @@ export namespace SessionPrompt { result, ) + const output = result.content + .filter((x: any) => x.type === "text") + .map((x: any) => x.text) + .join("\n\n") + return { title: "", metadata: {}, From e5df43f9b72fb4857df641e3e352591024764e1b Mon Sep 17 00:00:00 2001 From: Yuku Kotani Date: Fri, 24 Oct 2025 01:44:06 +0900 Subject: [PATCH 12/21] docs: Add Google Vertex AI provider documentation (#3349) --- packages/web/src/content/docs/providers.mdx | 48 +++++++++++++++------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 654e434ad..496b382be 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -119,12 +119,6 @@ To use Amazon Bedrock with OpenCode: AWS_ACCESS_KEY_ID=XXX opencode ``` - Or add it to a `.env` file in the project root. - - ```bash title=".env" - AWS_ACCESS_KEY_ID=XXX - ``` - Or add it to your bash profile. ```bash title="~/.bash_profile" @@ -225,12 +219,6 @@ Or if you already have an API key, you can select **Manually enter API Key** and AZURE_RESOURCE_NAME=XXX opencode ``` - Or add it to a `.env` file in the project root: - - ```bash title=".env" - AZURE_RESOURCE_NAME=XXX - ``` - Or add it to your bash profile: ```bash title="~/.bash_profile" @@ -422,6 +410,42 @@ Some models need to be manually enabled in your [GitHub Copilot settings](https: --- +### Google Vertex AI + +To use Google Vertex AI with OpenCode: + +1. Head over to the **Model Garden** in the Google Cloud Console and check the + models available in your region. + + :::tip + You need to have a Google Cloud project with Vertex AI API enabled. + ::: + +1. You'll need to set the following environment variables: + - `GOOGLE_VERTEX_PROJECT`: Your Google Cloud project ID + - `GOOGLE_VERTEX_REGION` (optional): The region for Vertex AI (defaults to us-east5) + - One of these authentication options: + - `GOOGLE_APPLICATION_CREDENTIALS`: Path to your service account JSON key file + - Or authenticate using gcloud CLI with `gcloud auth application-default login` + + Once you have these, set them while running opencode. + + ```bash + GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json GOOGLE_VERTEX_PROJECT=your-project-id opencode + ``` + + Or add them to your bash profile. + + ```bash title="~/.bash_profile" + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json + export GOOGLE_VERTEX_PROJECT=your-project-id + export GOOGLE_VERTEX_REGION=us-central1 + ``` + +1. Run the `/models` command to select the model you want. + +--- + ### LM Studio You can configure opencode to use local models through LM Studio. From d69e8e5528b664c59ad0d5b13cf23e218aede201 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 23 Oct 2025 11:49:53 -0500 Subject: [PATCH 13/21] docs: tweak google vertex --- packages/web/src/content/docs/providers.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 496b382be..a89550396 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -421,14 +421,14 @@ To use Google Vertex AI with OpenCode: You need to have a Google Cloud project with Vertex AI API enabled. ::: -1. You'll need to set the following environment variables: +1. Set the required environment variables: - `GOOGLE_VERTEX_PROJECT`: Your Google Cloud project ID - - `GOOGLE_VERTEX_REGION` (optional): The region for Vertex AI (defaults to us-east5) - - One of these authentication options: + - `GOOGLE_VERTEX_REGION` (optional): The region for Vertex AI (defaults to `us-east5`) + - Authentication (choose one): - `GOOGLE_APPLICATION_CREDENTIALS`: Path to your service account JSON key file - - Or authenticate using gcloud CLI with `gcloud auth application-default login` + - Authenticate using gcloud CLI: `gcloud auth application-default login` - Once you have these, set them while running opencode. + Set them while running opencode. ```bash GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json GOOGLE_VERTEX_PROJECT=your-project-id opencode From 5f8a3a574ef125a073f081c869a4d33054a2719a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 23 Oct 2025 11:53:21 -0500 Subject: [PATCH 14/21] docs: fix numbers --- packages/web/src/content/docs/providers.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index a89550396..dbf4b62de 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -421,7 +421,7 @@ To use Google Vertex AI with OpenCode: You need to have a Google Cloud project with Vertex AI API enabled. ::: -1. Set the required environment variables: +2. Set the required environment variables: - `GOOGLE_VERTEX_PROJECT`: Your Google Cloud project ID - `GOOGLE_VERTEX_REGION` (optional): The region for Vertex AI (defaults to `us-east5`) - Authentication (choose one): @@ -442,7 +442,7 @@ To use Google Vertex AI with OpenCode: export GOOGLE_VERTEX_REGION=us-central1 ``` -1. Run the `/models` command to select the model you want. +3. Run the `/models` command to select the model you want. --- From a68111ca777cb5d0d84ea141af0db20962cc4cfe Mon Sep 17 00:00:00 2001 From: Thierry Delafontaine Date: Thu, 23 Oct 2025 19:16:03 +0200 Subject: [PATCH 15/21] fix: move `zod-to-json-schema` to `dependencies` (#3387) --- bun.lock | 2 +- packages/opencode/package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index dbbeb3fbc..9d58c40ce 100644 --- a/bun.lock +++ b/bun.lock @@ -207,6 +207,7 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", + "zod-to-json-schema": "3.24.5", }, "devDependencies": { "@ai-sdk/amazon-bedrock": "2.2.10", @@ -222,7 +223,6 @@ "@typescript/native-preview": "catalog:", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", - "zod-to-json-schema": "3.24.5", }, }, "packages/plugin": { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a1a6e76a8..508b72106 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -29,7 +29,6 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:", "vscode-languageserver-types": "3.17.5", - "zod-to-json-schema": "3.24.5", "@opencode-ai/script": "workspace:*" }, "dependencies": { @@ -70,6 +69,7 @@ "web-tree-sitter": "0.22.6", "xdg-basedir": "5.1.0", "yargs": "18.0.0", - "zod": "catalog:" + "zod": "catalog:", + "zod-to-json-schema": "3.24.5" } } From 913c3ae79970c533ddf75527eab8b7008aedd2c7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 23 Oct 2025 12:44:21 -0500 Subject: [PATCH 16/21] tweak: split out title before newline --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ee8e36775..47eff2e66 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1902,7 +1902,7 @@ export namespace SessionPrompt { .then((result) => { if (result.text) return Session.update(input.session.id, (draft) => { - const cleaned = result.text.replace(/[\s\S]*?<\/think>\s*/g, "") + const cleaned = result.text.replace(/[\s\S]*?<\/think>\s*/g, "").split("\n")[0] const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned draft.title = title.trim() }) From b5f336c0eab41e21be0d4313463b3ca4c8561784 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 23 Oct 2025 12:52:08 -0500 Subject: [PATCH 17/21] test: rm flaky test --- packages/opencode/test/acp.test.ts | 112 ----------------------------- 1 file changed, 112 deletions(-) delete mode 100644 packages/opencode/test/acp.test.ts diff --git a/packages/opencode/test/acp.test.ts b/packages/opencode/test/acp.test.ts deleted file mode 100644 index 22908e2db..000000000 --- a/packages/opencode/test/acp.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { spawn } from "child_process" - -describe("ACP Server", () => { - test("initialize and shutdown", async () => { - const proc = spawn("bun", ["run", "dev", "acp"], { - cwd: process.cwd(), - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, OPENCODE: "1" }, - }) - - const encoder = new TextEncoder() - const decoder = new TextDecoder() - - let initResponse: any = null - - proc.stdout.on("data", (chunk: Buffer) => { - const lines = decoder.decode(chunk).split("\n") - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - try { - const msg = JSON.parse(trimmed) - if (msg.id === 1) initResponse = msg - } catch (e) {} - } - }) - - // Wait for server to be ready - await new Promise((resolve) => setTimeout(resolve, 500)) - - proc.stdin.write( - encoder.encode( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { protocolVersion: 1 }, - }) + "\n", - ), - ) - - await new Promise((resolve) => setTimeout(resolve, 500)) - - expect(initResponse).toBeTruthy() - expect(initResponse.result.protocolVersion).toBe(1) - expect(initResponse.result.agentCapabilities).toBeTruthy() - - proc.kill() - }, 10000) - - test("create session", async () => { - const proc = spawn("bun", ["run", "dev", "acp"], { - cwd: process.cwd(), - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, OPENCODE: "1" }, - }) - - const encoder = new TextEncoder() - const decoder = new TextDecoder() - - let sessionResponse: any = null - - proc.stdout.on("data", (chunk: Buffer) => { - const lines = decoder.decode(chunk).split("\n") - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - try { - const msg = JSON.parse(trimmed) - if (msg.id === 2) sessionResponse = msg - } catch (e) {} - } - }) - - proc.stdin.write( - encoder.encode( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { protocolVersion: 1 }, - }) + "\n", - ), - ) - - await new Promise((resolve) => setTimeout(resolve, 500)) - - proc.stdin.write( - encoder.encode( - JSON.stringify({ - jsonrpc: "2.0", - id: 2, - method: "session/new", - params: { - cwd: process.cwd(), - mcpServers: [], - }, - }) + "\n", - ), - ) - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - expect(sessionResponse).toBeTruthy() - expect(sessionResponse.result.sessionId).toBeTruthy() - - proc.kill() - }, 10000) -}) From 9b5fe10df6d2326040612d3ad7d682e2fd8fc24b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:35:09 -0500 Subject: [PATCH 18/21] add flag wildcard parsing support for bash tool (#3390) --- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/util/wildcard.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 2e456c8b3..d56b4690b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -97,7 +97,7 @@ export const BashTool = Tool.define("bash", { // always allow cd if it passes above check if (command[0] !== "cd") { - const action = Wildcard.all(node.text, permissions) + const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions) if (action === "deny") { throw new Error( `The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`, diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index d329501f2..9b595a0a9 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -25,4 +25,30 @@ export namespace Wildcard { } return result } + + export function allStructured(input: { head: string; tail: string[] }, patterns: Record) { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result = undefined + for (const [pattern, value] of sorted) { + const parts = pattern.split(/\s+/) + if (!match(input.head, parts[0])) continue + if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) { + result = value + continue + } + } + return result + } + + function matchSequence(items: string[], patterns: string[]): boolean { + if (patterns.length === 0) return true + const [pattern, ...rest] = patterns + if (pattern === "*") return matchSequence(items, rest) + for (let i = 0; i < items.length; i++) { + if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) { + return true + } + } + return false + } } From f4dfae0bb044673930684497aee6721aece48b67 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 23 Oct 2025 16:04:58 -0400 Subject: [PATCH 19/21] ignore: diff stuff --- packages/opencode/src/server/server.ts | 53 ++++---------- packages/opencode/src/session/index.ts | 43 ----------- packages/opencode/src/session/prompt.ts | 10 +-- packages/opencode/src/session/summary.ts | 93 +++++++++++++++++++----- 4 files changed, 94 insertions(+), 105 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 623cb8aff..d8c80a475 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,12 +1,6 @@ import { Log } from "../util/log" import { Bus } from "../bus" -import { - describeRoute, - generateSpecs, - validator, - resolver, - openAPIRouteHandler, -} from "hono-openapi" +import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { Hono } from "hono" import { cors } from "hono/cors" import { streamSSE } from "hono/streaming" @@ -42,6 +36,7 @@ import { MCP } from "../mcp" import { Storage } from "../storage/storage" import type { ContentfulStatusCode } from "hono/utils/http-status" import { Snapshot } from "@/snapshot" +import { MessageSummary } from "@/session/summary" const ERRORS = { 400: { @@ -73,9 +68,7 @@ const ERRORS = { } as const function errors(...codes: number[]) { - return Object.fromEntries( - codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]), - ) + return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) } export namespace Server { @@ -99,8 +92,7 @@ export namespace Server { else status = 500 return c.json(err.toObject(), { status }) } - const message = - err instanceof Error && err.stack ? err.stack : err.toString() + const message = err instanceof Error && err.stack ? err.stack : err.toString() return c.json(new NamedError.Unknown({ message }).toObject(), { status: 500, }) @@ -194,17 +186,14 @@ export namespace Server { .get( "/experimental/tool/ids", describeRoute({ - description: - "List all tool IDs (including built-in and dynamically registered)", + description: "List all tool IDs (including built-in and dynamically registered)", operationId: "tool.ids", responses: { 200: { description: "Tool IDs", content: { "application/json": { - schema: resolver( - z.array(z.string()).meta({ ref: "ToolIDs" }), - ), + schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), }, }, }, @@ -218,8 +207,7 @@ export namespace Server { .get( "/experimental/tool", describeRoute({ - description: - "List tools with JSON schema parameters for a provider/model", + description: "List tools with JSON schema parameters for a provider/model", operationId: "tool.list", responses: { 200: { @@ -260,9 +248,7 @@ export namespace Server { id: t.id, description: t.description, // Handle both Zod schemas and plain JSON schemas - parameters: (t.parameters as any)?._def - ? zodToJsonSchema(t.parameters as any) - : t.parameters, + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, })), ) }, @@ -643,19 +629,19 @@ export namespace Server { validator( "param", z.object({ - id: Session.diff.schema.shape.sessionID, + id: MessageSummary.diff.schema.shape.sessionID, }), ), validator( "query", z.object({ - messageID: Session.diff.schema.shape.messageID, + messageID: MessageSummary.diff.schema.shape.messageID, }), ), async (c) => { const query = c.req.valid("query") const params = c.req.valid("param") - const result = await Session.diff({ + const result = await MessageSummary.diff({ sessionID: params.id, messageID: query.messageID, }) @@ -1040,15 +1026,10 @@ export namespace Server { }, }), async (c) => { - const providers = await Provider.list().then((x) => - mapValues(x, (item) => item.info), - ) + const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info)) return c.json({ providers: Object.values(providers), - default: mapValues( - providers, - (item) => Provider.sort(Object.values(item.models))[0].id, - ), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), }) }, ) @@ -1243,12 +1224,8 @@ export namespace Server { validator( "json", z.object({ - service: z - .string() - .meta({ description: "Service name for the log entry" }), - level: z - .enum(["debug", "info", "error", "warn"]) - .meta({ description: "Log level" }), + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), message: z.string().meta({ description: "Log message" }), extra: z .record(z.string(), z.any()) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 23b97077b..cf3219529 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -406,47 +406,4 @@ export namespace Session { await Project.setInitialized(Instance.project.id) }, ) - - export const diff = fn( - z.object({ - sessionID: Identifier.schema("session"), - messageID: Identifier.schema("message").optional(), - }), - async (input) => { - const all = await messages(input.sessionID) - const index = !input.messageID ? 0 : all.findIndex((x) => x.info.id === input.messageID) - if (index === -1) return [] - - let from: string | undefined - let to: string | undefined - - // scan assistant messages to find earliest from and latest to - // snapshot - for (let i = index + 1; i < all.length; i++) { - const item = all[i] - - // if messageID is provided, stop at the next user message - if (input.messageID && item.info.role === "user") break - - if (!from) { - for (const part of item.parts) { - if (part.type === "step-start" && part.snapshot) { - from = part.snapshot - break - } - } - } - - for (const part of item.parts) { - if (part.type === "step-finish" && part.snapshot) { - to = part.snapshot - break - } - } - } - - if (from && to) return Snapshot.diffFull(from, to) - return [] - }, - ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 47eff2e66..3ce7ff9d3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -398,11 +398,6 @@ export namespace SessionPrompt { } state().queued.delete(input.sessionID) SessionCompaction.prune(input) - MessageSummary.summarize({ - sessionID: input.sessionID, - messageID: result.info.parentID, - providerID: model.providerID, - }) return result } } @@ -1297,6 +1292,11 @@ export namespace SessionPrompt { } snapshot = undefined } + MessageSummary.summarize({ + sessionID: input.sessionID, + messageID: assistantMsg.parentID, + providerID: assistantMsg.modelID, + }) break case "text-start": diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f796f40e3..36441ea46 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -5,6 +5,8 @@ import { Session } from "." import { generateText } from "ai" import { MessageV2 } from "./message-v2" import { Flag } from "@/flag/flag" +import { Identifier } from "@/id/id" +import { Snapshot } from "@/snapshot" export namespace MessageSummary { export const summarize = fn( @@ -14,37 +16,90 @@ export namespace MessageSummary { providerID: z.string(), }), async (input) => { - if (!Flag.OPENCODE_EXPERIMENTAL_TURN_SUMMARY) return const messages = await Session.messages(input.sessionID).then((msgs) => msgs.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ), ) - const small = await Provider.getSmallModel(input.providerID) - if (!small) return - - const result = await generateText({ - model: small.language, - maxOutputTokens: 100, - messages: [ - { - role: "user", - content: ` + const userMsg = messages.find((m) => m.info.id === input.messageID)! + const diffs = await computeDiff({ messages }) + userMsg.info.summary = { + diffs, + text: "", + } + if ( + Flag.OPENCODE_EXPERIMENTAL_TURN_SUMMARY && + messages.every((m) => m.info.role !== "assistant" || m.info.time.completed) + ) { + const small = await Provider.getSmallModel(input.providerID) + if (!small) return + const result = await generateText({ + model: small.language, + maxOutputTokens: 100, + messages: [ + { + role: "user", + content: ` Summarize the following conversation into 2 sentences MAX explaining what the assistant did and why. Do not explain the user's input. ${JSON.stringify(MessageV2.toModelMessage(messages))} `, - }, - ], - }) - - const userMsg = messages.find((m) => m.info.id === input.messageID)! - userMsg.info.summary = { - text: result.text, - diffs: [], + }, + ], + }) + userMsg.info.summary = { + text: result.text, + diffs: [], + } } await Session.updateMessage(userMsg.info) }, ) + + export const diff = fn( + z.object({ + sessionID: Identifier.schema("session"), + messageID: Identifier.schema("message").optional(), + }), + async (input) => { + let all = await Session.messages(input.sessionID) + if (input.messageID) + all = all.filter( + (x) => x.info.id === input.messageID || (x.info.role === "assistant" && x.info.parentID === input.messageID), + ) + + return computeDiff({ + messages: all, + }) + }, + ) + + async function computeDiff(input: { messages: MessageV2.WithParts[] }) { + let from: string | undefined + let to: string | undefined + + // scan assistant messages to find earliest from and latest to + // snapshot + for (const item of input.messages) { + if (!from) { + for (const part of item.parts) { + if (part.type === "step-start" && part.snapshot) { + from = part.snapshot + break + } + } + } + + for (const part of item.parts) { + if (part.type === "step-finish" && part.snapshot) { + to = part.snapshot + break + } + } + } + + if (from && to) return Snapshot.diffFull(from, to) + return [] + } } From cee7106054e36bc4cd7197e28fea953afd4d0e48 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 23 Oct 2025 16:28:20 -0400 Subject: [PATCH 20/21] session summaries in data --- packages/opencode/src/server/server.ts | 8 +-- packages/opencode/src/session/index.ts | 5 ++ packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/summary.ts | 82 ++++++++++++++---------- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d8c80a475..8d10ef873 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -36,7 +36,7 @@ import { MCP } from "../mcp" import { Storage } from "../storage/storage" import type { ContentfulStatusCode } from "hono/utils/http-status" import { Snapshot } from "@/snapshot" -import { MessageSummary } from "@/session/summary" +import { SessionSummary } from "@/session/summary" const ERRORS = { 400: { @@ -629,19 +629,19 @@ export namespace Server { validator( "param", z.object({ - id: MessageSummary.diff.schema.shape.sessionID, + id: SessionSummary.diff.schema.shape.sessionID, }), ), validator( "query", z.object({ - messageID: MessageSummary.diff.schema.shape.messageID, + messageID: SessionSummary.diff.schema.shape.messageID, }), ), async (c) => { const query = c.req.valid("query") const params = c.req.valid("param") - const result = await MessageSummary.diff({ + const result = await SessionSummary.diff({ sessionID: params.id, messageID: query.messageID, }) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index cf3219529..64f64082e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -36,6 +36,11 @@ export namespace Session { projectID: z.string(), directory: z.string(), parentID: Identifier.schema("session").optional(), + summary: z + .object({ + diffs: Snapshot.FileDiff.array(), + }) + .optional(), share: z .object({ url: z.string(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3ce7ff9d3..3fb7d85ba 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,7 +49,7 @@ import { spawn } from "child_process" import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" -import { MessageSummary } from "./summary" +import { SessionSummary } from "./summary" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -1292,7 +1292,7 @@ export namespace SessionPrompt { } snapshot = undefined } - MessageSummary.summarize({ + SessionSummary.summarize({ sessionID: input.sessionID, messageID: assistantMsg.parentID, providerID: assistantMsg.modelID, diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 36441ea46..6d7b59e5b 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -8,7 +8,7 @@ import { Flag } from "@/flag/flag" import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" -export namespace MessageSummary { +export namespace SessionSummary { export const summarize = fn( z.object({ sessionID: z.string(), @@ -16,46 +16,62 @@ export namespace MessageSummary { providerID: z.string(), }), async (input) => { - const messages = await Session.messages(input.sessionID).then((msgs) => - msgs.filter( - (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), - ), - ) - const userMsg = messages.find((m) => m.info.id === input.messageID)! - const diffs = await computeDiff({ messages }) - userMsg.info.summary = { + const all = await Session.messages(input.sessionID) + await Promise.all([ + summarizeSession({ sessionID: input.sessionID, messages: all }), + summarizeMessage({ messageID: input.messageID, messages: all }), + ]) + }, + ) + + async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) { + const diffs = await computeDiff({ messages: input.messages }) + await Session.update(input.sessionID, (draft) => { + draft.summary = { diffs, - text: "", } - if ( - Flag.OPENCODE_EXPERIMENTAL_TURN_SUMMARY && - messages.every((m) => m.info.role !== "assistant" || m.info.time.completed) - ) { - const small = await Provider.getSmallModel(input.providerID) - if (!small) return - const result = await generateText({ - model: small.language, - maxOutputTokens: 100, - messages: [ - { - role: "user", - content: ` + }) + } + + async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) { + const messages = input.messages.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + const userMsg = messages.find((m) => m.info.id === input.messageID)! + const diffs = await computeDiff({ messages }) + userMsg.info.summary = { + diffs, + text: "", + } + if ( + Flag.OPENCODE_EXPERIMENTAL_TURN_SUMMARY && + messages.every((m) => m.info.role !== "assistant" || m.info.time.completed) + ) { + const assistantMsg = messages.find((m) => m.info.role === "assistant")!.info as MessageV2.Assistant + const small = await Provider.getSmallModel(assistantMsg.providerID) + if (!small) return + const result = await generateText({ + model: small.language, + maxOutputTokens: 100, + messages: [ + { + role: "user", + content: ` Summarize the following conversation into 2 sentences MAX explaining what the assistant did and why. Do not explain the user's input. ${JSON.stringify(MessageV2.toModelMessage(messages))} `, - }, - ], - }) - userMsg.info.summary = { - text: result.text, - diffs: [], - } + }, + ], + }) + userMsg.info.summary = { + text: result.text, + diffs: [], } - await Session.updateMessage(userMsg.info) - }, - ) + } + await Session.updateMessage(userMsg.info) + } export const diff = fn( z.object({ From 4bd7646ccbb94562760f263f2c465addbbe04eed Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 23 Oct 2025 16:33:00 -0400 Subject: [PATCH 21/21] regen sdk --- packages/sdk/js/src/gen/types.gen.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 46aa2475e..3dd785dd8 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -534,11 +534,22 @@ export type Path = { directory: string } +export type FileDiff = { + file: string + before: string + after: string + additions: number + deletions: number +} + export type Session = { id: string projectID: string directory: string parentID?: string + summary?: { + diffs: Array + } share?: { url: string } @@ -583,14 +594,6 @@ export type Todo = { id: string } -export type FileDiff = { - file: string - before: string - after: string - additions: number - deletions: number -} - export type UserMessage = { id: string sessionID: string