From 5e6353c4351f16958f318593a01dd7ba0d85b2d0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Nov 2025 16:22:19 -0500 Subject: [PATCH 01/29] fix: add session status management and fix TypeScript errors - Add SessionStatus module for tracking session state (idle/busy/retry) - Fix SessionLock.acquire() usage in prompt.ts - Add missing agent and model fields to User messages - Fix type casting for prompt result - Update TUI to display retry status for API errors --- .../src/cli/cmd/tui/routes/session/index.tsx | 11 +- packages/opencode/src/session/message-v2.ts | 13 + packages/opencode/src/session/prompt.ts | 621 ++++++++---------- packages/opencode/src/session/status.ts | 73 ++ 4 files changed, 353 insertions(+), 365 deletions(-) create mode 100644 packages/opencode/src/session/status.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4c3dd96ff5e..f053a02548c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -902,7 +902,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) }} - + item.type === "step-finish" && item.reason === "tool-calls")) + (props.last && + (props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls") || + (props.message.error?.name === "APIError" && props.message.error.data.isRetryable))) } > {Locale.titlecase(props.message.mode)} + + {props.message.error!.data.message as string} [retrying] + { const base = { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f4dc84be583..ebc2f4cc963 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -21,13 +21,11 @@ import { jsonSchema, } from "ai" import { SessionCompaction } from "./compaction" -import { SessionLock } from "./lock" import { Instance } from "../project/instance" 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" @@ -50,8 +48,10 @@ import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" -import { Config } from "@/config/config" import { NamedError } from "@/util/error" +import { SessionStatus } from "./status" +import { SessionLock } from "./lock" +import { fn } from "@/util/fn" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -73,7 +73,6 @@ export namespace SessionPrompt { const queued = new Map< string, { - messageID: string callback: (input: MessageV2.WithParts) => void }[] >() @@ -193,312 +192,234 @@ export namespace SessionPrompt { ) return parts } - export async function prompt(input: PromptInput): Promise { + + export const prompt = fn(PromptInput, async (input) => { const l = log.clone().tag("session", input.sessionID) l.info("prompt") const session = await Session.get(input.sessionID) await SessionRevert.cleanup(session) - const userMsg = await createUserMessage(input) + await createUserMessage(input) await Session.touch(input.sessionID) - // Early return for context-only messages (no AI inference) - if (input.noReply) { - return userMsg - } + return loop(input.sessionID) + }) - if (isBusy(input.sessionID)) { + async function loop(sessionID: string) { + const abort = SessionStatus.start(sessionID) + if (!abort) { return new Promise((resolve) => { - const queue = state().queued.get(input.sessionID) ?? [] + const queue = state().queued.get(sessionID) ?? [] queue.push({ - messageID: userMsg.info.id, callback: resolve, }) - state().queued.set(input.sessionID, queue) + state().queued.set(sessionID, queue) }) } - const agent = await Agent.get(input.agent ?? "build") - const model = await resolveModel({ - agent, - model: input.model, - }).then((x) => Provider.getModel(x.providerID, x.modelID)) - - using abort = lock(input.sessionID) - const system = await resolveSystemPrompt({ - providerID: model.providerID, - modelID: model.info.id, - agent, - system: input.system, + using _ = defer(() => { + SessionStatus.end(sessionID) }) - const processor = await createProcessor({ - sessionID: input.sessionID, - model: model.info, - providerID: model.providerID, - agent: agent.name, - system, - abort: abort.signal, - }) + let step = 0 + while (true) { + // TODO: insert reminders + const msgs: MessageV2.WithParts[] = await getMessages({ + sessionID, + signal: abort, + }) + const lastUser = msgs.findLast((m) => m.info.role === "user")?.info as MessageV2.User + if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - const tools = await resolveTools({ - agent, - sessionID: input.sessionID, - modelID: model.modelID, - providerID: model.providerID, - tools: input.tools, - processor, - }) + const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant + if (lastAssistant && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) { + break + } - const params = await Plugin.trigger( - "chat.params", - { - sessionID: input.sessionID, - agent: agent.name, + step++ + const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + const agent = await Agent.get(lastUser.agent) + const processor = await createProcessor({ + userMessage: lastUser, + sessionID: sessionID, model: model.info, - provider: await Provider.getProvider(model.providerID), - message: userMsg, - }, - { - temperature: model.info.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) - : undefined, - topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), - options: { - ...ProviderTransform.options(model.providerID, model.modelID, input.sessionID), - ...model.info.options, - ...agent.options, - }, - }, - ) + providerID: model.providerID, + agent: agent.name, + abort, + }) + const system = await resolveSystemPrompt({ + providerID: model.providerID, + modelID: model.info.id, + agent, + system: lastUser.system, + }) + const tools = await resolveTools({ + agent, + sessionID, + model: lastUser.model, + tools: lastUser.tools, + processor, + }) - let step = 0 - while (true) { - const msgs: MessageV2.WithParts[] = pipe( - await getMessages({ - sessionID: input.sessionID, + const params = await Plugin.trigger( + "chat.params", + { + sessionID: sessionID, + agent: lastUser.agent, model: model.info, - providerID: model.providerID, - signal: abort.signal, - }), - (messages) => insertReminders({ messages, agent }), + provider: await Provider.getProvider(model.providerID), + message: lastUser, + }, + { + temperature: model.info.temperature + ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) + : undefined, + topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), + options: { + ...ProviderTransform.options(model.providerID, model.modelID, sessionID), + ...model.info.options, + ...agent.options, + }, + }, ) - step++ - await processor.next(msgs.findLast((m) => m.info.role === "user")?.info.id!) + if (step === 1) { - state().track( - ensureTitle({ - session, - history: msgs, - message: userMsg, - providerID: model.providerID, - modelID: model.info.id, - }), - ) SessionSummary.summarize({ - sessionID: input.sessionID, - messageID: userMsg.info.id, + sessionID: sessionID, + messageID: lastUser.id, }) } + await using _ = defer(async () => { await processor.end() }) - const doStream = () => - streamText({ - onError(error) { - log.error("stream error", { - error, + + 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, }) - }, - 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, - input: JSON.stringify({ - tool: input.toolCall.toolName, - error: input.error.message, - }), - toolName: "invalid", + toolName: lower, } - }, - headers: { - ...(model.providerID === "opencode" - ? { - "x-opencode-session": input.sessionID, - "x-opencode-request": userMsg.info.id, - } - : undefined), - ...model.info.headers, - }, - // 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, + } + return { + ...input.toolCall, + input: JSON.stringify({ + tool: input.toolCall.toolName, + error: input.error.message, + }), + toolName: "invalid", + } + }, + headers: { + ...(model.providerID === "opencode" + ? { + "x-opencode-session": sessionID, + "x-opencode-request": lastUser.id, + } + : undefined), + ...model.info.headers, + }, + // 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, + 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, + }), ), - 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 - } + ...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 - }, + 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() - const cfg = await Config.get() - const maxRetries = cfg.experimental?.chatMaxRetries ?? MAX_RETRIES - let result = await processor.process(stream, { - count: 0, - max: maxRetries, + }, + ], + }), }) - if (result.shouldRetry) { - const start = Date.now() - for (let retry = 1; retry < maxRetries; retry++) { - const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry") - - if (lastRetryPart) { - const delayMs = SessionRetry.getBoundedDelay({ - error: lastRetryPart.error, - attempt: retry, - startTime: start, - }) - if (!delayMs) { - break - } - - log.info("retrying with backoff", { - attempt: retry, - delayMs, - elapsed: Date.now() - start, - }) - - const stop = await SessionRetry.sleep(delayMs, abort.signal) - .then(() => false) - .catch((error) => { - let err = error - if (error instanceof DOMException && error.name === "AbortError") { - 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 - }) - - if (stop) break - } - - stream = doStream() - result = await processor.process(stream, { - count: retry, - max: maxRetries, - }) - if (!result.shouldRetry) { - break - } - } - } + const result = await processor.process(stream) await processor.end() - const queued = state().queued.get(input.sessionID) ?? [] - - if (!result.blocked && !result.info.error) { - if ((await stream.finishReason) === "tool-calls") { - continue - } + if (result.blocked) break + if (result.info.error) break + } - const unprocessed = queued.filter((x) => x.messageID > result.info.id) - if (unprocessed.length) { - continue - } + SessionCompaction.prune({ sessionID }) + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user") continue + const queued = state().queued.get(sessionID) ?? [] + for (const q of queued) { + q.callback(item) } - for (const item of queued) { - item.callback(result) - } - state().queued.delete(input.sessionID) - SessionCompaction.prune(input) - return result + state().queued.delete(sessionID) + return item } + throw new Error("Impossible") } - async function getMessages(input: { - sessionID: string - model: ModelsDev.Model - providerID: string - signal: AbortSignal - }) { + async function getMessages(input: { sessionID: string; signal: AbortSignal }) { let msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) - const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant") + const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant + if (!lastAssistant) return msgs + const model = await Provider.getModel(lastAssistant.providerID, lastAssistant.modelID) if ( - lastAssistant?.info.role === "assistant" && SessionCompaction.isOverflow({ - tokens: lastAssistant.info.tokens, - model: input.model, + tokens: lastAssistant.tokens, + model: model.info, }) ) { const summaryMsg = await SessionCompaction.run({ sessionID: input.sessionID, - providerID: input.providerID, - modelID: input.model.id, signal: input.signal, + modelID: lastAssistant.modelID, + providerID: lastAssistant.providerID, }) const resumeMsgID = Identifier.ascending("message") const resumeMsg = { @@ -509,6 +430,11 @@ export namespace SessionPrompt { time: { created: Date.now(), }, + model: { + providerID: lastAssistant.providerID, + modelID: lastAssistant.modelID, + }, + agent: lastAssistant.mode, }), parts: [ await Session.updatePart({ @@ -564,21 +490,27 @@ export namespace SessionPrompt { async function resolveTools(input: { agent: Agent.Info + model: { + providerID: string + modelID: string + } sessionID: string - modelID: string - providerID: string tools?: Record processor: Processor }) { const tools: Record = {} const enabledTools = pipe( input.agent.tools, - mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, input.agent)), + mergeDeep(await ToolRegistry.enabled(input.model.providerID, input.model.modelID, input.agent)), mergeDeep(input.tools ?? {}), ) - for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) { + for (const item of await ToolRegistry.tools(input.model.providerID, input.model.modelID)) { if (Wildcard.all(item.id, enabledTools) === false) continue - const schema = ProviderTransform.schema(input.providerID, input.modelID, z.toJSONSchema(item.parameters)) + const schema = ProviderTransform.schema( + input.model.providerID, + input.model.modelID, + z.toJSONSchema(item.parameters), + ) tools[item.id] = tool({ id: item.id as any, description: item.description, @@ -600,10 +532,7 @@ export namespace SessionPrompt { abort: options.abortSignal!, messageID: input.processor.message.id, callID: options.toolCallId, - extra: { - modelID: input.modelID, - providerID: input.providerID, - }, + extra: input.model, agent: input.agent.name, metadata: async (val) => { const match = input.processor.partFromToolCall(options.toolCallId) @@ -694,6 +623,7 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { + const agent = await Agent.get(input.agent ?? "build") const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), role: "user", @@ -701,6 +631,13 @@ export namespace SessionPrompt { time: { created: Date.now(), }, + tools: input.tools, + system: input.system, + agent: agent.name, + model: await resolveModel({ + model: input.model, + agent, + }), } const parts = await Promise.all( @@ -993,10 +930,10 @@ export namespace SessionPrompt { export type Processor = Awaited> async function createProcessor(input: { + userMessage: MessageV2.User sessionID: string providerID: string model: ModelsDev.Model - system: string[] agent: string abort: AbortSignal }) { @@ -1004,62 +941,45 @@ export namespace SessionPrompt { let snapshot: string | undefined let blocked = false - async function createMessage(parentID: string) { - const msg: MessageV2.Info = { - id: Identifier.ascending("message"), - parentID, - role: "assistant", - mode: input.agent, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: input.model.id, - providerID: input.providerID, - time: { - created: Date.now(), - }, - sessionID: input.sessionID, - } - await Session.updateMessage(msg) - return msg + const assistantMsg: MessageV2.Info = { + id: Identifier.ascending("message"), + parentID: input.userMessage.id, + role: "assistant", + mode: input.agent, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model.id, + providerID: input.providerID, + time: { + created: Date.now(), + }, + sessionID: input.sessionID, } - - let assistantMsg: MessageV2.Assistant | undefined + await Session.updateMessage(assistantMsg) const result = { async end() { - if (assistantMsg) { - assistantMsg.time.completed = Date.now() - await Session.updateMessage(assistantMsg) - assistantMsg = undefined - } - }, - async next(parentID: string) { - if (assistantMsg) { - throw new Error("end previous assistant message first") - } - assistantMsg = await createMessage(parentID) - return assistantMsg + assistantMsg.time.completed = Date.now() + await Session.updateMessage(assistantMsg) }, get message() { - if (!assistantMsg) throw new Error("call next() first before accessing message") return assistantMsg }, partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(stream: StreamTextResult, never>, retries: { count: number; max: number }) { + async process(stream: StreamTextResult, never>) { 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 = {} @@ -1250,6 +1170,7 @@ export namespace SessionPrompt { usage: value.usage, metadata: value.providerMetadata, }) + assistantMsg.finish = value.finishReason assistantMsg.cost += usage.cost assistantMsg.tokens = usage.tokens await Session.updatePart({ @@ -1339,26 +1260,11 @@ export namespace SessionPrompt { 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: 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, - }) - } + assistantMsg.error = error + Bus.publish(Session.Event.Error, { + sessionID: assistantMsg.sessionID, + error: assistantMsg.error, + }) } const p = await MessageV2.parts(assistantMsg.id) for (const part of p) { @@ -1377,42 +1283,14 @@ export namespace SessionPrompt { }) } } - if (!shouldRetry) { - assistantMsg.time.completed = Date.now() - } + assistantMsg.time.completed = Date.now() await Session.updateMessage(assistantMsg) - return { info: assistantMsg, parts: p, blocked, shouldRetry } + return { info: assistantMsg, parts: p, blocked } }, } return result } - function isBusy(sessionID: string) { - return SessionLock.isLocked(sessionID) - } - - function lock(sessionID: string) { - const handle = SessionLock.acquire({ - sessionID, - }) - log.info("locking", { sessionID }) - return { - signal: handle.signal, - abort: handle.abort, - async [Symbol.dispose]() { - handle[Symbol.dispose]() - log.info("unlocking", { sessionID }) - - const session = await Session.get(sessionID) - if (session.parentID) return - - Bus.publish(Event.Idle, { - sessionID, - }) - }, - } - } - export const ShellInput = z.object({ sessionID: Identifier.schema("session"), agent: z.string(), @@ -1420,11 +1298,13 @@ export namespace SessionPrompt { }) export type ShellInput = z.infer export async function shell(input: ShellInput) { - using abort = lock(input.sessionID) + using abort = SessionLock.acquire({ sessionID: input.sessionID }) const session = await Session.get(input.sessionID) if (session.revert) { SessionRevert.cleanup(session) } + const agent = await Agent.get(input.agent) + const model = await resolveModel({ agent, model: undefined }) const userMsg: MessageV2.User = { id: Identifier.ascending("message"), sessionID: input.sessionID, @@ -1432,6 +1312,11 @@ export namespace SessionPrompt { created: Date.now(), }, role: "user", + agent: input.agent, + model: { + providerID: model.providerID, + modelID: model.modelID, + }, } await Session.updateMessage(userMsg) const userPart: MessageV2.Part = { @@ -1675,7 +1560,7 @@ export namespace SessionPrompt { let result: MessageV2.WithParts if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) { - using abort = lock(input.sessionID) + using abort = SessionLock.acquire({ sessionID: input.sessionID }) const userMsg: MessageV2.User = { id: Identifier.ascending("message"), @@ -1684,6 +1569,11 @@ export namespace SessionPrompt { created: Date.now(), }, role: "user", + agent: agentName, + model: { + providerID: model.providerID, + modelID: model.modelID, + }, } await Session.updateMessage(userMsg) const userPart: MessageV2.Part = { @@ -1784,13 +1674,13 @@ export namespace SessionPrompt { result = { info: assistantMsg, parts: [toolPart] } } else { - result = await prompt({ + result = (await prompt({ sessionID: input.sessionID, messageID: input.messageID, model, agent: agentName, parts, - }) + })) as MessageV2.WithParts } Bus.publish(Command.Event.Executed, { @@ -1855,6 +1745,11 @@ export namespace SessionPrompt { time: { created: Date.now(), }, + agent: input.message.info.role === "user" ? input.message.info.agent : "build", + model: { + providerID: input.providerID, + modelID: input.modelID, + }, }, parts: input.message.parts, }, diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts new file mode 100644 index 00000000000..214cf82fc58 --- /dev/null +++ b/packages/opencode/src/session/status.ts @@ -0,0 +1,73 @@ +import { Bus } from "@/bus" +import { Instance } from "@/project/instance" +import z from "zod" + +export namespace SessionStatus { + export const Info = z + .union([ + z.object({ + type: z.literal("idle"), + }), + z.object({ + type: z.literal("retry"), + }), + z.object({ + type: z.literal("busy"), + }), + ]) + .meta({ + ref: "SessionStatus", + }) + export type Info = z.infer + + export const Event = { + Updated: Bus.event( + "session.status.updated", + z.object({ + sessionID: z.string(), + status: Info, + }), + ), + } + + export const state = Instance.state(() => { + const status: Record< + string, + { + controller: AbortController + status: Info + } + > = {} + return status + }) + + export function start(sessionID: string) { + const s = state() + if (s[sessionID]) return + const controller = new AbortController() + s[sessionID] = { + controller, + status: { + type: "busy", + }, + } + Bus.publish(Event.Updated, { sessionID, status: s[sessionID].status }) + return controller.signal + } + + export function end(sessionID: string) { + const s = state() + if (!s[sessionID]) return false + s[sessionID].controller.abort() + delete s[sessionID] + Bus.publish(Event.Updated, { sessionID, status: { type: "idle" } }) + return true + } + + export function get(sessionID: string) { + const s = state() + const match = s[sessionID] + if (!match) return { type: "idle" } + return match.status + } +} From c56d2fdb98f2de03db349817133239dc124f2b47 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Nov 2025 19:44:32 -0500 Subject: [PATCH 02/29] sync --- .opencode/opencode.json | 7 +- packages/opencode/src/session/prompt.ts | 121 +++++++++++++----------- 2 files changed, 71 insertions(+), 57 deletions(-) diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 7da874d3680..2ec720efbff 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,4 +1,9 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-openai-codex-auth"] + "plugin": ["opencode-openai-codex-auth"], + "provider": { + "opencode": { + "options": {} + } + } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ebc2f4cc963..68ff4a341b2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -195,7 +195,7 @@ export namespace SessionPrompt { export const prompt = fn(PromptInput, async (input) => { const l = log.clone().tag("session", input.sessionID) - l.info("prompt") + l.info("prompt", input) const session = await Session.get(input.sessionID) await SessionRevert.cleanup(session) @@ -224,22 +224,33 @@ export namespace SessionPrompt { let step = 0 while (true) { - // TODO: insert reminders - const msgs: MessageV2.WithParts[] = await getMessages({ + let msgs: MessageV2.WithParts[] = await getMessages({ sessionID, signal: abort, }) const lastUser = msgs.findLast((m) => m.info.role === "user")?.info as MessageV2.User if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + log.info("last user", { id: lastUser.id, model: lastUser.model }) const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant - if (lastAssistant && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) { + log.info("last assistant", { id: lastAssistant?.id }) + if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) { break } step++ const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + msgs = await checkOverflow({ + sessionID, + model: model.info, + abort, + msgs, + }) const agent = await Agent.get(lastUser.agent) + msgs = insertReminders({ + messages: msgs, + agent, + }) const processor = await createProcessor({ userMessage: lastUser, sessionID: sessionID, @@ -291,10 +302,6 @@ export namespace SessionPrompt { }) } - await using _ = defer(async () => { - await processor.end() - }) - const stream = streamText({ onError(error) { log.error("stream error", { @@ -385,8 +392,6 @@ export namespace SessionPrompt { }), }) const result = await processor.process(stream) - await processor.end() - if (result.blocked) break if (result.info.error) break } @@ -405,55 +410,63 @@ export namespace SessionPrompt { } async function getMessages(input: { sessionID: string; signal: AbortSignal }) { - let msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) - const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant - if (!lastAssistant) return msgs - const model = await Provider.getModel(lastAssistant.providerID, lastAssistant.modelID) + const msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) + return msgs + } + + async function checkOverflow(input: { + sessionID: string + msgs: MessageV2.WithParts[] + model: ModelsDev.Model + abort: AbortSignal + }) { + const lastAssistant = input.msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant + if (!lastAssistant) return input.msgs if ( - SessionCompaction.isOverflow({ + !SessionCompaction.isOverflow({ tokens: lastAssistant.tokens, - model: model.info, + model: input.model, }) - ) { - const summaryMsg = await SessionCompaction.run({ + ) + return input.msgs + // TODO: make this more efficient + const summaryMsg = await SessionCompaction.run({ + sessionID: input.sessionID, + signal: input.abort, + modelID: lastAssistant.modelID, + providerID: lastAssistant.providerID, + }) + const resumeMsgID = Identifier.ascending("message") + const resumeMsg = { + info: await Session.updateMessage({ + id: resumeMsgID, + role: "user", sessionID: input.sessionID, - signal: input.signal, - modelID: lastAssistant.modelID, - providerID: lastAssistant.providerID, - }) - const resumeMsgID = Identifier.ascending("message") - const resumeMsg = { - info: await Session.updateMessage({ - id: resumeMsgID, - role: "user", + time: { + created: Date.now(), + }, + model: { + providerID: lastAssistant.providerID, + modelID: lastAssistant.modelID, + }, + agent: lastAssistant.mode, + }), + parts: [ + await Session.updatePart({ + type: "text", sessionID: input.sessionID, + messageID: resumeMsgID, + id: Identifier.ascending("part"), + text: "Use the above summary generated from your last session to resume from where you left off.", time: { - created: Date.now(), - }, - model: { - providerID: lastAssistant.providerID, - modelID: lastAssistant.modelID, + start: Date.now(), + end: Date.now(), }, - agent: lastAssistant.mode, + synthetic: true, }), - parts: [ - await Session.updatePart({ - type: "text", - sessionID: input.sessionID, - messageID: resumeMsgID, - id: Identifier.ascending("part"), - text: "Use the above summary generated from your last session to resume from where you left off.", - time: { - start: Date.now(), - end: Date.now(), - }, - synthetic: true, - }), - ], - } - msgs = [summaryMsg, resumeMsg] + ], } - return msgs + return [summaryMsg, resumeMsg] } async function resolveModel(input: { model: PromptInput["model"]; agent: Agent.Info }) { @@ -967,10 +980,6 @@ export namespace SessionPrompt { await Session.updateMessage(assistantMsg) const result = { - async end() { - assistantMsg.time.completed = Date.now() - await Session.updateMessage(assistantMsg) - }, get message() { return assistantMsg }, @@ -1349,8 +1358,8 @@ export namespace SessionPrompt { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: "", - providerID: "", + modelID: model.modelID, + providerID: model.providerID, } await Session.updateMessage(msg) const part: MessageV2.Part = { From ddbcb005abc5edf91632c33da3fd98c42c5fdf48 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Nov 2025 19:45:53 -0500 Subject: [PATCH 03/29] fix --- 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 68ff4a341b2..84115c3759d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -209,7 +209,7 @@ export namespace SessionPrompt { async function loop(sessionID: string) { const abort = SessionStatus.start(sessionID) if (!abort) { - return new Promise((resolve) => { + return new Promise((resolve) => { const queue = state().queued.get(sessionID) ?? [] queue.push({ callback: resolve, From d6f2864622be81287cbf9f9db8814ad00509f386 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Nov 2025 21:10:26 -0500 Subject: [PATCH 04/29] sync --- .opencode/opencode.json | 4 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 12 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 52 ++++---- packages/opencode/src/server/server.ts | 25 +++- packages/opencode/src/session/index.ts | 1 - packages/opencode/src/session/prompt.ts | 119 +++++++++++++++--- packages/opencode/src/session/status.ts | 73 ----------- packages/sdk/js/src/gen/sdk.gen.ts | 13 ++ packages/sdk/js/src/gen/types.gen.ts | 59 +++++++++ 9 files changed, 236 insertions(+), 122 deletions(-) delete mode 100644 packages/opencode/src/session/status.ts diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 2ec720efbff..b2923f26959 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -3,7 +3,9 @@ "plugin": ["opencode-openai-codex-auth"], "provider": { "opencode": { - "options": {} + "options": { + "baseURL": "http://localhost:8080" + } } } } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 6899cf575b0..7fcbbc57cdc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -11,6 +11,7 @@ import type { LspStatus, McpStatus, FormatterStatus, + SessionStatus, } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -31,6 +32,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } config: Config session: Session[] + session_status: { + [sessionID: string]: SessionStatus + } session_diff: { [sessionID: string]: Snapshot.FileDiff[] } @@ -56,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ command: [], provider: [], session: [], + session_status: {}, session_diff: {}, todo: {}, message: {}, @@ -138,6 +143,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) break + + case "session.status": { + setStore("session_status", event.properties.sessionID, event.properties.status) + break + } + case "message.updated": { const messages = store.message[event.properties.info.sessionID] if (!messages) { @@ -234,6 +245,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)), sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), + sdk.client.session.status().then((x) => setStore("session_status", x.data!)), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f053a02548c..52a79218f30 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -17,7 +17,7 @@ import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" import { useTheme } from "@tui/context/theme" -import { BoxRenderable, ScrollBoxRenderable, TextAttributes, addDefaultParsers } from "@opentui/core" +import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk" import { useLocal } from "@tui/context/local" @@ -58,7 +58,6 @@ import { Editor } from "../../util/editor" import { Global } from "@/global" import fs from "fs/promises" import stripAnsi from "strip-ansi" -import { LSP } from "@/lsp/index.ts" addDefaultParsers(parsers.parsers) @@ -84,7 +83,12 @@ export function Session() { const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) const pending = createMemo(() => { - return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id + return messages().findLast((x) => x.role === "assistant")?.id + }) + + const lastUserMessage = createMemo(() => { + const p = pending() + return messages().findLast((x) => x.role === "user" && (!p || x.id < p)) as UserMessage }) const dimensions = useTerminalDimensions() @@ -634,6 +638,13 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) + const status = createMemo( + () => + sync.data.session_status[route.sessionID] ?? { + type: "idle", + }, + ) + return ( + + + {Locale.titlecase(lastUserMessage().agent)} + + + + {status().message} [retry #{status().attempt}] + + + + (prompt = r)} @@ -918,30 +940,6 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las {props.message.error?.data.message} - item.type === "step-finish" && item.reason === "tool-calls") || - (props.message.error?.name === "APIError" && props.message.error.data.isRetryable))) - } - > - - {Locale.titlecase(props.message.mode)} - - - {props.message.error!.data.message as string} [retrying] - - - { + const result = SessionPrompt.status() + return c.json(result) + }, + ) .get( "/session/:id", describeRoute({ @@ -586,7 +608,8 @@ export namespace Server { }), ), async (c) => { - return c.json(SessionLock.abort(c.req.valid("param").id)) + SessionPrompt.cancel(c.req.valid("param").id) + return c.json(true) }, ) .post( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b920ca23ca6..aa9712f881e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,7 +1,6 @@ import { Decimal } from "decimal.js" import z from "zod" import { type LanguageModelUsage, type ProviderMetadata } from "ai" - import { Bus } from "../bus" import { Config } from "../config/config" import { Flag } from "../flag/flag" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 84115c3759d..f50b9df43ca 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,9 +49,9 @@ import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@/util/error" -import { SessionStatus } from "./status" import { SessionLock } from "./lock" import { fn } from "@/util/fn" +import { SessionRetry } from "./retry" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -59,7 +59,33 @@ export namespace SessionPrompt { const MAX_RETRIES = 10 const DOOM_LOOP_THRESHOLD = 3 + export const Status = z + .union([ + z.object({ + type: z.literal("idle"), + }), + z.object({ + type: z.literal("retry"), + attempt: z.number(), + message: z.string(), + }), + z.object({ + type: z.literal("busy"), + }), + ]) + .meta({ + ref: "SessionStatus", + }) + export type Status = z.infer + export const Event = { + Status: Bus.event( + "session.status", + z.object({ + sessionID: z.string(), + status: Status, + }), + ), Idle: Bus.event( "session.idle", z.object({ @@ -77,6 +103,8 @@ export namespace SessionPrompt { }[] >() const pending = new Set>() + const status: Record = {} + const abort: Record = {} const track = (promise: Promise) => { pending.add(promise) @@ -84,6 +112,8 @@ export namespace SessionPrompt { } return { + status, + abort, queued, pending, track, @@ -92,9 +122,26 @@ export namespace SessionPrompt { async (current) => { current.queued.clear() await Promise.allSettled([...current.pending]) + for (const item of Object.values(current.abort)) { + item.abort() + } }, ) + export function status() { + return state().status + } + + export const setStatus = fn(z.object({ sessionID: Identifier.schema("session"), status: Status }), (input) => { + Bus.publish(Event.Status, { sessionID: input.sessionID, status: input.status }) + if (input.status.type === "idle") { + delete state().status[input.sessionID] + delete state().abort[input.sessionID] + return + } + state().status[input.sessionID] = input.status + }) + export const PromptInput = z.object({ sessionID: Identifier.schema("session"), messageID: Identifier.schema("message").optional(), @@ -206,8 +253,31 @@ export namespace SessionPrompt { return loop(input.sessionID) }) + function start(sessionID: string) { + const s = state() + if (s.status[sessionID]) return + const controller = new AbortController() + s.abort[sessionID] = controller + setStatus({ sessionID, status: { type: "busy" } }) + return controller.signal + } + + export function cancel(sessionID: string) { + const s = state() + const signal = s.abort[sessionID] + const status = s.status[sessionID] + if (signal) { + signal.abort() + delete s.abort[sessionID] + } + if (status) { + setStatus({ sessionID, status: { type: "idle" } }) + } + return + } + async function loop(sessionID: string) { - const abort = SessionStatus.start(sessionID) + const abort = start(sessionID) if (!abort) { return new Promise((resolve) => { const queue = state().queued.get(sessionID) ?? [] @@ -218,27 +288,21 @@ export namespace SessionPrompt { }) } - using _ = defer(() => { - SessionStatus.end(sessionID) - }) + using _ = defer(() => cancel(sessionID)) let step = 0 + let retries = 0 while (true) { - let msgs: MessageV2.WithParts[] = await getMessages({ - sessionID, - signal: abort, - }) + let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) const lastUser = msgs.findLast((m) => m.info.role === "user")?.info as MessageV2.User if (!lastUser) throw new Error("No user message found in stream. This should never happen.") log.info("last user", { id: lastUser.id, model: lastUser.model }) const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant log.info("last assistant", { id: lastAssistant?.id }) - if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) { - break - } - + if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) break step++ + const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) msgs = await checkOverflow({ sessionID, @@ -393,9 +457,30 @@ export namespace SessionPrompt { }) const result = await processor.process(stream) if (result.blocked) break + if (result.info.error?.name === "APIError" && result.info.error.data.isRetryable) { + retries++ + const delay = SessionRetry.getRetryDelayInMs(result.info.error, retries) + if (!delay) break + setStatus({ + sessionID, + status: { + type: "retry", + attempt: retries, + message: result.info.error.data.message, + }, + }) + await SessionRetry.sleep(delay, abort).catch(() => {}) + setStatus({ + sessionID, + status: { + type: "busy", + }, + }) + continue + } + retries = 0 if (result.info.error) break } - SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user") continue @@ -409,11 +494,6 @@ export namespace SessionPrompt { throw new Error("Impossible") } - async function getMessages(input: { sessionID: string; signal: AbortSignal }) { - const msgs = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) - return msgs - } - async function checkOverflow(input: { sessionID: string msgs: MessageV2.WithParts[] @@ -1702,6 +1782,7 @@ export namespace SessionPrompt { return result } + // TODO: wire this back up async function ensureTitle(input: { session: Session.Info message: MessageV2.WithParts diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts deleted file mode 100644 index 214cf82fc58..00000000000 --- a/packages/opencode/src/session/status.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Bus } from "@/bus" -import { Instance } from "@/project/instance" -import z from "zod" - -export namespace SessionStatus { - export const Info = z - .union([ - z.object({ - type: z.literal("idle"), - }), - z.object({ - type: z.literal("retry"), - }), - z.object({ - type: z.literal("busy"), - }), - ]) - .meta({ - ref: "SessionStatus", - }) - export type Info = z.infer - - export const Event = { - Updated: Bus.event( - "session.status.updated", - z.object({ - sessionID: z.string(), - status: Info, - }), - ), - } - - export const state = Instance.state(() => { - const status: Record< - string, - { - controller: AbortController - status: Info - } - > = {} - return status - }) - - export function start(sessionID: string) { - const s = state() - if (s[sessionID]) return - const controller = new AbortController() - s[sessionID] = { - controller, - status: { - type: "busy", - }, - } - Bus.publish(Event.Updated, { sessionID, status: s[sessionID].status }) - return controller.signal - } - - export function end(sessionID: string) { - const s = state() - if (!s[sessionID]) return false - s[sessionID].controller.abort() - delete s[sessionID] - Bus.publish(Event.Updated, { sessionID, status: { type: "idle" } }) - return true - } - - export function get(sessionID: string) { - const s = state() - const match = s[sessionID] - if (!match) return { type: "idle" } - return match.status - } -} diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 6987eb4716a..04186efcef0 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -24,6 +24,9 @@ import type { SessionCreateData, SessionCreateResponses, SessionCreateErrors, + SessionStatusData, + SessionStatusResponses, + SessionStatusErrors, SessionDeleteData, SessionDeleteResponses, SessionDeleteErrors, @@ -292,6 +295,16 @@ class Session extends _HeyApiClient { }) } + /** + * Get session status + */ + public status(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/session/status", + ...options, + }) + } + /** * Delete a session and all its data */ diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 3e64fc9a01a..941521928db 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -564,6 +564,17 @@ export type Session = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + } + | { + type: "busy" + } + export type NotFoundError = { name: "NotFoundError" data: { @@ -602,6 +613,15 @@ export type UserMessage = { body?: string diffs: Array } + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: { + [key: string]: boolean + } } export type ProviderAuthError = { @@ -674,6 +694,7 @@ export type AssistantMessage = { write: number } } + finish?: string } export type Message = UserMessage | AssistantMessage @@ -1279,6 +1300,14 @@ export type EventCommandExecuted = { } } +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + export type EventSessionIdle = { type: "session.idle" properties: { @@ -1352,6 +1381,7 @@ export type Event = | EventFileEdited | EventTodoUpdated | EventCommandExecuted + | EventSessionStatus | EventSessionIdle | EventSessionCreated | EventSessionUpdated @@ -1567,6 +1597,35 @@ export type SessionCreateResponses = { export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] +export type SessionStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/session/status" +} + +export type SessionStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] + +export type SessionStatusResponses = { + /** + * Get session status + */ + 200: { + [key: string]: SessionStatus + } +} + +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] + export type SessionDeleteData = { body?: never path: { From 7718aec891bfd69d2cb44a49f352f21b13d186b7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Nov 2025 21:56:21 -0500 Subject: [PATCH 05/29] sync --- .opencode/command/commit.md | 1 + .opencode/opencode.json | 4 +- packages/opencode/src/server/server.ts | 1 - packages/opencode/src/session/compaction.ts | 2 - packages/opencode/src/session/lock.ts | 97 ------------ packages/opencode/src/session/prompt.ts | 156 ++++---------------- packages/opencode/src/session/revert.ts | 13 +- packages/opencode/src/tool/task.ts | 3 +- 8 files changed, 31 insertions(+), 246 deletions(-) delete mode 100644 packages/opencode/src/session/lock.ts diff --git a/.opencode/command/commit.md b/.opencode/command/commit.md index 2e3d759b654..9626f172cf9 100644 --- a/.opencode/command/commit.md +++ b/.opencode/command/commit.md @@ -1,5 +1,6 @@ --- description: Git commit and push +subtask: true --- commit and push diff --git a/.opencode/opencode.json b/.opencode/opencode.json index b2923f26959..2ec720efbff 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -3,9 +3,7 @@ "plugin": ["opencode-openai-codex-auth"], "provider": { "opencode": { - "options": { - "baseURL": "http://localhost:8080" - } + "options": {} } } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 211d7286091..0ba6c9c3921 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -27,7 +27,6 @@ import { Global } from "../global" import { ProjectRoute } from "./project" import { ToolRegistry } from "../tool/registry" import { zodToJsonSchema } from "zod-to-json-schema" -import { SessionLock } from "../session/lock" import { SessionPrompt } from "../session/prompt" import { SessionCompaction } from "../session/compaction" import { SessionRevert } from "../session/revert" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ff988845d1f..3b36f0329d7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -13,7 +13,6 @@ import { SessionPrompt } from "./prompt" import { Flag } from "../flag/flag" import { Token } from "../util/token" import { Log } from "../util/log" -import { SessionLock } from "./lock" import { ProviderTransform } from "@/provider/transform" import { SessionRetry } from "./retry" import { Config } from "@/config/config" @@ -88,7 +87,6 @@ export namespace SessionCompaction { } export async function run(input: { sessionID: string; providerID: string; modelID: string; signal?: AbortSignal }) { - if (!input.signal) SessionLock.assertUnlocked(input.sessionID) await using lock = input.signal === undefined ? SessionLock.acquire({ sessionID: input.sessionID }) : undefined const signal = input.signal ?? lock!.signal diff --git a/packages/opencode/src/session/lock.ts b/packages/opencode/src/session/lock.ts deleted file mode 100644 index 22eb8187c42..00000000000 --- a/packages/opencode/src/session/lock.ts +++ /dev/null @@ -1,97 +0,0 @@ -import z from "zod" -import { Instance } from "../project/instance" -import { Log } from "../util/log" -import { NamedError } from "../util/error" - -export namespace SessionLock { - const log = Log.create({ service: "session.lock" }) - - export const LockedError = NamedError.create( - "SessionLockedError", - z.object({ - sessionID: z.string(), - message: z.string(), - }), - ) - - type LockState = { - controller: AbortController - created: number - } - - const state = Instance.state( - () => { - const locks = new Map() - return { - locks, - } - }, - async (current) => { - for (const [sessionID, lock] of current.locks) { - log.info("force abort", { sessionID }) - lock.controller.abort() - } - current.locks.clear() - }, - ) - - function get(sessionID: string) { - return state().locks.get(sessionID) - } - - function unset(input: { sessionID: string; controller: AbortController }) { - const lock = get(input.sessionID) - if (!lock) return false - if (lock.controller !== input.controller) return false - state().locks.delete(input.sessionID) - return true - } - - export function acquire(input: { sessionID: string }) { - const lock = get(input.sessionID) - if (lock) { - throw new LockedError({ - sessionID: input.sessionID, - message: `Session ${input.sessionID} is locked`, - }) - } - const controller = new AbortController() - state().locks.set(input.sessionID, { - controller, - created: Date.now(), - }) - log.info("locked", { sessionID: input.sessionID }) - return { - signal: controller.signal, - abort() { - controller.abort() - unset({ sessionID: input.sessionID, controller }) - }, - async [Symbol.dispose]() { - const removed = unset({ sessionID: input.sessionID, controller }) - if (removed) { - log.info("unlocked", { sessionID: input.sessionID }) - } - }, - } - } - - export function abort(sessionID: string) { - const lock = get(sessionID) - if (!lock) return false - log.info("abort", { sessionID }) - lock.controller.abort() - state().locks.delete(sessionID) - return true - } - - export function isLocked(sessionID: string) { - return get(sessionID) !== undefined - } - - export function assertUnlocked(sessionID: string) { - const lock = get(sessionID) - if (!lock) return - throw new LockedError({ sessionID, message: `Session ${sessionID} is locked` }) - } -} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f50b9df43ca..e1fc63e8beb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,14 +49,12 @@ import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@/util/error" -import { SessionLock } from "./lock" import { fn } from "@/util/fn" import { SessionRetry } from "./retry" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = 32_000 - const MAX_RETRIES = 10 const DOOM_LOOP_THRESHOLD = 3 export const Status = z @@ -132,6 +130,19 @@ export namespace SessionPrompt { return state().status } + export function getStatus(sessionID: string) { + return ( + state().status[sessionID] ?? { + type: "idle", + } + ) + } + + export function assertNotBusy(sessionID: string) { + const status = getStatus(sessionID) + if (status?.type !== "idle") throw new Session.BusyError(sessionID) + } + export const setStatus = fn(z.object({ sessionID: Identifier.schema("session"), status: Status }), (input) => { Bus.publish(Event.Status, { sessionID: input.sessionID, status: input.status }) if (input.status.type === "idle") { @@ -1387,7 +1398,6 @@ export namespace SessionPrompt { }) export type ShellInput = z.infer export async function shell(input: ShellInput) { - using abort = SessionLock.acquire({ sessionID: input.sessionID }) const session = await Session.get(input.sessionID) if (session.revert) { SessionRevert.cleanup(session) @@ -1502,7 +1512,6 @@ export namespace SessionPrompt { const proc = spawn(shell, args, { cwd: Instance.directory, - signal: abort.signal, detached: true, stdio: ["ignore", "pipe", "pipe"], env: { @@ -1511,11 +1520,6 @@ export namespace SessionPrompt { }, }) - abort.signal.addEventListener("abort", () => { - if (!proc.pid) return - process.kill(-proc.pid) - }) - let output = "" proc.stdout?.on("data", (chunk) => { @@ -1646,132 +1650,22 @@ export namespace SessionPrompt { })() const agent = await Agent.get(agentName) - let result: MessageV2.WithParts if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) { - using abort = SessionLock.acquire({ sessionID: input.sessionID }) - - const userMsg: MessageV2.User = { - id: Identifier.ascending("message"), - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - role: "user", - agent: agentName, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - } - await Session.updateMessage(userMsg) - const userPart: MessageV2.Part = { - type: "text", - id: Identifier.ascending("part"), - messageID: userMsg.id, - sessionID: input.sessionID, - text: "The following tool was executed by the user", - synthetic: true, - } - await Session.updatePart(userPart) - - const assistantMsg: MessageV2.Assistant = { - id: Identifier.ascending("message"), - sessionID: input.sessionID, - parentID: userMsg.id, - mode: agentName, - cost: 0, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - time: { - created: Date.now(), - }, - role: "assistant", - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: model.modelID, - providerID: model.providerID, - } - await Session.updateMessage(assistantMsg) - - const args = { - description: "Consulting " + agent.name, - subagent_type: agent.name, - prompt: template, - } - const toolPart: MessageV2.ToolPart = { - type: "tool", - id: Identifier.ascending("part"), - messageID: assistantMsg.id, - sessionID: input.sessionID, - tool: "task", - callID: ulid(), - state: { - status: "running", - time: { - start: Date.now(), - }, - input: { - description: args.description, - subagent_type: args.subagent_type, - // truncate prompt to preserve context - prompt: args.prompt.length > 100 ? args.prompt.substring(0, 97) + "..." : args.prompt, - }, - }, - } - await Session.updatePart(toolPart) - - const taskResult = await TaskTool.init().then((t) => - t.execute(args, { - sessionID: input.sessionID, - abort: abort.signal, - agent: agent.name, - messageID: assistantMsg.id, - extra: {}, - metadata: async (metadata) => { - if (toolPart.state.status === "running") { - toolPart.state.metadata = metadata.metadata - toolPart.state.title = metadata.title - await Session.updatePart(toolPart) - } - }, - }), - ) - - assistantMsg.time.completed = Date.now() - await Session.updateMessage(assistantMsg) - if (toolPart.state.status === "running") { - toolPart.state = { - status: "completed", - time: { - ...toolPart.state.time, - end: Date.now(), - }, - input: toolPart.state.input, - title: "", - metadata: taskResult.metadata, - output: taskResult.output, - } - await Session.updatePart(toolPart) - } - - result = { info: assistantMsg, parts: [toolPart] } - } else { - result = (await prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - model, - agent: agentName, - parts, - })) as MessageV2.WithParts + parts.push({ + type: "agent", + name: agent.name, + }) } + const result = (await prompt({ + sessionID: input.sessionID, + messageID: input.messageID, + model, + agent: agentName, + parts, + })) as MessageV2.WithParts + Bus.publish(Command.Event.Executed, { name: input.command, sessionID: input.sessionID, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index dbf81edc782..35c7b9a607e 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -7,7 +7,7 @@ import { Log } from "../util/log" import { splitWhen } from "remeda" import { Storage } from "../storage/storage" import { Bus } from "../bus" -import { SessionLock } from "./lock" +import { SessionPrompt } from "./prompt" export namespace SessionRevert { const log = Log.create({ service: "session.revert" }) @@ -20,11 +20,7 @@ export namespace SessionRevert { export type RevertInput = z.infer export async function revert(input: RevertInput) { - SessionLock.assertUnlocked(input.sessionID) - using _ = SessionLock.acquire({ - sessionID: input.sessionID, - }) - + SessionPrompt.assertNotBusy(input.sessionID) const all = await Session.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined const session = await Session.get(input.sessionID) @@ -70,10 +66,7 @@ export namespace SessionRevert { export async function unrevert(input: { sessionID: string }) { log.info("unreverting", input) - SessionLock.assertUnlocked(input.sessionID) - using _ = SessionLock.acquire({ - sessionID: input.sessionID, - }) + SessionPrompt.assertNotBusy(input.sessionID) const session = await Session.get(input.sessionID) if (!session.revert) return session if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a5369d33549..d127fba38f7 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,7 +6,6 @@ import { Bus } from "../bus" import { MessageV2 } from "../session/message-v2" import { Identifier } from "../id/id" import { Agent } from "../agent/agent" -import { SessionLock } from "../session/lock" import { SessionPrompt } from "../session/prompt" export const TaskTool = Tool.define("task", async () => { @@ -63,7 +62,7 @@ export const TaskTool = Tool.define("task", async () => { } ctx.abort.addEventListener("abort", () => { - SessionLock.abort(session.id) + SessionPrompt.cancel(session.id) }) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) const result = await SessionPrompt.prompt({ From 961210f496cc82e9bd6e6d605bbdf7ad3a6a9c1e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 13 Nov 2025 17:53:38 -0500 Subject: [PATCH 06/29] core: refactor session state management to improve reliability --- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/session/prompt.ts | 123 ++++++++---------- 2 files changed, 55 insertions(+), 70 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 52a79218f30..9964aab813e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -83,7 +83,7 @@ export function Session() { const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) const pending = createMemo(() => { - return messages().findLast((x) => x.role === "assistant")?.id + return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) const lastUserMessage = createMemo(() => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e1fc63e8beb..76bc072b3f8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -31,14 +31,13 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import { ModelsDev } from "../provider/models" import { defer } from "../util/defer" -import { mergeDeep, pipe } from "remeda" +import { mapValues, mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { ListTool } from "../tool/ls" -import { TaskTool } from "../tool/task" import { FileTime } from "../file/time" import { Permission } from "../permission" import { Snapshot } from "../snapshot" @@ -94,45 +93,33 @@ export namespace SessionPrompt { const state = Instance.state( () => { - const queued = new Map< + const data: Record< string, { - callback: (input: MessageV2.WithParts) => void - }[] - >() - const pending = new Set>() - const status: Record = {} - const abort: Record = {} - - const track = (promise: Promise) => { - pending.add(promise) - promise.finally(() => pending.delete(promise)) - } - - return { - status, - abort, - queued, - pending, - track, - } + abort: AbortController + status: Status + callbacks: { + resolve(input: MessageV2.WithParts): void + reject(): void + }[] + } + > = {} + return data }, async (current) => { - current.queued.clear() - await Promise.allSettled([...current.pending]) - for (const item of Object.values(current.abort)) { - item.abort() + for (const item of Object.values(current)) { + item.abort.abort() } }, ) export function status() { - return state().status + return mapValues(state(), (item) => item.status) } export function getStatus(sessionID: string) { return ( - state().status[sessionID] ?? { + state()[sessionID]?.status ?? { type: "idle", } ) @@ -143,16 +130,6 @@ export namespace SessionPrompt { if (status?.type !== "idle") throw new Session.BusyError(sessionID) } - export const setStatus = fn(z.object({ sessionID: Identifier.schema("session"), status: Status }), (input) => { - Bus.publish(Event.Status, { sessionID: input.sessionID, status: input.status }) - if (input.status.type === "idle") { - delete state().status[input.sessionID] - delete state().abort[input.sessionID] - return - } - state().status[input.sessionID] = input.status - }) - export const PromptInput = z.object({ sessionID: Identifier.schema("session"), messageID: Identifier.schema("message").optional(), @@ -266,36 +243,42 @@ export namespace SessionPrompt { function start(sessionID: string) { const s = state() - if (s.status[sessionID]) return + if (s[sessionID]) return const controller = new AbortController() - s.abort[sessionID] = controller - setStatus({ sessionID, status: { type: "busy" } }) + s[sessionID] = { + abort: controller, + status: { type: "busy" }, + callbacks: [], + } + Bus.publish(Event.Status, { + sessionID, + status: s[sessionID].status, + }) return controller.signal } export function cancel(sessionID: string) { const s = state() - const signal = s.abort[sessionID] - const status = s.status[sessionID] - if (signal) { - signal.abort() - delete s.abort[sessionID] - } - if (status) { - setStatus({ sessionID, status: { type: "idle" } }) + const match = s[sessionID] + if (!match) return + match.abort.abort() + for (const item of match.callbacks) { + item.reject() } + delete s[sessionID] + Bus.publish(Event.Status, { + sessionID, + status: { type: "idle" }, + }) return } async function loop(sessionID: string) { const abort = start(sessionID) if (!abort) { - return new Promise((resolve) => { - const queue = state().queued.get(sessionID) ?? [] - queue.push({ - callback: resolve, - }) - state().queued.set(sessionID, queue) + return new Promise((resolve, reject) => { + const callbacks = state()[sessionID].callbacks + callbacks.push({ resolve, reject }) }) } @@ -472,20 +455,22 @@ export namespace SessionPrompt { retries++ const delay = SessionRetry.getRetryDelayInMs(result.info.error, retries) if (!delay) break - setStatus({ + state()[sessionID].status = { + type: "retry", + attempt: retries, + message: result.info.error.data.message, + } + Bus.publish(Event.Status, { sessionID, - status: { - type: "retry", - attempt: retries, - message: result.info.error.data.message, - }, + status: state()[sessionID].status, }) await SessionRetry.sleep(delay, abort).catch(() => {}) - setStatus({ + state()[sessionID].status = { + type: "busy", + } + Bus.publish(Event.Status, { sessionID, - status: { - type: "busy", - }, + status: state()[sessionID].status, }) continue } @@ -495,11 +480,10 @@ export namespace SessionPrompt { SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user") continue - const queued = state().queued.get(sessionID) ?? [] + const queued = state()[sessionID].callbacks for (const q of queued) { - q.callback(item) + q.resolve(item) } - state().queued.delete(sessionID) return item } throw new Error("Impossible") @@ -511,7 +495,8 @@ export namespace SessionPrompt { model: ModelsDev.Model abort: AbortSignal }) { - const lastAssistant = input.msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant + const lastAssistant = input.msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.time.completed) + ?.info as MessageV2.Assistant if (!lastAssistant) return input.msgs if ( !SessionCompaction.isOverflow({ From f4480fe0002b9ca1d89f55a19b5fd716497b5751 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 13 Nov 2025 17:55:14 -0500 Subject: [PATCH 07/29] fix: resolve TypeScript errors in session status and lock usage --- a.out | 0 packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/session/compaction.ts | 5 +++-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 a.out diff --git a/a.out b/a.out new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9964aab813e..64605e9df35 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -798,7 +798,7 @@ export function Session() { - {status().message} [retry #{status().attempt}] + {(status() as any).message} [retry #{(status() as any).attempt}] diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3b36f0329d7..0ab4ca0aa08 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -16,6 +16,7 @@ import { Log } from "../util/log" import { ProviderTransform } from "@/provider/transform" import { SessionRetry } from "./retry" import { Config } from "@/config/config" +import { Lock } from "../util/lock" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -87,8 +88,8 @@ export namespace SessionCompaction { } export async function run(input: { sessionID: string; providerID: string; modelID: string; signal?: AbortSignal }) { - await using lock = input.signal === undefined ? SessionLock.acquire({ sessionID: input.sessionID }) : undefined - const signal = input.signal ?? lock!.signal + const signal = input.signal ?? new AbortController().signal + await using lock = input.signal === undefined ? await Lock.write(input.sessionID) : undefined await Session.update(input.sessionID, (draft) => { draft.time.compacting = Date.now() From f0bacb21a97fd4ba6437e72eb743cebe315422a6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 13 Nov 2025 21:32:02 -0500 Subject: [PATCH 08/29] sync --- packages/opencode/src/cli/cmd/debug/file.ts | 16 ++++++++++++++++ packages/opencode/src/file/ripgrep.ts | 3 +++ packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/system.ts | 4 ++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 3d1e707dbca..51196614ce1 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -2,6 +2,7 @@ import { EOL } from "os" import { File } from "../../../file" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" +import { Ripgrep } from "@/file/ripgrep" const FileSearchCommand = cmd({ command: "search ", @@ -62,6 +63,20 @@ const FileListCommand = cmd({ }, }) +const FileTreeCommand = cmd({ + command: "tree [dir]", + builder: (yargs) => + yargs.positional("dir", { + type: "string", + description: "Directory to tree", + default: process.cwd(), + }), + async handler(args) { + const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 }) + console.log(files) + }, +}) + export const FileCommand = cmd({ command: "file", builder: (yargs) => @@ -70,6 +85,7 @@ export const FileCommand = cmd({ .command(FileStatusCommand) .command(FileListCommand) .command(FileSearchCommand) + .command(FileTreeCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 29014d199e7..7c871fafba2 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -8,8 +8,10 @@ import { lazy } from "../util/lazy" import { $ } from "bun" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" +import { Log } from "@/util/log" export namespace Ripgrep { + const log = Log.create({ service: "ripgrep" }) const Stats = z.object({ elapsed: z.object({ secs: z.number(), @@ -254,6 +256,7 @@ export namespace Ripgrep { } export async function tree(input: { cwd: string; limit?: number }) { + log.info("tree", input) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) interface Node { path: string[] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 76bc072b3f8..d442a02be3e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -480,7 +480,7 @@ export namespace SessionPrompt { SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user") continue - const queued = state()[sessionID].callbacks + const queued = state()[sessionID]?.callbacks ?? [] for (const q of queued) { q.resolve(item) } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 7d44bbda4e9..aaccccc48dc 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -43,7 +43,7 @@ export namespace SystemPrompt { ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, ``, - ``, + ``, ` ${ project.vcs === "git" ? await Ripgrep.tree({ @@ -52,7 +52,7 @@ export namespace SystemPrompt { }) : "" }`, - ``, + ``, ].join("\n"), ] } From d0277fae0d548a5964041937725617f33a2b2cc8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 14 Nov 2025 01:11:27 -0500 Subject: [PATCH 09/29] core: extract session processor to handle streaming responses and tool execution --- packages/opencode/src/session/compaction.ts | 107 ++- packages/opencode/src/session/processor.ts | 350 ++++++++++ packages/opencode/src/session/prompt.ts | 728 +++++--------------- 3 files changed, 640 insertions(+), 545 deletions(-) create mode 100644 packages/opencode/src/session/processor.ts diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 0ab4ca0aa08..cb088c0a9b4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -17,6 +17,7 @@ import { ProviderTransform } from "@/provider/transform" import { SessionRetry } from "./retry" import { Config } from "@/config/config" import { Lock } from "../util/lock" +import { SessionProcessor } from "./processor" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -36,7 +37,8 @@ export namespace SessionCompaction { if (context === 0) return false const count = input.tokens.input + input.tokens.cache.read + input.tokens.output const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX - const usable = context - output + // const usable = context - output + const usable = 20_000 return count > usable } @@ -87,6 +89,109 @@ export namespace SessionCompaction { } } + export async function process(input: { + parentID: string + messages: MessageV2.WithParts[] + sessionID: string + model: { + providerID: string + modelID: string + } + abort: AbortSignal + }) { + const model = await Provider.getModel(input.model.providerID, input.model.modelID) + const system = [ + ...SystemPrompt.summarize(model.providerID), + ...(await SystemPrompt.environment()), + ...(await SystemPrompt.custom()), + ] + const msg = (await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "assistant", + parentID: input.parentID, + sessionID: input.sessionID, + mode: "build", + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + summary: true, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: input.model.modelID, + providerID: model.providerID, + time: { + created: Date.now(), + }, + })) as MessageV2.Assistant + const stream = streamText({ + // set to 0, we handle loop + maxRetries: 0, + model: model.language, + providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options), + headers: model.info.headers, + abortSignal: input.abort, + tools: model.info.tool_call ? {} : undefined, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...MessageV2.toModelMessage(input.messages), + { + 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 processor = SessionProcessor.create({ + assistantMessage: msg, + sessionID: input.sessionID, + providerID: input.model.providerID, + model: model.info, + abort: input.abort, + }) + const result = await processor.process(stream) + const userMessage = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: input.sessionID, + time: { + created: Date.now(), + }, + model: { + providerID: input.model.providerID, + modelID: input.model.modelID, + }, + agent: "build", + }) + await Session.updatePart({ + type: "text", + sessionID: input.sessionID, + messageID: userMessage.id, + id: Identifier.ascending("part"), + text: "Use the above summary generated from your last session to resume from where you left off.", + time: { + start: Date.now(), + end: Date.now(), + }, + synthetic: true, + }) + return result + } + export async function run(input: { sessionID: string; providerID: string; modelID: string; signal?: AbortSignal }) { const signal = input.signal ?? new AbortController().signal await using lock = input.signal === undefined ? await Lock.write(input.sessionID) : undefined diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts new file mode 100644 index 00000000000..cac169c46e4 --- /dev/null +++ b/packages/opencode/src/session/processor.ts @@ -0,0 +1,350 @@ +import type { ModelsDev } from "@/provider/models" +import { MessageV2 } from "./message-v2" +import type { StreamTextResult, Tool as AITool } from "ai" +import { Log } from "@/util/log" +import { Identifier } from "@/id/id" +import { Session } from "." +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" +import { Snapshot } from "@/snapshot" +import { SessionSummary } from "./summary" +import { Bus } from "@/bus" + +export namespace SessionProcessor { + const DOOM_LOOP_THRESHOLD = 3 + const log = Log.create({ service: "session.processor" }) + + export type Info = Awaited> + export type Result = Awaited> + + export function create(input: { + assistantMessage: MessageV2.Assistant + sessionID: string + providerID: string + model: ModelsDev.Model + abort: AbortSignal + }) { + const toolcalls: Record = {} + let snapshot: string | undefined + let blocked = false + + const result = { + get message() { + return input.assistantMessage + }, + partFromToolCall(toolCallID: string) { + return toolcalls[toolCallID] + }, + async process(stream: StreamTextResult, never>) { + log.info("process") + try { + let currentText: MessageV2.TextPart | undefined + let reasoningMap: Record = {} + + for await (const value of stream.fullStream) { + input.abort.throwIfAborted() + switch (value.type) { + case "start": + break + + case "reasoning-start": + if (value.id in reasoningMap) { + continue + } + reasoningMap[value.id] = { + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "reasoning", + text: "", + time: { + start: Date.now(), + }, + metadata: value.providerMetadata, + } + break + + case "reasoning-delta": + if (value.id in reasoningMap) { + const part = reasoningMap[value.id] + part.text += value.text + if (value.providerMetadata) part.metadata = value.providerMetadata + if (part.text) await Session.updatePart({ part, delta: value.text }) + } + break + + case "reasoning-end": + if (value.id in reasoningMap) { + const part = reasoningMap[value.id] + part.text = part.text.trimEnd() + + part.time = { + ...part.time, + end: Date.now(), + } + if (value.providerMetadata) part.metadata = value.providerMetadata + await Session.updatePart(part) + delete reasoningMap[value.id] + } + break + + case "tool-input-start": + const part = await Session.updatePart({ + id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "tool", + tool: value.toolName, + callID: value.id, + state: { + status: "pending", + input: {}, + raw: "", + }, + }) + toolcalls[value.id] = part as MessageV2.ToolPart + break + + case "tool-input-delta": + break + + case "tool-input-end": + break + + case "tool-call": { + const match = toolcalls[value.toolCallId] + if (match) { + const part = await Session.updatePart({ + ...match, + tool: value.toolName, + state: { + status: "running", + input: value.input, + time: { + start: Date.now(), + }, + }, + metadata: value.providerMetadata, + }) + toolcalls[value.toolCallId] = part as MessageV2.ToolPart + + const parts = await MessageV2.parts(input.assistantMessage.id) + const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) + if ( + lastThree.length === DOOM_LOOP_THRESHOLD && + lastThree.every( + (p) => + p.type === "tool" && + p.tool === value.toolName && + p.state.status !== "pending" && + JSON.stringify(p.state.input) === JSON.stringify(value.input), + ) + ) { + const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission) + if (permission.doom_loop === "ask") { + await Permission.ask({ + type: "doom_loop", + pattern: value.toolName, + sessionID: input.assistantMessage.sessionID, + messageID: input.assistantMessage.id, + callID: value.toolCallId, + title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, + metadata: { + tool: value.toolName, + input: value.input, + }, + }) + } + } + } + break + } + case "tool-result": { + const match = toolcalls[value.toolCallId] + if (match && match.state.status === "running") { + await Session.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(), + }, + attachments: value.output.attachments, + }, + }) + + delete toolcalls[value.toolCallId] + } + break + } + + case "tool-error": { + const match = toolcalls[value.toolCallId] + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + status: "error", + input: value.input, + error: (value.error as any).toString(), + metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, + time: { + start: match.state.time.start, + end: Date.now(), + }, + }, + }) + + if (value.error instanceof Permission.RejectedError) { + blocked = true + } + delete toolcalls[value.toolCallId] + } + break + } + case "error": + throw value.error + + case "start-step": + snapshot = await Snapshot.track() + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + snapshot, + type: "step-start", + }) + break + + case "finish-step": + const usage = Session.getUsage({ + model: input.model, + usage: value.usage, + metadata: value.providerMetadata, + }) + input.assistantMessage.finish = value.finishReason + input.assistantMessage.cost += usage.cost + input.assistantMessage.tokens = usage.tokens + await Session.updatePart({ + id: Identifier.ascending("part"), + reason: value.finishReason, + snapshot: await Snapshot.track(), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "step-finish", + tokens: usage.tokens, + cost: usage.cost, + }) + await Session.updateMessage(input.assistantMessage) + if (snapshot) { + const patch = await Snapshot.patch(snapshot) + if (patch.files.length) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + } + snapshot = undefined + } + SessionSummary.summarize({ + sessionID: input.sessionID, + messageID: input.assistantMessage.parentID, + }) + break + + case "text-start": + currentText = { + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "text", + text: "", + time: { + start: Date.now(), + }, + metadata: value.providerMetadata, + } + break + + case "text-delta": + if (currentText) { + currentText.text += value.text + if (value.providerMetadata) currentText.metadata = value.providerMetadata + if (currentText.text) + await Session.updatePart({ + part: currentText, + delta: value.text, + }) + } + break + + case "text-end": + if (currentText) { + currentText.text = currentText.text.trimEnd() + currentText.time = { + start: Date.now(), + end: Date.now(), + } + if (value.providerMetadata) currentText.metadata = value.providerMetadata + await Session.updatePart(currentText) + } + currentText = undefined + break + + case "finish": + input.assistantMessage.time.completed = Date.now() + await Session.updateMessage(input.assistantMessage) + break + + default: + log.info("unhandled", { + ...value, + }) + continue + } + } + } catch (e) { + log.error("process", { + error: e, + }) + const error = MessageV2.fromError(e, { providerID: input.providerID }) + input.assistantMessage.error = error + Bus.publish(Session.Event.Error, { + sessionID: input.assistantMessage.sessionID, + error: input.assistantMessage.error, + }) + } + const p = await MessageV2.parts(input.assistantMessage.id) + for (const part of p) { + if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { + await Session.updatePart({ + ...part, + state: { + ...part.state, + status: "error", + error: "Tool execution aborted", + time: { + start: Date.now(), + end: Date.now(), + }, + }, + }) + } + } + input.assistantMessage.time.completed = Date.now() + await Session.updateMessage(input.assistantMessage) + return { info: input.assistantMessage, parts: p, blocked } + }, + } + return result + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d442a02be3e..6d9e1e8cb57 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -50,11 +50,12 @@ import { SessionSummary } from "./summary" import { NamedError } from "@/util/error" import { fn } from "@/util/fn" import { SessionRetry } from "./retry" +import { SessionProcessor } from "./processor" +import { iife } from "@/util/iife" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = 32_000 - const DOOM_LOOP_THRESHOLD = 3 export const Status = z .union([ @@ -298,158 +299,197 @@ export namespace SessionPrompt { step++ const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) - msgs = await checkOverflow({ - sessionID, - model: model.info, - abort, - msgs, - }) - const agent = await Agent.get(lastUser.agent) - msgs = insertReminders({ - messages: msgs, - agent, - }) - const processor = await createProcessor({ - userMessage: lastUser, - sessionID: sessionID, - model: model.info, - providerID: model.providerID, - agent: agent.name, - abort, - }) - const system = await resolveSystemPrompt({ - providerID: model.providerID, - modelID: model.info.id, - agent, - system: lastUser.system, - }) - const tools = await resolveTools({ - agent, - sessionID, - model: lastUser.model, - tools: lastUser.tools, - processor, - }) - const params = await Plugin.trigger( - "chat.params", - { - sessionID: sessionID, - agent: lastUser.agent, - model: model.info, - provider: await Provider.getProvider(model.providerID), - message: lastUser, - }, - { - temperature: model.info.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) - : undefined, - topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), - options: { - ...ProviderTransform.options(model.providerID, model.modelID, sessionID), - ...model.info.options, - ...agent.options, - }, - }, - ) + const result = await iife(async () => { + if ( + await checkOverflow({ + sessionID, + model: model.info, + abort, + msgs, + }) + ) { + return await SessionCompaction.process({ + messages: msgs, + parentID: lastUser.id, + abort, + model: { + providerID: model.providerID, + modelID: model.modelID, + }, + sessionID, + }) + } - if (step === 1) { - SessionSummary.summarize({ + const agent = await Agent.get(lastUser.agent) + msgs = insertReminders({ + messages: msgs, + agent, + }) + const processor = SessionProcessor.create({ + assistantMessage: (await Session.updateMessage({ + id: Identifier.ascending("message"), + parentID: lastUser.id, + role: "assistant", + mode: agent.mode, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: model.modelID, + providerID: model.providerID, + time: { + created: Date.now(), + }, + sessionID, + })) as MessageV2.Assistant, sessionID: sessionID, - messageID: lastUser.id, + model: model.info, + providerID: model.providerID, + abort, }) - } + const system = await resolveSystemPrompt({ + providerID: model.providerID, + modelID: model.info.id, + agent, + system: lastUser.system, + }) + const tools = await resolveTools({ + agent, + sessionID, + model: lastUser.model, + tools: lastUser.tools, + processor, + }) + const params = await Plugin.trigger( + "chat.params", + { + sessionID: sessionID, + agent: lastUser.agent, + model: model.info, + provider: await Provider.getProvider(model.providerID), + message: lastUser, + }, + { + temperature: model.info.temperature + ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) + : undefined, + topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), + options: { + ...ProviderTransform.options(model.providerID, model.modelID, sessionID), + ...model.info.options, + ...agent.options, + }, + }, + ) - const stream = streamText({ - onError(error) { - log.error("stream error", { - error, + if (step === 1) { + SessionSummary.summarize({ + sessionID: sessionID, + messageID: lastUser.id, }) - }, - 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 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, + }) + 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": sessionID, - "x-opencode-request": lastUser.id, - } - : undefined), - ...model.info.headers, - }, - // 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, - 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 - }), + }, + headers: { + ...(model.providerID === "opencode" + ? { + "x-opencode-session": sessionID, + "x-opencode-request": lastUser.id, + } + : undefined), + ...model.info.headers, + }, + // 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, ), - ], - 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) + abortSignal: abort, + 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 } - return args.params - }, - }, + 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 + }, + }, + ], + }), + }) + + return await processor.process(stream) }) - const result = await processor.process(stream) + if (result.blocked) break if (result.info.error?.name === "APIError" && result.info.error.data.isRetryable) { retries++ @@ -497,52 +537,11 @@ export namespace SessionPrompt { }) { const lastAssistant = input.msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.time.completed) ?.info as MessageV2.Assistant - if (!lastAssistant) return input.msgs - if ( - !SessionCompaction.isOverflow({ - tokens: lastAssistant.tokens, - model: input.model, - }) - ) - return input.msgs - // TODO: make this more efficient - const summaryMsg = await SessionCompaction.run({ - sessionID: input.sessionID, - signal: input.abort, - modelID: lastAssistant.modelID, - providerID: lastAssistant.providerID, + if (!lastAssistant) return false + return SessionCompaction.isOverflow({ + tokens: lastAssistant.tokens, + model: input.model, }) - const resumeMsgID = Identifier.ascending("message") - const resumeMsg = { - info: await Session.updateMessage({ - id: resumeMsgID, - role: "user", - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - model: { - providerID: lastAssistant.providerID, - modelID: lastAssistant.modelID, - }, - agent: lastAssistant.mode, - }), - parts: [ - await Session.updatePart({ - type: "text", - sessionID: input.sessionID, - messageID: resumeMsgID, - id: Identifier.ascending("part"), - text: "Use the above summary generated from your last session to resume from where you left off.", - time: { - start: Date.now(), - end: Date.now(), - }, - synthetic: true, - }), - ], - } - return [summaryMsg, resumeMsg] } async function resolveModel(input: { model: PromptInput["model"]; agent: Agent.Info }) { @@ -585,7 +584,7 @@ export namespace SessionPrompt { } sessionID: string tools?: Record - processor: Processor + processor: SessionProcessor.Info }) { const tools: Record = {} const enabledTools = pipe( @@ -1017,365 +1016,6 @@ export namespace SessionPrompt { return input.messages } - export type Processor = Awaited> - async function createProcessor(input: { - userMessage: MessageV2.User - sessionID: string - providerID: string - model: ModelsDev.Model - agent: string - abort: AbortSignal - }) { - const toolcalls: Record = {} - let snapshot: string | undefined - let blocked = false - - const assistantMsg: MessageV2.Info = { - id: Identifier.ascending("message"), - parentID: input.userMessage.id, - role: "assistant", - mode: input.agent, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: input.model.id, - providerID: input.providerID, - time: { - created: Date.now(), - }, - sessionID: input.sessionID, - } - await Session.updateMessage(assistantMsg) - - const result = { - get message() { - return assistantMsg - }, - partFromToolCall(toolCallID: string) { - return toolcalls[toolCallID] - }, - async process(stream: StreamTextResult, never>) { - log.info("process") - if (!assistantMsg) throw new Error("call next() first before processing") - try { - let currentText: MessageV2.TextPart | undefined - let reasoningMap: Record = {} - - for await (const value of stream.fullStream) { - input.abort.throwIfAborted() - switch (value.type) { - case "start": - break - - case "reasoning-start": - if (value.id in reasoningMap) { - continue - } - reasoningMap[value.id] = { - id: Identifier.ascending("part"), - messageID: assistantMsg.id, - sessionID: assistantMsg.sessionID, - type: "reasoning", - text: "", - time: { - start: Date.now(), - }, - metadata: value.providerMetadata, - } - break - - case "reasoning-delta": - if (value.id in reasoningMap) { - const part = reasoningMap[value.id] - part.text += value.text - if (value.providerMetadata) part.metadata = value.providerMetadata - if (part.text) await Session.updatePart({ part, delta: value.text }) - } - break - - case "reasoning-end": - if (value.id in reasoningMap) { - const part = reasoningMap[value.id] - part.text = part.text.trimEnd() - - part.time = { - ...part.time, - end: Date.now(), - } - if (value.providerMetadata) part.metadata = value.providerMetadata - await Session.updatePart(part) - delete reasoningMap[value.id] - } - break - - case "tool-input-start": - const part = await Session.updatePart({ - id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), - messageID: assistantMsg.id, - sessionID: assistantMsg.sessionID, - type: "tool", - tool: value.toolName, - callID: value.id, - state: { - status: "pending", - input: {}, - raw: "", - }, - }) - toolcalls[value.id] = part as MessageV2.ToolPart - break - - case "tool-input-delta": - break - - case "tool-input-end": - break - - case "tool-call": { - const match = toolcalls[value.toolCallId] - if (match) { - const part = await Session.updatePart({ - ...match, - tool: value.toolName, - state: { - status: "running", - input: value.input, - time: { - start: Date.now(), - }, - }, - metadata: value.providerMetadata, - }) - toolcalls[value.toolCallId] = part as MessageV2.ToolPart - - const parts = await MessageV2.parts(assistantMsg.id) - const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) - if ( - lastThree.length === DOOM_LOOP_THRESHOLD && - lastThree.every( - (p) => - p.type === "tool" && - p.tool === value.toolName && - p.state.status !== "pending" && - JSON.stringify(p.state.input) === JSON.stringify(value.input), - ) - ) { - const permission = await Agent.get(input.agent).then((x) => x.permission) - if (permission.doom_loop === "ask") { - await Permission.ask({ - type: "doom_loop", - pattern: value.toolName, - sessionID: assistantMsg.sessionID, - messageID: assistantMsg.id, - callID: value.toolCallId, - title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, - metadata: { - tool: value.toolName, - input: value.input, - }, - }) - } - } - } - break - } - case "tool-result": { - const match = toolcalls[value.toolCallId] - if (match && match.state.status === "running") { - await Session.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(), - }, - attachments: value.output.attachments, - }, - }) - - delete toolcalls[value.toolCallId] - } - break - } - - case "tool-error": { - const match = toolcalls[value.toolCallId] - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - status: "error", - input: value.input, - error: (value.error as any).toString(), - metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, - time: { - start: match.state.time.start, - end: Date.now(), - }, - }, - }) - - if (value.error instanceof Permission.RejectedError) { - blocked = true - } - delete toolcalls[value.toolCallId] - } - break - } - case "error": - throw value.error - - case "start-step": - snapshot = await Snapshot.track() - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: assistantMsg.id, - sessionID: assistantMsg.sessionID, - snapshot, - type: "step-start", - }) - break - - case "finish-step": - const usage = Session.getUsage({ - model: input.model, - usage: value.usage, - metadata: value.providerMetadata, - }) - assistantMsg.finish = value.finishReason - assistantMsg.cost += usage.cost - assistantMsg.tokens = usage.tokens - await Session.updatePart({ - id: Identifier.ascending("part"), - reason: value.finishReason, - snapshot: await Snapshot.track(), - messageID: assistantMsg.id, - sessionID: assistantMsg.sessionID, - type: "step-finish", - tokens: usage.tokens, - cost: usage.cost, - }) - await Session.updateMessage(assistantMsg) - if (snapshot) { - const patch = await Snapshot.patch(snapshot) - if (patch.files.length) { - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: assistantMsg.id, - sessionID: assistantMsg.sessionID, - type: "patch", - hash: patch.hash, - files: patch.files, - }) - } - snapshot = undefined - } - SessionSummary.summarize({ - sessionID: input.sessionID, - messageID: assistantMsg.parentID, - }) - break - - case "text-start": - currentText = { - id: Identifier.ascending("part"), - messageID: assistantMsg.id, - sessionID: assistantMsg.sessionID, - type: "text", - text: "", - time: { - start: Date.now(), - }, - metadata: value.providerMetadata, - } - break - - case "text-delta": - if (currentText) { - currentText.text += value.text - if (value.providerMetadata) currentText.metadata = value.providerMetadata - if (currentText.text) - await Session.updatePart({ - part: currentText, - delta: value.text, - }) - } - break - - case "text-end": - if (currentText) { - currentText.text = currentText.text.trimEnd() - currentText.time = { - start: Date.now(), - end: Date.now(), - } - if (value.providerMetadata) currentText.metadata = value.providerMetadata - await Session.updatePart(currentText) - } - currentText = undefined - break - - case "finish": - assistantMsg.time.completed = Date.now() - await Session.updateMessage(assistantMsg) - break - - default: - log.info("unhandled", { - ...value, - }) - continue - } - } - } catch (e) { - log.error("process", { - error: e, - }) - const error = MessageV2.fromError(e, { providerID: input.providerID }) - assistantMsg.error = error - Bus.publish(Session.Event.Error, { - sessionID: assistantMsg.sessionID, - error: assistantMsg.error, - }) - } - const p = await MessageV2.parts(assistantMsg.id) - for (const part of p) { - if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { - await Session.updatePart({ - ...part, - state: { - ...part.state, - status: "error", - error: "Tool execution aborted", - time: { - start: Date.now(), - end: Date.now(), - }, - }, - }) - } - } - assistantMsg.time.completed = Date.now() - await Session.updateMessage(assistantMsg) - return { info: assistantMsg, parts: p, blocked } - }, - } - return result - } - export const ShellInput = z.object({ sessionID: Identifier.schema("session"), agent: z.string(), From 295c66226f98a8f4f403c8daa8bf3eae76bbf34c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 14 Nov 2025 13:44:27 -0500 Subject: [PATCH 10/29] fix --- packages/sdk/js/src/gen/types.gen.ts | 846 +++++---------------------- 1 file changed, 145 insertions(+), 701 deletions(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 3a2c407abe1..e961f218717 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -42,6 +42,15 @@ export type UserMessage = { body?: string diffs: Array } + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: { + [key: string]: boolean + } } export type ProviderAuthError = { @@ -114,6 +123,7 @@ export type AssistantMessage = { write: number } } + finish?: string } export type Message = UserMessage | AssistantMessage @@ -377,13 +387,6 @@ export type EventMessagePartRemoved = { } } -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - export type Permission = { id: string type: string @@ -414,6 +417,13 @@ export type EventPermissionReplied = { } } +export type EventSessionCompacted = { + type: "session.compacted" + properties: { + sessionID: string + } +} + export type EventFileEdited = { type: "file.edited" properties: { @@ -458,6 +468,27 @@ export type EventCommandExecuted = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + export type EventSessionIdle = { type: "session.idle" properties: { @@ -598,12 +629,13 @@ export type Event = | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartRemoved - | EventSessionCompacted | EventPermissionUpdated | EventPermissionReplied + | EventSessionCompacted | EventFileEdited | EventTodoUpdated | EventCommandExecuted + | EventSessionStatus | EventSessionIdle | EventSessionCreated | EventSessionUpdated @@ -1157,531 +1189,139 @@ export type Path = { directory: string } -export type FileDiff = { - file: string - before: string - after: string - additions: number - deletions: number +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } } -export type Session = { - id: string - projectID: string - directory: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - share?: { - url: string - } - title: string - version: string - time: { - created: number - updated: number - compacting?: number +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + time?: { + start: number + end?: number } - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string + metadata?: { + [key: string]: unknown } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - } - | { - type: "busy" - } +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number } } -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string - /** - * Unique identifier for the todo item - */ - id: string +export type Command = { + name: string + description?: string + agent?: string + model?: string + template: string + subtask?: boolean } -export type UserMessage = { +export type Model = { id: string - sessionID: string - role: "user" - time: { - created: number + name: string + release_date: string + attachment: boolean + reasoning: boolean + temperature: boolean + tool_call: boolean + cost: { + input: number + output: number + cache_read?: number + cache_write?: number } - summary?: { - title?: string - body?: string - diffs: Array + limit: { + context: number + output: number } - agent: string - model: { - providerID: string - modelID: string + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> } - system?: string - tools?: { - [key: string]: boolean + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" + options: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + provider?: { + npm: string } } -export type ProviderAuthError = { - name: "ProviderAuthError" - data: { - providerID: string - message: string +export type Provider = { + api?: string + name: string + env: Array + id: string + npm?: string + models: { + [key: string]: Model } } -export type UnknownError = { - name: "UnknownError" - data: { - message: string +export type Symbol = { + name: string + kind: number + location: { + uri: string + range: Range } } -export type MessageOutputLengthError = { - name: "MessageOutputLengthError" - data: { - [key: string]: unknown - } +export type FileNode = { + name: string + path: string + absolute: string + type: "file" | "directory" + ignored: boolean } -export type MessageAbortedError = { - name: "MessageAbortedError" - data: { - message: string +export type FileContent = { + type: "text" + content: string + diff?: string + patch?: { + oldFileName: string + newFileName: string + oldHeader?: string + newHeader?: string + hunks: Array<{ + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: Array + }> + index?: string } -} - -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 - role: "assistant" - time: { - created: number - completed?: number - } - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError - parentID: string - modelID: string - providerID: string - mode: string - path: { - cwd: string - root: string - } - summary?: boolean - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - finish?: string -} - -export type Message = UserMessage | AssistantMessage - -export type TextPart = { - id: string - sessionID: string - messageID: string - type: "text" - text: string - synthetic?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type ReasoningPart = { - id: string - sessionID: string - messageID: string - type: "reasoning" - text: string - metadata?: { - [key: string]: unknown - } - time: { - start: number - end?: number - } -} - -export type FilePartSourceText = { - value: string - start: number - end: number -} - -export type FileSource = { - text: FilePartSourceText - type: "file" - path: string -} - -export type Range = { - start: { - line: number - character: number - } - end: { - line: number - character: number - } -} - -export type SymbolSource = { - text: FilePartSourceText - type: "symbol" - path: string - range: Range - name: string - kind: number -} - -export type FilePartSource = FileSource | SymbolSource - -export type FilePart = { - id: string - sessionID: string - messageID: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type ToolStatePending = { - status: "pending" - input: { - [key: string]: unknown - } - raw: string -} - -export type ToolStateRunning = { - status: "running" - input: { - [key: string]: unknown - } - title?: string - metadata?: { - [key: string]: unknown - } - time: { - start: number - } -} - -export type ToolStateCompleted = { - status: "completed" - input: { - [key: string]: unknown - } - output: string - title: string - metadata: { - [key: string]: unknown - } - time: { - start: number - end: number - compacted?: number - } - attachments?: Array -} - -export type ToolStateError = { - status: "error" - input: { - [key: string]: unknown - } - error: string - metadata?: { - [key: string]: unknown - } - time: { - start: number - end: number - } -} - -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export type ToolPart = { - id: string - sessionID: string - messageID: string - type: "tool" - callID: string - tool: string - state: ToolState - metadata?: { - [key: string]: unknown - } -} - -export type StepStartPart = { - id: string - sessionID: string - messageID: string - type: "step-start" - snapshot?: string -} - -export type StepFinishPart = { - id: string - sessionID: string - messageID: string - type: "step-finish" - reason: string - snapshot?: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } -} - -export type SnapshotPart = { - id: string - sessionID: string - messageID: string - type: "snapshot" - snapshot: string -} - -export type PatchPart = { - id: string - sessionID: string - messageID: string - type: "patch" - hash: string - files: Array -} - -export type AgentPart = { - id: string - sessionID: string - messageID: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type RetryPart = { - id: string - sessionID: string - messageID: string - type: "retry" - attempt: number - error: ApiError - time: { - created: number - } -} - -export type Part = - | TextPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - -export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type AgentPartInput = { - id?: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type Command = { - name: string - description?: string - agent?: string - model?: string - template: string - subtask?: boolean -} - -export type Model = { - id: string - name: string - release_date: string - attachment: boolean - reasoning: boolean - temperature: boolean - tool_call: boolean - cost: { - input: number - output: number - cache_read?: number - cache_write?: number - } - limit: { - context: number - output: number - } - modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" - options: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - provider?: { - npm: string - } -} - -export type Provider = { - api?: string - name: string - env: Array - id: string - npm?: string - models: { - [key: string]: Model - } -} - -export type Symbol = { - name: string - kind: number - location: { - uri: string - range: Range - } -} - -export type FileNode = { - name: string - path: string - absolute: string - type: "file" | "directory" - ignored: boolean -} - -export type FileContent = { - type: "text" - content: string - diff?: string - patch?: { - oldFileName: string - newFileName: string - oldHeader?: string - newHeader?: string - hunks: Array<{ - oldStart: number - oldLines: number - newStart: number - newLines: number - lines: Array - }> - index?: string - } - encoding?: "base64" - mimeType?: string + encoding?: "base64" + mimeType?: string } export type File = { @@ -1784,203 +1424,7 @@ export type GlobalEventResponses = { 200: GlobalEvent } -export type EventLspUpdated = { - type: "lsp.updated" - properties: { - [key: string]: unknown - } -} - -export type EventMessageUpdated = { - type: "message.updated" - properties: { - info: Message - } -} - -export type EventMessageRemoved = { - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - -export type EventMessagePartUpdated = { - type: "message.part.updated" - properties: { - part: Part - delta?: string - } -} - -export type EventMessagePartRemoved = { - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - -export type Permission = { - id: string - type: string - pattern?: string | Array - sessionID: string - messageID: string - callID?: string - title: string - metadata: { - [key: string]: unknown - } - time: { - created: number - } -} - -export type EventPermissionUpdated = { - type: "permission.updated" - properties: Permission -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - permissionID: string - response: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type EventSessionCreated = { - type: "session.created" - properties: { - info: Session - } -} - -export type EventSessionUpdated = { - type: "session.updated" - properties: { - info: Session - } -} - -export type EventSessionDeleted = { - type: "session.deleted" - properties: { - info: Session - } -} - -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError - } -} - -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type Event = - | EventInstallationUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCompacted - | EventPermissionUpdated - | EventPermissionReplied - | EventFileEdited - | EventTodoUpdated - | EventCommandExecuted - | EventSessionStatus - | EventSessionIdle - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - | EventSessionDiff - | EventSessionError - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventServerConnected - | EventFileWatcherUpdated -======= export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] ->>>>>>> dev export type ProjectListData = { body?: never From ec4ce5fe92a37710309744d1f206db0e18b11409 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 14 Nov 2025 22:24:55 -0500 Subject: [PATCH 11/29] progress-ish --- .gitignore | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 123 ++++++++++-------- packages/opencode/src/session/compaction.ts | 26 ---- packages/opencode/src/session/message-v2.ts | 25 ++++ packages/opencode/src/session/prompt.ts | 60 +++++++-- packages/sdk/js/src/gen/types.gen.ts | 8 ++ 6 files changed, 152 insertions(+), 91 deletions(-) diff --git a/.gitignore b/.gitignore index f69a7079669..d1d9839629d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dist .turbo **/.serena .serena/ +refs diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1e5536a1efa..60869589459 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -26,7 +26,15 @@ import { type ScrollAcceleration, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk" +import type { + AssistantMessage, + Part, + ToolPart, + UserMessage, + TextPart, + ReasoningPart, + CompactionPart, +} from "@opencode-ai/sdk" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -878,58 +886,71 @@ function UserMessage(props: { const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : theme.secondary)) + const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) + return ( - - { - setHover(true) - }} - onMouseOut={() => { - setHover(false) - }} - onMouseUp={props.onMouseUp} - border={["left"]} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - marginTop={props.index === 0 ? 0 : 1} - backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} - customBorderChars={SplitBorder.customBorderChars} - borderColor={color()} - flexShrink={0} - > - {text()?.text} - - - - {(file) => { - const bg = createMemo(() => { - if (file.mime.startsWith("image/")) return theme.accent - if (file.mime === "application/pdf") return theme.primary - return theme.secondary - }) - return ( - - {MIME_BADGE[file.mime] ?? file.mime} - {file.filename} - - ) - }} - - - - - {sync.data.config.username ?? "You"}{" "} - ({Locale.time(props.message.time.created)})} - > - QUEUED + <> + + { + setHover(true) + }} + onMouseOut={() => { + setHover(false) + }} + onMouseUp={props.onMouseUp} + border={["left"]} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + marginTop={props.index === 0 ? 0 : 1} + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + customBorderChars={SplitBorder.customBorderChars} + borderColor={color()} + flexShrink={0} + > + {text()?.text} + + + + {(file) => { + const bg = createMemo(() => { + if (file.mime.startsWith("image/")) return theme.accent + if (file.mime === "application/pdf") return theme.primary + return theme.secondary + }) + return ( + + {MIME_BADGE[file.mime] ?? file.mime} + {file.filename} + + ) + }} + + - - - + + {sync.data.config.username ?? "You"}{" "} + ({Locale.time(props.message.time.created)})} + > + QUEUED + + + + + + + + ) } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index cb088c0a9b4..f53da40da97 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -115,7 +115,6 @@ export namespace SessionCompaction { cwd: Instance.directory, root: Instance.worktree, }, - summary: true, cost: 0, tokens: { output: 0, @@ -164,31 +163,6 @@ export namespace SessionCompaction { abort: input.abort, }) const result = await processor.process(stream) - const userMessage = await Session.updateMessage({ - id: Identifier.ascending("message"), - role: "user", - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - model: { - providerID: input.model.providerID, - modelID: input.model.modelID, - }, - agent: "build", - }) - await Session.updatePart({ - type: "text", - sessionID: input.sessionID, - messageID: userMessage.id, - id: Identifier.ascending("part"), - text: "Use the above summary generated from your last session to resume from where you left off.", - time: { - start: Date.now(), - end: Date.now(), - }, - synthetic: true, - }) return result } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index efe11849e71..32f26bd71a3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -142,6 +142,13 @@ export namespace MessageV2 { }) export type AgentPart = z.infer + export const CompactionPart = PartBase.extend({ + type: z.literal("compaction"), + }).meta({ + ref: "CompactionPart", + }) + export type CompactionPart = z.infer + export const RetryPart = PartBase.extend({ type: z.literal("retry"), attempt: z.number(), @@ -301,6 +308,7 @@ export namespace MessageV2 { PatchPart, AgentPart, RetryPart, + CompactionPart, ]) .meta({ ref: "Part", @@ -563,6 +571,15 @@ export namespace MessageV2 { filename: part.filename, }, ] + + if (part.type === "compaction") { + return [ + { + type: "text", + text: "The user requested a compaction of the session. YOU MUST CONTINUE THE CONVERSATION AFTER THIS MESSAGE.", + }, + ] + } return [] }), }) @@ -684,9 +701,17 @@ export namespace MessageV2 { export async function filterCompacted(stream: AsyncIterable) { const result = [] as MessageV2.WithParts[] + const completed = new Set() for await (const msg of stream) { result.push(msg) + if ( + msg.info.role === "user" && + completed.has(msg.info.id) && + msg.parts.some((part) => part.type === "compaction") + ) + break if (msg.info.role === "assistant" && msg.info.summary === true) break + if (msg.info.role === "assistant" && msg.info.finish) completed.add(msg.info.id) } result.reverse() return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2b59e43606b..e06d856aba6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -286,26 +286,58 @@ export namespace SessionPrompt { let retries = 0 while (true) { let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const lastUser = msgs.findLast((m) => m.info.role === "user")?.info as MessageV2.User - if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - log.info("last user", { id: lastUser.id, model: lastUser.model }) - const lastAssistant = msgs.findLast((msg) => msg.info.role === "assistant")?.info as MessageV2.Assistant - log.info("last assistant", { id: lastAssistant?.id }) + let lastUser: MessageV2.User | undefined + let lastAssistant: MessageV2.Assistant | undefined + let lastFinished: MessageV2.Assistant | undefined + let tasks: MessageV2.CompactionPart[] = [] + for (let i = msgs.length - 1; i >= 0; i--) { + const msg = msgs[i] + if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User + if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant + if (msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant + if (lastUser && lastFinished) break + const compaction = msg.parts.find((part) => part.type === "compaction") + if (compaction) { + tasks.push(compaction) + } + } + + if (!lastUser) throw new Error("No user message found in stream. This should never happen.") if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) break + log.info("last assistant", { id: lastAssistant?.id }) + step++ const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + const task = tasks.pop() + + if ( + task?.type !== "compaction" && + lastFinished && + SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info }) + ) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + model: lastUser.model, + sessionID, + agent: lastUser.agent, + time: { + created: Date.now(), + }, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + }) + continue + } const result = await iife(async () => { - if ( - await checkOverflow({ - sessionID, - model: model.info, - abort, - msgs, - }) - ) { + if (task?.type === "compaction") { return await SessionCompaction.process({ messages: msgs, parentID: lastUser.id, @@ -328,7 +360,7 @@ export namespace SessionPrompt { id: Identifier.ascending("message"), parentID: lastUser.id, role: "assistant", - mode: agent.mode, + mode: agent.name, path: { cwd: Instance.directory, root: Instance.worktree, diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index e961f218717..f996c774cf9 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -358,6 +358,13 @@ export type RetryPart = { } } +export type CompactionPart = { + id: string + sessionID: string + messageID: string + type: "compaction" +} + export type Part = | TextPart | ReasoningPart @@ -369,6 +376,7 @@ export type Part = | PatchPart | AgentPart | RetryPart + | CompactionPart export type EventMessagePartUpdated = { type: "message.part.updated" From 4379270ba833f3d136f0cc34a452ea03a6155d61 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 18:06:44 -0500 Subject: [PATCH 12/29] progress --- packages/opencode/src/session/compaction.ts | 291 ++--------- packages/opencode/src/session/message-v2.ts | 190 ++++--- packages/opencode/src/session/processor.ts | 551 ++++++++++---------- packages/opencode/src/session/prompt.ts | 220 ++++---- 4 files changed, 498 insertions(+), 754 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f53da40da97..eeb424ed40f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,9 +1,8 @@ -import { streamText, type ModelMessage, type StreamTextResult, type Tool as AITool } from "ai" +import { streamText, type ModelMessage } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" -import { defer } from "../util/defer" import { MessageV2 } from "./message-v2" import { SystemPrompt } from "./system" import { Bus } from "../bus" @@ -14,9 +13,6 @@ import { Flag } from "../flag/flag" import { Token } from "../util/token" import { Log } from "../util/log" import { ProviderTransform } from "@/provider/transform" -import { SessionRetry } from "./retry" -import { Config } from "@/config/config" -import { Lock } from "../util/lock" import { SessionProcessor } from "./processor" export namespace SessionCompaction { @@ -37,14 +33,12 @@ export namespace SessionCompaction { if (context === 0) return false const count = input.tokens.input + input.tokens.cache.read + input.tokens.output const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX - // const usable = context - output - const usable = 20_000 + const usable = context - output return count > usable } 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 @@ -111,6 +105,7 @@ export namespace SessionCompaction { parentID: input.parentID, sessionID: input.sessionID, mode: "build", + summary: true, path: { cwd: Instance.directory, root: Instance.worktree, @@ -128,33 +123,6 @@ export namespace SessionCompaction { created: Date.now(), }, })) as MessageV2.Assistant - const stream = streamText({ - // set to 0, we handle loop - maxRetries: 0, - model: model.language, - providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options), - headers: model.info.headers, - abortSignal: input.abort, - tools: model.info.tool_call ? {} : undefined, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage(input.messages), - { - 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 processor = SessionProcessor.create({ assistantMessage: msg, sessionID: input.sessionID, @@ -162,79 +130,14 @@ export namespace SessionCompaction { model: model.info, abort: input.abort, }) - const result = await processor.process(stream) - return result - } - - export async function run(input: { sessionID: string; providerID: string; modelID: string; signal?: AbortSignal }) { - const signal = input.signal ?? new AbortController().signal - await using lock = input.signal === undefined ? await Lock.write(input.sessionID) : undefined - - await Session.update(input.sessionID, (draft) => { - draft.time.compacting = Date.now() - }) - await using _ = defer(async () => { - await Session.update(input.sessionID, (draft) => { - draft.time.compacting = undefined - }) - }) - const toSummarize = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID)) - const model = await Provider.getModel(input.providerID, input.modelID) - const system = [ - ...SystemPrompt.summarize(model.providerID), - ...(await SystemPrompt.environment()), - ...(await SystemPrompt.custom()), - ] - - const msg = (await Session.updateMessage({ - id: Identifier.ascending("message"), - role: "assistant", - parentID: toSummarize.findLast((m) => m.info.role === "user")?.info.id!, - sessionID: input.sessionID, - mode: "build", - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - summary: true, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: input.modelID, - providerID: model.providerID, - time: { - created: Date.now(), - }, - })) as MessageV2.Assistant - - const part = (await Session.updatePart({ - type: "text", - sessionID: input.sessionID, - messageID: msg.id, - id: Identifier.ascending("part"), - text: "", - time: { - start: Date.now(), - }, - })) as MessageV2.TextPart - - const doStream = () => + const result = await processor.process(() => streamText({ // set to 0, we handle loop maxRetries: 0, model: model.language, providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options), headers: model.info.headers, - abortSignal: signal, - onError(error) { - log.error("stream error", { - error, - }) - }, + abortSignal: input.abort, tools: model.info.tool_call ? {} : undefined, messages: [ ...system.map( @@ -243,7 +146,7 @@ export namespace SessionCompaction { content: x, }), ), - ...MessageV2.toModelMessage(toSummarize), + ...MessageV2.toModelMessage(input.messages), { role: "user", content: [ @@ -254,168 +157,32 @@ export namespace SessionCompaction { ], }, ], + }), + ) + if (result === "continue") { + const continueMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: input.sessionID, + time: { + created: Date.now(), + }, + agent: "build", + model: input.model, }) - - // 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, - delta: value.text, - }) - 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 - } - 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, - }) - } - } - - const parts = await MessageV2.parts(msg.id) - return { - info: msg, - parts, - shouldRetry, - } - } - - let stream = doStream() - const cfg = await Config.get() - const maxRetries = cfg.experimental?.chatMaxRetries ?? MAX_RETRIES - let result = await process(stream, { - count: 0, - max: maxRetries, - }) - if (result.shouldRetry) { - const start = Date.now() - for (let retry = 1; retry < maxRetries; retry++) { - const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry") - - if (lastRetryPart) { - const delayMs = SessionRetry.getBoundedDelay({ - error: lastRetryPart.error, - attempt: retry, - startTime: start, - }) - if (!delayMs) { - break - } - - log.info("retrying with backoff", { - attempt: retry, - delayMs, - elapsed: Date.now() - start, - }) - - 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: maxRetries, - }) - if (!result.shouldRetry) { - break - } - } - } - - msg.time.completed = Date.now() - - if ( - !msg.error || - (MessageV2.AbortedError.isInstance(msg.error) && - result.parts.some((part): part is MessageV2.TextPart => part.type === "text" && part.text.length > 0)) - ) { - msg.summary = true - Bus.publish(Event.Compacted, { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: continueMsg.id, sessionID: input.sessionID, + type: "text", + synthetic: true, + text: "Continue if you have next steps", + time: { + start: Date.now(), + end: Date.now(), + }, }) } - await Session.updateMessage(msg) - - return { - info: msg, - parts: result.parts, - } + return "continue" } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 32f26bd71a3..c636b5635e9 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -550,116 +550,101 @@ export namespace MessageV2 { if (msg.parts.length === 0) continue if (msg.info.role === "user") { - result.push({ + const userMessage: UIMessage = { id: msg.info.id, role: "user", - parts: msg.parts.flatMap((part): UIMessage["parts"] => { - if (part.type === "text") - return [ - { - type: "text", - text: part.text, - }, - ] - // text/plain and directory files are converted into text parts, ignore them - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") - return [ - { - type: "file", - url: part.url, - mediaType: part.mime, - filename: part.filename, - }, - ] - - if (part.type === "compaction") { - return [ - { - type: "text", - text: "The user requested a compaction of the session. YOU MUST CONTINUE THE CONVERSATION AFTER THIS MESSAGE.", - }, - ] - } - return [] - }), - }) + parts: [], + } + result.push(userMessage) + for (const part of msg.parts) { + if (part.type === "text") + userMessage.parts.push({ + type: "text", + text: part.text, + }) + // text/plain and directory files are converted into text parts, ignore them + if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") + userMessage.parts.push({ + type: "file", + url: part.url, + mediaType: part.mime, + filename: part.filename, + }) + + if (part.type === "compaction") { + userMessage.parts.push({ + type: "text", + text: "The user requested a compaction of the session.", + }) + } + } } if (msg.info.role === "assistant") { - result.push({ + const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", - parts: msg.parts.flatMap((part): UIMessage["parts"] => { - if (part.type === "text") - return [ - { - type: "text", - text: part.text, - providerMetadata: part.metadata, - }, - ] - if (part.type === "step-start") - return [ - { - type: "step-start", - }, - ] - if (part.type === "tool") { - if (part.state.status === "completed") { - if (part.state.attachments?.length) { - result.push({ - id: Identifier.ascending("message"), - role: "user", - parts: [ - { - type: "text", - text: `Tool ${part.tool} returned an attachment:`, - }, - ...part.state.attachments.map((attachment) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - filename: attachment.filename, - })), - ], - }) - } - return [ - { - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-available", - toolCallId: part.callID, - input: part.state.input, - output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, - callProviderMetadata: part.metadata, - }, - ] + parts: [], + } + result.push(assistantMessage) + for (const part of msg.parts) { + if (part.type === "text") + assistantMessage.parts.push({ + type: "text", + text: part.text, + providerMetadata: part.metadata, + }) + if (part.type === "step-start") + assistantMessage.parts.push({ + type: "step-start", + }) + if (part.type === "tool") { + if (part.state.status === "completed") { + if (part.state.attachments?.length) { + result.push({ + id: Identifier.ascending("message"), + role: "user", + parts: [ + { + type: "text", + text: `Tool ${part.tool} returned an attachment:`, + }, + ...part.state.attachments.map((attachment) => ({ + type: "file" as const, + url: attachment.url, + mediaType: attachment.mime, + filename: attachment.filename, + })), + ], + }) } - if (part.state.status === "error") - return [ - { - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - callProviderMetadata: part.metadata, - }, - ] - } - if (part.type === "reasoning") { - return [ - { - type: "reasoning", - text: part.text, - providerMetadata: part.metadata, - }, - ] + assistantMessage.parts.push({ + type: ("tool-" + part.tool) as `tool-${string}`, + state: "output-available", + toolCallId: part.callID, + input: part.state.input, + output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, + callProviderMetadata: part.metadata, + }) } - - return [] - }), - }) + if (part.state.status === "error") + assistantMessage.parts.push({ + type: ("tool-" + part.tool) as `tool-${string}`, + state: "output-error", + toolCallId: part.callID, + input: part.state.input, + errorText: part.state.error, + callProviderMetadata: part.metadata, + }) + } + if (part.type === "reasoning") { + assistantMessage.parts.push({ + type: "reasoning", + text: part.text, + providerMetadata: part.metadata, + }) + } + } } } @@ -710,8 +695,7 @@ export namespace MessageV2 { msg.parts.some((part) => part.type === "compaction") ) break - if (msg.info.role === "assistant" && msg.info.summary === true) break - if (msg.info.role === "assistant" && msg.info.finish) completed.add(msg.info.id) + if (msg.info.summary) break } result.reverse() return result diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index cac169c46e4..7b3ad90b5f9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,6 +1,6 @@ import type { ModelsDev } from "@/provider/models" import { MessageV2 } from "./message-v2" -import type { StreamTextResult, Tool as AITool } from "ai" +import { type StreamTextResult, type Tool as AITool, APICallError } from "ai" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." @@ -9,6 +9,7 @@ import { Permission } from "@/permission" import { Snapshot } from "@/snapshot" import { SessionSummary } from "./summary" import { Bus } from "@/bus" +import { SessionRetry } from "./retry" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -27,6 +28,7 @@ export namespace SessionProcessor { const toolcalls: Record = {} let snapshot: string | undefined let blocked = false + let attempt = 0 const result = { get message() { @@ -35,314 +37,327 @@ export namespace SessionProcessor { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(stream: StreamTextResult, never>) { + async process(fn: () => StreamTextResult, never>) { log.info("process") - try { - let currentText: MessageV2.TextPart | undefined - let reasoningMap: Record = {} + while (true) { + try { + let currentText: MessageV2.TextPart | undefined + let reasoningMap: Record = {} + const stream = fn() - for await (const value of stream.fullStream) { - input.abort.throwIfAborted() - switch (value.type) { - case "start": - break + for await (const value of stream.fullStream) { + input.abort.throwIfAborted() + switch (value.type) { + case "start": + break - case "reasoning-start": - if (value.id in reasoningMap) { - continue - } - reasoningMap[value.id] = { - id: Identifier.ascending("part"), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "reasoning", - text: "", - time: { - start: Date.now(), - }, - metadata: value.providerMetadata, - } - break - - case "reasoning-delta": - if (value.id in reasoningMap) { - const part = reasoningMap[value.id] - part.text += value.text - if (value.providerMetadata) part.metadata = value.providerMetadata - if (part.text) await Session.updatePart({ part, delta: value.text }) - } - break - - case "reasoning-end": - if (value.id in reasoningMap) { - const part = reasoningMap[value.id] - part.text = part.text.trimEnd() - - part.time = { - ...part.time, - end: Date.now(), + case "reasoning-start": + if (value.id in reasoningMap) { + continue } - if (value.providerMetadata) part.metadata = value.providerMetadata - await Session.updatePart(part) - delete reasoningMap[value.id] - } - break + reasoningMap[value.id] = { + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "reasoning", + text: "", + time: { + start: Date.now(), + }, + metadata: value.providerMetadata, + } + break - case "tool-input-start": - const part = await Session.updatePart({ - id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "tool", - tool: value.toolName, - callID: value.id, - state: { - status: "pending", - input: {}, - raw: "", - }, - }) - toolcalls[value.id] = part as MessageV2.ToolPart - break + case "reasoning-delta": + if (value.id in reasoningMap) { + const part = reasoningMap[value.id] + part.text += value.text + if (value.providerMetadata) part.metadata = value.providerMetadata + if (part.text) await Session.updatePart({ part, delta: value.text }) + } + break - case "tool-input-delta": - break + case "reasoning-end": + if (value.id in reasoningMap) { + const part = reasoningMap[value.id] + part.text = part.text.trimEnd() - case "tool-input-end": - break + part.time = { + ...part.time, + end: Date.now(), + } + if (value.providerMetadata) part.metadata = value.providerMetadata + await Session.updatePart(part) + delete reasoningMap[value.id] + } + break - case "tool-call": { - const match = toolcalls[value.toolCallId] - if (match) { + case "tool-input-start": const part = await Session.updatePart({ - ...match, + id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "tool", tool: value.toolName, + callID: value.id, state: { - status: "running", - input: value.input, - time: { - start: Date.now(), - }, + status: "pending", + input: {}, + raw: "", }, - metadata: value.providerMetadata, }) - toolcalls[value.toolCallId] = part as MessageV2.ToolPart + toolcalls[value.id] = part as MessageV2.ToolPart + break - const parts = await MessageV2.parts(input.assistantMessage.id) - const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) - if ( - lastThree.length === DOOM_LOOP_THRESHOLD && - lastThree.every( - (p) => - p.type === "tool" && - p.tool === value.toolName && - p.state.status !== "pending" && - JSON.stringify(p.state.input) === JSON.stringify(value.input), - ) - ) { - const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission) - if (permission.doom_loop === "ask") { - await Permission.ask({ - type: "doom_loop", - pattern: value.toolName, - sessionID: input.assistantMessage.sessionID, - messageID: input.assistantMessage.id, - callID: value.toolCallId, - title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, - metadata: { - tool: value.toolName, - input: value.input, + case "tool-input-delta": + break + + case "tool-input-end": + break + + case "tool-call": { + const match = toolcalls[value.toolCallId] + if (match) { + const part = await Session.updatePart({ + ...match, + tool: value.toolName, + state: { + status: "running", + input: value.input, + time: { + start: Date.now(), }, - }) + }, + metadata: value.providerMetadata, + }) + toolcalls[value.toolCallId] = part as MessageV2.ToolPart + + const parts = await MessageV2.parts(input.assistantMessage.id) + const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) + if ( + lastThree.length === DOOM_LOOP_THRESHOLD && + lastThree.every( + (p) => + p.type === "tool" && + p.tool === value.toolName && + p.state.status !== "pending" && + JSON.stringify(p.state.input) === JSON.stringify(value.input), + ) + ) { + const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission) + if (permission.doom_loop === "ask") { + await Permission.ask({ + type: "doom_loop", + pattern: value.toolName, + sessionID: input.assistantMessage.sessionID, + messageID: input.assistantMessage.id, + callID: value.toolCallId, + title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`, + metadata: { + tool: value.toolName, + input: value.input, + }, + }) + } } } + break } - break - } - case "tool-result": { - const match = toolcalls[value.toolCallId] - if (match && match.state.status === "running") { - await Session.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(), + case "tool-result": { + const match = toolcalls[value.toolCallId] + if (match && match.state.status === "running") { + await Session.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(), + }, + attachments: value.output.attachments, }, - attachments: value.output.attachments, - }, - }) + }) - delete toolcalls[value.toolCallId] + delete toolcalls[value.toolCallId] + } + break } - break - } - case "tool-error": { - const match = toolcalls[value.toolCallId] - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - status: "error", - input: value.input, - error: (value.error as any).toString(), - metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, - time: { - start: match.state.time.start, - end: Date.now(), + case "tool-error": { + const match = toolcalls[value.toolCallId] + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + status: "error", + input: value.input, + error: (value.error as any).toString(), + metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined, + time: { + start: match.state.time.start, + end: Date.now(), + }, }, - }, - }) + }) - if (value.error instanceof Permission.RejectedError) { - blocked = true + if (value.error instanceof Permission.RejectedError) { + blocked = true + } + delete toolcalls[value.toolCallId] } - delete toolcalls[value.toolCallId] + break } - break - } - case "error": - throw value.error + case "error": + throw value.error - case "start-step": - snapshot = await Snapshot.track() - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - snapshot, - type: "step-start", - }) - break + case "start-step": + snapshot = await Snapshot.track() + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + snapshot, + type: "step-start", + }) + break - case "finish-step": - const usage = Session.getUsage({ - model: input.model, - usage: value.usage, - metadata: value.providerMetadata, - }) - input.assistantMessage.finish = value.finishReason - input.assistantMessage.cost += usage.cost - input.assistantMessage.tokens = usage.tokens - await Session.updatePart({ - id: Identifier.ascending("part"), - reason: value.finishReason, - snapshot: await Snapshot.track(), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "step-finish", - tokens: usage.tokens, - cost: usage.cost, - }) - await Session.updateMessage(input.assistantMessage) - if (snapshot) { - const patch = await Snapshot.patch(snapshot) - if (patch.files.length) { - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "patch", - hash: patch.hash, - files: patch.files, - }) + case "finish-step": + const usage = Session.getUsage({ + model: input.model, + usage: value.usage, + metadata: value.providerMetadata, + }) + input.assistantMessage.finish = value.finishReason + input.assistantMessage.cost += usage.cost + input.assistantMessage.tokens = usage.tokens + await Session.updatePart({ + id: Identifier.ascending("part"), + reason: value.finishReason, + snapshot: await Snapshot.track(), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "step-finish", + tokens: usage.tokens, + cost: usage.cost, + }) + await Session.updateMessage(input.assistantMessage) + if (snapshot) { + const patch = await Snapshot.patch(snapshot) + if (patch.files.length) { + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + } + snapshot = undefined } - snapshot = undefined - } - SessionSummary.summarize({ - sessionID: input.sessionID, - messageID: input.assistantMessage.parentID, - }) - break + SessionSummary.summarize({ + sessionID: input.sessionID, + messageID: input.assistantMessage.parentID, + }) + break - case "text-start": - currentText = { - id: Identifier.ascending("part"), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "text", - text: "", - time: { - start: Date.now(), - }, - metadata: value.providerMetadata, - } - break + case "text-start": + currentText = { + id: Identifier.ascending("part"), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "text", + text: "", + time: { + start: Date.now(), + }, + metadata: value.providerMetadata, + } + break - case "text-delta": - if (currentText) { - currentText.text += value.text - if (value.providerMetadata) currentText.metadata = value.providerMetadata - if (currentText.text) - await Session.updatePart({ - part: currentText, - delta: value.text, - }) - } - break + case "text-delta": + if (currentText) { + currentText.text += value.text + if (value.providerMetadata) currentText.metadata = value.providerMetadata + if (currentText.text) + await Session.updatePart({ + part: currentText, + delta: value.text, + }) + } + break - case "text-end": - if (currentText) { - currentText.text = currentText.text.trimEnd() - currentText.time = { - start: Date.now(), - end: Date.now(), + case "text-end": + if (currentText) { + currentText.text = currentText.text.trimEnd() + currentText.time = { + start: Date.now(), + end: Date.now(), + } + if (value.providerMetadata) currentText.metadata = value.providerMetadata + await Session.updatePart(currentText) } - if (value.providerMetadata) currentText.metadata = value.providerMetadata - await Session.updatePart(currentText) - } - currentText = undefined - break + currentText = undefined + break - case "finish": - input.assistantMessage.time.completed = Date.now() - await Session.updateMessage(input.assistantMessage) - break + case "finish": + input.assistantMessage.time.completed = Date.now() + await Session.updateMessage(input.assistantMessage) + break - default: - log.info("unhandled", { - ...value, - }) + default: + log.info("unhandled", { + ...value, + }) + continue + } + } + } catch (e) { + log.error("process", { + error: e, + }) + const error = MessageV2.fromError(e, { providerID: input.providerID }) + if (error?.name === "APIError" && error.data.isRetryable) { + attempt++ + const delay = SessionRetry.getRetryDelayInMs(error, attempt) + if (delay) { + await SessionRetry.sleep(delay, input.abort) continue + } } + input.assistantMessage.error = error + Bus.publish(Session.Event.Error, { + sessionID: input.assistantMessage.sessionID, + error: input.assistantMessage.error, + }) } - } catch (e) { - log.error("process", { - error: e, - }) - const error = MessageV2.fromError(e, { providerID: input.providerID }) - input.assistantMessage.error = error - Bus.publish(Session.Event.Error, { - sessionID: input.assistantMessage.sessionID, - error: input.assistantMessage.error, - }) - } - const p = await MessageV2.parts(input.assistantMessage.id) - for (const part of p) { - if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { - await Session.updatePart({ - ...part, - state: { - ...part.state, - status: "error", - error: "Tool execution aborted", - time: { - start: Date.now(), - end: Date.now(), + const p = await MessageV2.parts(input.assistantMessage.id) + for (const part of p) { + if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { + await Session.updatePart({ + ...part, + state: { + ...part.state, + status: "error", + error: "Tool execution aborted", + time: { + start: Date.now(), + end: Date.now(), + }, }, - }, - }) + }) + } } + input.assistantMessage.time.completed = Date.now() + await Session.updateMessage(input.assistantMessage) + if (blocked) return "stop" + if (input.assistantMessage.error) return "stop" + return "continue" } - input.assistantMessage.time.completed = Date.now() - await Session.updateMessage(input.assistantMessage) - return { info: input.assistantMessage, parts: p, blocked } }, } return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e06d856aba6..655cbf66b7e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -283,8 +283,8 @@ export namespace SessionPrompt { using _ = defer(() => cancel(sessionID)) let step = 0 - let retries = 0 while (true) { + if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) let lastUser: MessageV2.User | undefined @@ -295,10 +295,11 @@ export namespace SessionPrompt { const msg = msgs[i] if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant - if (msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant + if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) + lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break const compaction = msg.parts.find((part) => part.type === "compaction") - if (compaction) { + if (compaction && !lastFinished) { tasks.push(compaction) } } @@ -312,9 +313,25 @@ export namespace SessionPrompt { const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) const task = tasks.pop() + // pending compaction + if (task?.type === "compaction") { + await SessionCompaction.process({ + messages: msgs, + parentID: lastUser.id, + abort, + model: { + providerID: model.providerID, + modelID: model.modelID, + }, + sessionID, + }) + continue + } + + // context overflow, needs compaction if ( - task?.type !== "compaction" && lastFinished && + lastFinished.summary !== true && SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info }) ) { const msg = await Session.updateMessage({ @@ -336,97 +353,85 @@ export namespace SessionPrompt { continue } - const result = await iife(async () => { - if (task?.type === "compaction") { - return await SessionCompaction.process({ - messages: msgs, - parentID: lastUser.id, - abort, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - sessionID, - }) - } - - const agent = await Agent.get(lastUser.agent) - msgs = insertReminders({ - messages: msgs, - agent, - }) - const processor = SessionProcessor.create({ - assistantMessage: (await Session.updateMessage({ - id: Identifier.ascending("message"), - parentID: lastUser.id, - role: "assistant", - mode: agent.name, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: model.modelID, - providerID: model.providerID, - time: { - created: Date.now(), - }, - sessionID, - })) as MessageV2.Assistant, - sessionID: sessionID, - model: model.info, - providerID: model.providerID, - abort, - }) - const system = await resolveSystemPrompt({ + // normal processing + const agent = await Agent.get(lastUser.agent) + msgs = insertReminders({ + messages: msgs, + agent, + }) + const processor = SessionProcessor.create({ + assistantMessage: (await Session.updateMessage({ + id: Identifier.ascending("message"), + parentID: lastUser.id, + role: "assistant", + mode: agent.name, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: model.modelID, providerID: model.providerID, - modelID: model.info.id, - agent, - system: lastUser.system, - }) - const tools = await resolveTools({ - agent, - sessionID, - model: lastUser.model, - tools: lastUser.tools, - processor, - }) - const params = await Plugin.trigger( - "chat.params", - { - sessionID: sessionID, - agent: lastUser.agent, - model: model.info, - provider: await Provider.getProvider(model.providerID), - message: lastUser, + time: { + created: Date.now(), }, - { - temperature: model.info.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) - : undefined, - topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), - options: { - ...ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID), - ...model.info.options, - ...agent.options, - }, + sessionID, + })) as MessageV2.Assistant, + sessionID: sessionID, + model: model.info, + providerID: model.providerID, + abort, + }) + const system = await resolveSystemPrompt({ + providerID: model.providerID, + modelID: model.info.id, + agent, + system: lastUser.system, + }) + const tools = await resolveTools({ + agent, + sessionID, + model: lastUser.model, + tools: lastUser.tools, + processor, + }) + const params = await Plugin.trigger( + "chat.params", + { + sessionID: sessionID, + agent: lastUser.agent, + model: model.info, + provider: await Provider.getProvider(model.providerID), + message: lastUser, + }, + { + temperature: model.info.temperature + ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) + : undefined, + topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), + options: { + ...ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID), + ...model.info.options, + ...agent.options, }, - ) + }, + ) - if (step === 1) { - SessionSummary.summarize({ - sessionID: sessionID, - messageID: lastUser.id, - }) - } + if (step === 1) { + SessionSummary.summarize({ + sessionID: sessionID, + messageID: lastUser.id, + }) + } - const stream = streamText({ + const result = await processor.process(() => + streamText({ onError(error) { log.error("stream error", { error, @@ -514,37 +519,10 @@ export namespace SessionPrompt { }, ], }), - }) - - return await processor.process(stream) - }) - - if (result.blocked) break - if (result.info.error?.name === "APIError" && result.info.error.data.isRetryable) { - retries++ - const delay = SessionRetry.getRetryDelayInMs(result.info.error, retries) - if (!delay) break - state()[sessionID].status = { - type: "retry", - attempt: retries, - message: result.info.error.data.message, - } - Bus.publish(Event.Status, { - sessionID, - status: state()[sessionID].status, - }) - await SessionRetry.sleep(delay, abort).catch(() => {}) - state()[sessionID].status = { - type: "busy", - } - Bus.publish(Event.Status, { - sessionID, - status: state()[sessionID].status, - }) - continue - } - retries = 0 - if (result.info.error) break + }), + ) + if (result === "stop") break + continue } SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { From 1f436aa66141a3ddd7893fda86e024ab3c8e4111 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 18:29:39 -0500 Subject: [PATCH 13/29] sync --- packages/opencode/src/server/server.ts | 9 +++++- packages/opencode/src/session/compaction.ts | 35 ++++++++++++++++++--- packages/opencode/src/session/message-v2.ts | 4 +-- packages/opencode/src/session/prompt.ts | 20 +++--------- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 92395d4a5e9..a5fbd896f18 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -793,7 +793,14 @@ export namespace Server { async (c) => { const id = c.req.valid("param").id const body = c.req.valid("json") - await SessionCompaction.run({ ...body, sessionID: id }) + await SessionCompaction.create({ + sessionID: id, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + }) + await SessionPrompt.loop(id) return c.json(true) }, ) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index eeb424ed40f..0bb949ba971 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,6 +14,7 @@ import { Token } from "../util/token" import { Log } from "../util/log" import { ProviderTransform } from "@/provider/transform" import { SessionProcessor } from "./processor" +import { fn } from "@/util/fn" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -94,11 +95,7 @@ export namespace SessionCompaction { abort: AbortSignal }) { const model = await Provider.getModel(input.model.providerID, input.model.modelID) - const system = [ - ...SystemPrompt.summarize(model.providerID), - ...(await SystemPrompt.environment()), - ...(await SystemPrompt.custom()), - ] + const system = [...SystemPrompt.summarize(model.providerID)] const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", @@ -185,4 +182,32 @@ export namespace SessionCompaction { } return "continue" } + + export const create = fn( + z.object({ + sessionID: Identifier.schema("session"), + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }), + }), + async (input) => { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + model: input.model, + sessionID: input.sessionID, + agent: "build", + time: { + created: Date.now(), + }, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + }) + }, + ) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c636b5635e9..fe8c4b13df8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -574,7 +574,7 @@ export namespace MessageV2 { if (part.type === "compaction") { userMessage.parts.push({ type: "text", - text: "The user requested a compaction of the session.", + text: "What did we do so far?", }) } } @@ -695,7 +695,7 @@ export namespace MessageV2 { msg.parts.some((part) => part.type === "compaction") ) break - if (msg.info.summary) break + if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID) } result.reverse() return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 655cbf66b7e..3c0c926f0c5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -271,7 +271,7 @@ export namespace SessionPrompt { return } - async function loop(sessionID: string) { + export const loop = fn(Identifier.schema("session"), async (sessionID) => { const abort = start(sessionID) if (!abort) { return new Promise((resolve, reject) => { @@ -334,21 +334,9 @@ export namespace SessionPrompt { lastFinished.summary !== true && SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info }) ) { - const msg = await Session.updateMessage({ - id: Identifier.ascending("message"), - role: "user", - model: lastUser.model, + await SessionCompaction.create({ sessionID, - agent: lastUser.agent, - time: { - created: Date.now(), - }, - }) - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: msg.id, - sessionID: msg.sessionID, - type: "compaction", + model: lastUser.model, }) continue } @@ -534,7 +522,7 @@ export namespace SessionPrompt { return item } throw new Error("Impossible") - } + }) async function checkOverflow(input: { sessionID: string From f7bf7eca5aaaf951e728000d3477aca59397a8ab Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 18:49:01 -0500 Subject: [PATCH 14/29] core: add subtask support to session system for delegating work to specialized agents --- packages/opencode/src/session/message-v2.ts | 9 ++ packages/opencode/src/session/prompt.ts | 106 ++++++++++++++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index fe8c4b13df8..e0d52ff3cac 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -149,6 +149,14 @@ export namespace MessageV2 { }) export type CompactionPart = z.infer + export const SubtaskPart = PartBase.extend({ + type: z.literal("subtask"), + prompt: z.string(), + description: z.string(), + agent: z.string(), + }) + export type SubtaskPart = z.infer + export const RetryPart = PartBase.extend({ type: z.literal("retry"), attempt: z.number(), @@ -299,6 +307,7 @@ export namespace MessageV2 { export const Part = z .discriminatedUnion("type", [ TextPart, + SubtaskPart, ReasoningPart, FilePart, ToolPart, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3c0c926f0c5..21697a2dcd0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,6 +49,7 @@ import { fn } from "@/util/fn" import { SessionRetry } from "./retry" import { SessionProcessor } from "./processor" import { iife } from "@/util/iife" +import { TaskTool } from "@/tool/task" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -173,6 +174,16 @@ export namespace SessionPrompt { .meta({ ref: "AgentPartInput", }), + MessageV2.SubtaskPart.omit({ + messageID: true, + sessionID: true, + }) + .partial({ + id: true, + }) + .meta({ + ref: "SubtaskPartInput", + }), ]), ), }) @@ -290,7 +301,7 @@ export namespace SessionPrompt { let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined let lastFinished: MessageV2.Assistant | undefined - let tasks: MessageV2.CompactionPart[] = [] + let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User @@ -298,9 +309,9 @@ export namespace SessionPrompt { if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break - const compaction = msg.parts.find((part) => part.type === "compaction") - if (compaction && !lastFinished) { - tasks.push(compaction) + const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") + if (task && !lastFinished) { + tasks.push(...task) } } @@ -313,6 +324,87 @@ export namespace SessionPrompt { const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) const task = tasks.pop() + // pending subtask + if (task?.type === "subtask") { + const taskTool = await TaskTool.init() + const assistantMessage = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "assistant", + parentID: lastUser.id, + sessionID, + mode: task.agent, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: model.modelID, + providerID: model.providerID, + time: { + created: Date.now(), + }, + }) + let part = await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMessage.id, + sessionID: assistantMessage.sessionID, + type: "tool", + callID: ulid(), + tool: TaskTool.id, + state: { + status: "running", + input: { + prompt: task.prompt, + description: task.description, + subagent_type: task.agent, + }, + time: { + start: Date.now(), + }, + }, + }) + const result = await taskTool + .execute( + { + prompt: task.prompt, + description: task.description, + subagent_type: task.agent, + }, + { + agent: task.agent, + messageID: assistantMessage.id, + sessionID: sessionID, + abort, + async metadata(input) { + part = await Session.updatePart({ + ...part, + type: "tool", + state: { + ...(part as any).state, + ...input, + } as any, + } as any) + }, + }, + ) + .catch(() => {}) + await Session.updatePart({ + ...part, + state: { + ...(part as any).state, + type: "completed", + ...result, + }, + } as any) + continue + } + // pending compaction if (task?.type === "compaction") { await SessionCompaction.process({ @@ -1289,8 +1381,10 @@ export namespace SessionPrompt { if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) { parts.push({ - type: "agent", - name: agent.name, + type: "subtask", + agent: agent.name, + description: command.description ?? "", + prompt: command.template, }) } From e1e6a10aef940bf74c9bffe92aac73afc5102dd2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 18:50:41 -0500 Subject: [PATCH 15/29] core: clean up imports and session message handling in prompt system --- packages/opencode/src/session/prompt.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 21697a2dcd0..1eb54a3c5a2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -46,10 +46,9 @@ import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@/util/error" import { fn } from "@/util/fn" -import { SessionRetry } from "./retry" import { SessionProcessor } from "./processor" -import { iife } from "@/util/iife" import { TaskTool } from "@/tool/task" +import type { Message } from "vscode-jsonrpc" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -327,7 +326,7 @@ export namespace SessionPrompt { // pending subtask if (task?.type === "subtask") { const taskTool = await TaskTool.init() - const assistantMessage = await Session.updateMessage({ + const assistantMessage: MessageV2.Assistant = await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", parentID: lastUser.id, @@ -402,6 +401,14 @@ export namespace SessionPrompt { ...result, }, } as any) + await Session.updateMessage({ + ...assistantMessage, + role: "assistant", + time: { + ...assistantMessage.time, + completed: Date.now(), + }, + }) continue } From 65dfd3172889d4bb38bca6443d51c5a98d062151 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:00:54 -0500 Subject: [PATCH 16/29] core: fix subtask message handling and tool execution flow --- packages/opencode/src/session/prompt.ts | 50 +++++++++++++++++-------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1eb54a3c5a2..e8967aac3d6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -326,7 +326,7 @@ export namespace SessionPrompt { // pending subtask if (task?.type === "subtask") { const taskTool = await TaskTool.init() - const assistantMessage: MessageV2.Assistant = await Session.updateMessage({ + const assistantMessage = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", parentID: lastUser.id, @@ -348,8 +348,8 @@ export namespace SessionPrompt { time: { created: Date.now(), }, - }) - let part = await Session.updatePart({ + })) as MessageV2.Assistant + let part = (await Session.updatePart({ id: Identifier.ascending("part"), messageID: assistantMessage.id, sessionID: assistantMessage.sessionID, @@ -367,7 +367,7 @@ export namespace SessionPrompt { start: Date.now(), }, }, - }) + })) as MessageV2.ToolPart const result = await taskTool .execute( { @@ -381,26 +381,18 @@ export namespace SessionPrompt { sessionID: sessionID, abort, async metadata(input) { - part = await Session.updatePart({ + ;(await Session.updatePart({ ...part, type: "tool", state: { - ...(part as any).state, + ...part.state, ...input, - } as any, - } as any) + }, + })) as MessageV2.ToolPart }, }, ) .catch(() => {}) - await Session.updatePart({ - ...part, - state: { - ...(part as any).state, - type: "completed", - ...result, - }, - } as any) await Session.updateMessage({ ...assistantMessage, role: "assistant", @@ -408,7 +400,33 @@ export namespace SessionPrompt { ...assistantMessage.time, completed: Date.now(), }, + finish: "tool-calls", }) + if (result) { + await Session.updatePart({ + ...part, + state: { + ...(part as any).state, + type: "completed", + ...result, + }, + } as any) + } + if (!result) { + await Session.updatePart({ + ...part, + state: { + status: "error", + error: "Tool execution failed", + time: { + start: part.state.status === "running" ? part.state.time.start : Date.now(), + end: Date.now(), + }, + metadata: part.metadata, + input: part.state.input, + }, + } satisfies MessageV2.ToolPart) + } continue } From c682d7b723b7a3829bb062913268bbc5afeae576 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:09:23 -0500 Subject: [PATCH 17/29] core: improve subtask prompt resolution and template handling --- packages/opencode/src/session/prompt.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e8967aac3d6..2350d8d9df4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -324,6 +324,7 @@ export namespace SessionPrompt { const task = tasks.pop() // pending subtask + // TODO: centralize "invoke tool" logic if (task?.type === "subtask") { const taskTool = await TaskTool.init() const assistantMessage = (await Session.updateMessage({ @@ -1384,8 +1385,6 @@ export namespace SessionPrompt { } template = template.trim() - const parts = await resolvePromptParts(template) - const model = await (async () => { if (command.model) { return Provider.parseModel(command.model) @@ -1401,17 +1400,20 @@ export namespace SessionPrompt { } return await Provider.defaultModel() })() - const agent = await Agent.get(agentName) - if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) { - parts.push({ - type: "subtask", - agent: agent.name, - description: command.description ?? "", - prompt: command.template, - }) - } + const parts = + (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true + ? [ + { + type: "subtask" as const, + agent: agent.name, + description: command.description ?? "", + // TODO: how can we make task tool accept a more complex input? + prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""), + }, + ] + : await resolvePromptParts(template) const result = (await prompt({ sessionID: input.sessionID, From 0b35b1b1443c8edc326c9a4db6e7e3b6502abfc8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:10:34 -0500 Subject: [PATCH 18/29] core: add subtask message display to user conversation history --- packages/opencode/src/session/message-v2.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e0d52ff3cac..bd6033f4b40 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -586,6 +586,12 @@ export namespace MessageV2 { text: "What did we do so far?", }) } + if (part.type === "subtask") { + userMessage.parts.push({ + type: "text", + text: `Running subtask ${part.agent}: ${part.description}`, + }) + } } } From 9790277a593d8cf459bff0ebdf4fc59aada5ff5c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:16:41 -0500 Subject: [PATCH 19/29] core: fix assistant message completion time and finish state handling --- packages/opencode/src/session/prompt.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2350d8d9df4..9fce3eaa04b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -394,15 +394,9 @@ export namespace SessionPrompt { }, ) .catch(() => {}) - await Session.updateMessage({ - ...assistantMessage, - role: "assistant", - time: { - ...assistantMessage.time, - completed: Date.now(), - }, - finish: "tool-calls", - }) + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + await Session.updateMessage(assistantMessage) if (result) { await Session.updatePart({ ...part, From 163d777b9acb3dfd4ca55e55862ca9a0164c8275 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:19:39 -0500 Subject: [PATCH 20/29] core: fix tool part type assertion in session metadata update --- packages/opencode/src/session/prompt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9fce3eaa04b..3213e0d2057 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -382,14 +382,14 @@ export namespace SessionPrompt { sessionID: sessionID, abort, async metadata(input) { - ;(await Session.updatePart({ + await Session.updatePart({ ...part, type: "tool", state: { ...part.state, ...input, }, - })) as MessageV2.ToolPart + } satisfies MessageV2.ToolPart) }, }, ) From d024bf64680c80674691c3a8f035f42240339059 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:26:14 -0500 Subject: [PATCH 21/29] tui: fix subtask message formatting to show tool execution instead of subtask description --- packages/opencode/src/session/message-v2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index bd6033f4b40..690873567e0 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -589,7 +589,7 @@ export namespace MessageV2 { if (part.type === "subtask") { userMessage.parts.push({ type: "text", - text: `Running subtask ${part.agent}: ${part.description}`, + text: "The following tool was executed by the user", }) } } From 5f770024ccf8c869eec6b8597dc000db24d7249e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:30:32 -0500 Subject: [PATCH 22/29] tui: fix completed state property name from type to status in session metadata --- 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 3213e0d2057..f6294246193 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -402,7 +402,7 @@ export namespace SessionPrompt { ...part, state: { ...(part as any).state, - type: "completed", + status: "completed", ...result, }, } as any) From 9026705fdbd5749a618c33583d5b3e781deb279e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:49:19 -0500 Subject: [PATCH 23/29] core: add debug logging for assistant message loop exit conditions --- packages/opencode/src/session/prompt.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f6294246193..0291f506ec0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -315,7 +315,10 @@ export namespace SessionPrompt { } if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) break + if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) { + log.info("exiting loop", { id: lastAssistant?.id }) + break + } log.info("last assistant", { id: lastAssistant?.id }) step++ From b84672d7672c552c311d3bf9bd850fd205b0d640 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:55:07 -0500 Subject: [PATCH 24/29] core: add debug logging for session cancellation and message loop steps --- packages/opencode/src/session/prompt.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0291f506ec0..dae7472537c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -266,6 +266,7 @@ export namespace SessionPrompt { } export function cancel(sessionID: string) { + log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] if (!match) return @@ -294,6 +295,7 @@ export namespace SessionPrompt { let step = 0 while (true) { + log.info("loop", { step }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) From 47e0e0ae1f81ee434eba4434899665bf27d72755 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:57:03 -0500 Subject: [PATCH 25/29] core: remove debug logging from session prompt and message loop --- packages/opencode/src/session/prompt.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dae7472537c..0982d7c4737 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -237,9 +237,6 @@ export namespace SessionPrompt { } export const prompt = fn(PromptInput, async (input) => { - const l = log.clone().tag("session", input.sessionID) - l.info("prompt", input) - const session = await Session.get(input.sessionID) await SessionRevert.cleanup(session) @@ -295,7 +292,7 @@ export namespace SessionPrompt { let step = 0 while (true) { - log.info("loop", { step }) + log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) @@ -318,10 +315,9 @@ export namespace SessionPrompt { if (!lastUser) throw new Error("No user message found in stream. This should never happen.") if (lastAssistant?.finish && lastAssistant.finish !== "tool-calls" && lastUser.id < lastAssistant.id) { - log.info("exiting loop", { id: lastAssistant?.id }) + log.info("exiting loop", { sessionID }) break } - log.info("last assistant", { id: lastAssistant?.id }) step++ From eea9346e97193aeabbf8eea457c7262943138d69 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 19:59:48 -0500 Subject: [PATCH 26/29] core: fix session cancellation cleanup to prevent memory leaks in task tool --- packages/opencode/src/tool/task.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d127fba38f7..8f27f570b5d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -7,6 +7,7 @@ import { MessageV2 } from "../session/message-v2" import { Identifier } from "../id/id" import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" +import { defer } from "@/util/defer" export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) @@ -61,9 +62,11 @@ export const TaskTool = Tool.define("task", async () => { providerID: msg.info.providerID, } - ctx.abort.addEventListener("abort", () => { + function cancel() { SessionPrompt.cancel(session.id) - }) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) const result = await SessionPrompt.prompt({ messageID, From 5dde596aa133b661a9400dbc6a5dfc9552e4b51b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 20:11:48 -0500 Subject: [PATCH 27/29] core: fix tool part state management to properly handle completion status and metadata --- packages/opencode/src/session/prompt.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0982d7c4737..f98c1f75ffa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -398,15 +398,22 @@ export namespace SessionPrompt { assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) - if (result) { + if (result && part.state.status === "running") { await Session.updatePart({ ...part, state: { - ...(part as any).state, status: "completed", - ...result, + input: part.state.input, + title: result.title, + metadata: result.metadata, + output: result.output, + attachments: result.attachments, + time: { + ...part.state.time, + end: Date.now(), + }, }, - } as any) + } satisfies MessageV2.ToolPart) } if (!result) { await Session.updatePart({ From bbcb7a28218833d785e62e1d69b0bdc0a09e16ba Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 16 Nov 2025 20:41:44 -0500 Subject: [PATCH 28/29] core: extract overflow check logic into separate function for better code organization --- packages/opencode/src/session/prompt.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f98c1f75ffa..5bd6a249623 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -320,6 +320,14 @@ export namespace SessionPrompt { } step++ + if (step === 1) + ensureTitle({ + session: await Session.get(sessionID), + modelID: lastUser.model.modelID, + providerID: lastUser.model.providerID, + message: msgs.find((m) => m.info.role === "user")!, + history: msgs, + }) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) const task = tasks.pop() @@ -644,21 +652,6 @@ export namespace SessionPrompt { throw new Error("Impossible") }) - async function checkOverflow(input: { - sessionID: string - msgs: MessageV2.WithParts[] - model: ModelsDev.Model - abort: AbortSignal - }) { - const lastAssistant = input.msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.time.completed) - ?.info as MessageV2.Assistant - if (!lastAssistant) return false - return SessionCompaction.isOverflow({ - tokens: lastAssistant.tokens, - model: input.model, - }) - } - async function resolveModel(input: { model: PromptInput["model"]; agent: Agent.Info }) { if (input.model) { return input.model From e6d549497c8024da76165b6306e1ea754ae1e34d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 17 Nov 2025 10:55:30 -0500 Subject: [PATCH 29/29] sync --- .opencode/{opencode.json => opencode.jsonc} | 8 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 53 +++++++--------- packages/opencode/src/server/server.ts | 5 +- packages/opencode/src/session/processor.ts | 9 ++- packages/opencode/src/session/prompt.ts | 58 ++--------------- packages/opencode/src/session/status.ts | 63 +++++++++++++++++++ 6 files changed, 105 insertions(+), 91 deletions(-) rename .opencode/{opencode.json => opencode.jsonc} (60%) create mode 100644 packages/opencode/src/session/status.ts diff --git a/.opencode/opencode.json b/.opencode/opencode.jsonc similarity index 60% rename from .opencode/opencode.json rename to .opencode/opencode.jsonc index 2ec720efbff..02278ce3afe 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.jsonc @@ -3,7 +3,9 @@ "plugin": ["opencode-openai-codex-auth"], "provider": { "opencode": { - "options": {} - } - } + "options": { + // "baseURL": "http://localhost:8080" + }, + }, + }, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index fb8f9860a17..192123e0087 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -20,21 +20,12 @@ import { useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, - TextAttributes, addDefaultParsers, MacOSScrollAccel, type ScrollAcceleration, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { - AssistantMessage, - Part, - ToolPart, - UserMessage, - TextPart, - ReasoningPart, - CompactionPart, -} from "@opencode-ai/sdk" +import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -674,13 +665,6 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) - const status = createMemo( - () => - sync.data.session_status[route.sessionID] ?? { - type: "idle", - }, - ) - return ( @@ -829,17 +813,6 @@ export function Session() { )} - - - {Locale.titlecase(lastUserMessage().agent)} - - - - {(status() as any).message} [retry #{(status() as any).attempt}] - - - - (prompt = r)} @@ -957,6 +930,13 @@ function UserMessage(props: { function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { const local = useLocal() const { theme } = useTheme() + const sync = useSync() + const status = createMemo( + () => + sync.data.session_status[props.message.sessionID] ?? { + type: "idle", + }, + ) return ( <> @@ -974,9 +954,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) }} - + {props.message.error?.data.message} + + + {Locale.titlecase(props.message.mode)} + + + + {(status() as any).message} [attempt #{(status() as any).attempt}] + + + + { - const result = SessionPrompt.status() + const result = SessionStatus.list() return c.json(result) }, ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 7b3ad90b5f9..de96c5eeaa5 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -10,6 +10,7 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "./summary" import { Bus } from "@/bus" import { SessionRetry } from "./retry" +import { SessionStatus } from "./status" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -49,6 +50,7 @@ export namespace SessionProcessor { input.abort.throwIfAborted() switch (value.type) { case "start": + SessionStatus.set(input.sessionID, { type: "busy" }) break case "reasoning-start": @@ -325,7 +327,12 @@ export namespace SessionProcessor { attempt++ const delay = SessionRetry.getRetryDelayInMs(error, attempt) if (delay) { - await SessionRetry.sleep(delay, input.abort) + SessionStatus.set(input.sessionID, { + type: "retry", + attempt, + message: error.data.message, + }) + await SessionRetry.sleep(delay, input.abort).catch(() => {}) continue } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5bd6a249623..b8f3d112d2c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -28,9 +28,8 @@ import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" -import { ModelsDev } from "../provider/models" import { defer } from "../util/defer" -import { mapValues, mergeDeep, pipe } from "remeda" +import { mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" @@ -48,39 +47,13 @@ import { NamedError } from "@/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" -import type { Message } from "vscode-jsonrpc" +import { SessionStatus } from "./status" export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = 32_000 - export const Status = z - .union([ - z.object({ - type: z.literal("idle"), - }), - z.object({ - type: z.literal("retry"), - attempt: z.number(), - message: z.string(), - }), - z.object({ - type: z.literal("busy"), - }), - ]) - .meta({ - ref: "SessionStatus", - }) - export type Status = z.infer - export const Event = { - Status: Bus.event( - "session.status", - z.object({ - sessionID: z.string(), - status: Status, - }), - ), Idle: Bus.event( "session.idle", z.object({ @@ -95,7 +68,6 @@ export namespace SessionPrompt { string, { abort: AbortController - status: Status callbacks: { resolve(input: MessageV2.WithParts): void reject(): void @@ -111,21 +83,9 @@ export namespace SessionPrompt { }, ) - export function status() { - return mapValues(state(), (item) => item.status) - } - - export function getStatus(sessionID: string) { - return ( - state()[sessionID]?.status ?? { - type: "idle", - } - ) - } - export function assertNotBusy(sessionID: string) { - const status = getStatus(sessionID) - if (status?.type !== "idle") throw new Session.BusyError(sessionID) + const match = state()[sessionID] + if (match) throw new Session.BusyError(sessionID) } export const PromptInput = z.object({ @@ -252,13 +212,8 @@ export namespace SessionPrompt { const controller = new AbortController() s[sessionID] = { abort: controller, - status: { type: "busy" }, callbacks: [], } - Bus.publish(Event.Status, { - sessionID, - status: s[sessionID].status, - }) return controller.signal } @@ -272,10 +227,7 @@ export namespace SessionPrompt { item.reject() } delete s[sessionID] - Bus.publish(Event.Status, { - sessionID, - status: { type: "idle" }, - }) + SessionStatus.set(sessionID, { type: "idle" }) return } diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts new file mode 100644 index 00000000000..ecac222f894 --- /dev/null +++ b/packages/opencode/src/session/status.ts @@ -0,0 +1,63 @@ +import { Bus } from "@/bus" +import { Instance } from "@/project/instance" +import z from "zod" + +export namespace SessionStatus { + export const Info = z + .union([ + z.object({ + type: z.literal("idle"), + }), + z.object({ + type: z.literal("retry"), + attempt: z.number(), + message: z.string(), + }), + z.object({ + type: z.literal("busy"), + }), + ]) + .meta({ + ref: "SessionStatus", + }) + export type Info = z.infer + + export const Event = { + Status: Bus.event( + "session.status", + z.object({ + sessionID: z.string(), + status: Info, + }), + ), + } + + const state = Instance.state(() => { + const data: Record = {} + return data + }) + + export function get(sessionID: string) { + return ( + state()[sessionID] ?? { + type: "idle", + } + ) + } + + export function list() { + return Object.values(state()) + } + + export function set(sessionID: string, status: Info) { + Bus.publish(Event.Status, { + sessionID, + status, + }) + if (status.type === "idle") { + delete state()[sessionID] + return + } + state()[sessionID] = status + } +}