From 5719a13cbfc4d857a5765029ccda4cd24c9ed5c1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 27 Mar 2026 22:29:01 -0400 Subject: [PATCH 01/66] refactor(session): effectify SessionPrompt service Migrate SessionPrompt to the Effect service pattern (Interface, Service, Layer, InstanceState, makeRuntime + async facades). Key design decisions: - Fiber-based cancellation replaces manual AbortController management. Effect.promise((signal) => ...) derives AbortSignals automatically; cancel() interrupts fibers and signals propagate to the AI SDK, shell processes, and tool execution. - Deferred queue replaces Promise callback queue. Concurrent loop() callers get a Deferred that resolves when the running fiber finishes. On cancel or error, queued callers now receive proper errors instead of hanging forever. - Separate loops/shells maps in InstanceState replace the single shared state object, with shell-to-loop handoff preserved: if callers queue a loop while a shell is running, shellE cleanup starts the loop. - Heavy helper functions (createUserMessage, handleSubtask, shellImpl, resolveCommand, insertReminders, ensureTitle) stay as plain async functions called via Effect.promise, keeping the migration incremental. - resolveTools and createStructuredOutputTool are unchanged (deeply tied to AI SDK tool callbacks). --- .../opencode/src/server/routes/session.ts | 2 +- packages/opencode/src/session/prompt.ts | 1362 +++++++++-------- packages/opencode/src/session/revert.ts | 4 +- .../test/session/prompt-concurrency.test.ts | 181 +++ 4 files changed, 868 insertions(+), 681 deletions(-) create mode 100644 packages/opencode/test/session/prompt-concurrency.test.ts diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index d499e5a1ecf4..4288703689c8 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -699,7 +699,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - SessionPrompt.assertNotBusy(params.sessionID) + await SessionPrompt.assertNotBusy(params.sessionID) await Session.removeMessage({ sessionID: params.sessionID, messageID: params.messageID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index acc9f635953c..5d47e7ba4245 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -37,7 +37,6 @@ import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" -import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" @@ -49,6 +48,9 @@ import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" +import { Deferred, Effect, Fiber, Layer, Scope, ServiceMap } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -66,30 +68,452 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) - const state = Instance.state( - () => { - const data: Record< - string, - { - abort: AbortController - callbacks: { - resolve(input: MessageV2.WithParts): void - reject(reason?: any): void - }[] + interface LoopEntry { + fiber?: Fiber.Fiber + queue: Deferred.Deferred[] + } + + export interface Interface { + readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect + readonly loop: (input: z.infer) => Effect.Effect + readonly shell: (input: ShellInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect + readonly resolvePromptParts: (template: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/SessionPrompt") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const status = yield* SessionStatus.Service + const scope = yield* Scope.Scope + + const cache = yield* InstanceState.make( + Effect.fn("SessionPrompt.state")(function* () { + const loops = new Map() + const shells = new Map>() + yield* Effect.addFinalizer(() => + Effect.forEach( + [...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []), ...shells.values()], + (fiber) => Fiber.interrupt(fiber), + ), + ) + return { loops, shells } + }), + ) + + const assertNotBusy = Effect.fn("SessionPrompt.assertNotBusy")(function* (sessionID: SessionID) { + const s = yield* InstanceState.get(cache) + if (s.loops.has(sessionID) || s.shells.has(sessionID)) throw new Session.BusyError(sessionID) + }) + + const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { + log.info("cancel", { sessionID }) + const s = yield* InstanceState.get(cache) + const loopEntry = s.loops.get(sessionID) + const shellEntry = s.shells.get(sessionID) + if (!loopEntry && !shellEntry) { + yield* status.set(sessionID, { type: "idle" }) + return } - > = {} - return data - }, - async (current) => { - for (const item of Object.values(current)) { - item.abort.abort() - } - }, + if (loopEntry) { + if (loopEntry.fiber) yield* Fiber.interrupt(loopEntry.fiber) + for (const d of loopEntry.queue) yield* Deferred.interrupt(d) + s.loops.delete(sessionID) + } + if (shellEntry) { + yield* Fiber.interrupt(shellEntry) + s.shells.delete(sessionID) + } + yield* status.set(sessionID, { type: "idle" }) + }) + + const resolvePromptPartsE = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { + return yield* Effect.promise(() => resolvePromptPartsImpl(template)) + }) + + const promptE = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { + const session = yield* Effect.promise(() => Session.get(input.sessionID)) + yield* Effect.promise(() => SessionRevert.cleanup(session)) + const message = yield* Effect.promise(() => createUserMessage(input)) + yield* Effect.promise(() => Session.touch(input.sessionID)) + + const permissions: Permission.Ruleset = [] + for (const [t, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) + } + if (permissions.length > 0) { + session.permission = permissions + yield* Effect.promise(() => Session.setPermission({ sessionID: session.id, permission: permissions })) + } + + if (input.noReply === true) return message + return yield* loopE({ sessionID: input.sessionID }) + }) + + const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) { + let structured: unknown | undefined + let step = 0 + const session = yield* Effect.promise(() => Session.get(sessionID)) + + while (true) { + yield* status.set(sessionID, { type: "busy" }) + log.info("loop", { step, sessionID }) + + let msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(sessionID))) + + let lastUser: MessageV2.User | undefined + let lastAssistant: MessageV2.Assistant | undefined + let lastFinished: MessageV2.Assistant | undefined + 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 + if (!lastAssistant && msg.info.role === "assistant") lastAssistant = 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 task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") + if (task && !lastFinished) tasks.push(...task) + } + + if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + if ( + lastAssistant?.finish && + !["tool-calls"].includes(lastAssistant.finish) && + lastUser.id < lastAssistant.id + ) { + log.info("exiting loop", { sessionID }) + break + } + + step++ + if (step === 1) + yield* Effect.promise(() => + ensureTitle({ + session, + modelID: lastUser.model.modelID, + providerID: lastUser.model.providerID, + history: msgs, + }), + ).pipe(Effect.ignore, Effect.forkIn(scope)) + + const model = yield* Effect.promise(() => + Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => { + if (Provider.ModelNotFoundError.isInstance(e)) { + const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ + message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, + }).toObject(), + }) + } + throw e + }), + ) + const task = tasks.pop() + + if (task?.type === "subtask") { + yield* Effect.promise((signal) => + handleSubtask({ task, model, lastUser: lastUser!, sessionID, session, msgs, signal }), + ) + continue + } + + if (task?.type === "compaction") { + const result = yield* Effect.promise((signal) => + SessionCompaction.process({ + messages: msgs, + parentID: lastUser!.id, + abort: signal, + sessionID, + auto: task.auto, + overflow: task.overflow, + }), + ) + if (result === "stop") break + continue + } + + if ( + lastFinished && + lastFinished.summary !== true && + (yield* Effect.promise(() => SessionCompaction.isOverflow({ tokens: lastFinished!.tokens, model }))) + ) { + yield* Effect.promise(() => + SessionCompaction.create({ sessionID, agent: lastUser!.agent, model: lastUser!.model, auto: true }), + ) + continue + } + + const agent = yield* Effect.promise(() => Agent.get(lastUser!.agent)) + if (!agent) { + const available = yield* Effect.promise(() => + Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)), + ) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser!.agent}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + throw error + } + const maxSteps = agent.steps ?? Infinity + const isLastStep = step >= maxSteps + msgs = yield* Effect.promise(() => insertReminders({ messages: msgs, agent, session })) + + const msg = yield* Effect.promise(() => + Session.updateMessage({ + id: MessageID.ascending(), + parentID: lastUser!.id, + role: "assistant", + mode: agent.name, + agent: agent.name, + variant: lastUser!.variant, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.id, + providerID: model.providerID, + time: { created: Date.now() }, + sessionID, + }), + ) + const processor = yield* Effect.promise((signal) => + Promise.resolve(SessionProcessor.create({ + assistantMessage: msg as MessageV2.Assistant, + sessionID, + model, + abort: signal, + })), + ) + + const outcome: "break" | "continue" = yield* Effect.ensuring( + Effect.gen(function* () { + const lastUserMsg = msgs.findLast((m) => m.info.role === "user") + const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + + const tools = yield* Effect.promise(() => + resolveTools({ agent, session, model, tools: lastUser!.tools, processor, bypassAgentCheck, messages: msgs }), + ) + + if (lastUser!.format?.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: lastUser!.format.schema, + onSuccess(output) { + structured = output + }, + }) + } + + if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser!.id }) + + if (step > 1 && lastFinished) { + for (const m of msgs) { + if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue + for (const p of m.parts) { + if (p.type !== "text" || p.ignored || p.synthetic) continue + if (!p.text.trim()) continue + p.text = [ + "", + "The user sent the following message:", + p.text, + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n") + } + } + } + + yield* Effect.promise(() => Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })) + + const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() => + Promise.all([ + SystemPrompt.skills(agent), + SystemPrompt.environment(model), + InstructionPrompt.system(), + MessageV2.toModelMessages(msgs, model), + ]), + ) + const system = [...env, ...(skills ? [skills] : []), ...instructions] + const format = lastUser!.format ?? { type: "text" as const } + if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + const result = yield* Effect.promise((signal) => + processor.process({ + user: lastUser!, + agent, + permission: session.permission, + abort: signal, + sessionID, + system, + messages: [ + ...modelMsgs, + ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), + ], + tools, + model, + toolChoice: format.type === "json_schema" ? "required" : undefined, + }), + ) + + if (structured !== undefined) { + processor.message.structured = structured + processor.message.finish = processor.message.finish ?? "stop" + yield* Effect.promise(() => Session.updateMessage(processor.message)) + return "break" as const + } + + const finished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish) + if (finished && !processor.message.error) { + if (format.type === "json_schema") { + processor.message.error = new MessageV2.StructuredOutputError({ + message: "Model did not produce structured output", + retries: 0, + }).toObject() + yield* Effect.promise(() => Session.updateMessage(processor.message)) + return "break" as const + } + } + + if (result === "stop") return "break" as const + if (result === "compact") { + yield* Effect.promise(() => + SessionCompaction.create({ + sessionID, + agent: lastUser!.agent, + model: lastUser!.model, + auto: true, + overflow: !processor.message.finish, + }), + ) + } + return "continue" as const + }), + Effect.sync(() => InstructionPrompt.clear(processor.message.id)), + ) + if (outcome === "break") break + continue + } + + SessionCompaction.prune({ sessionID }) + return yield* Effect.promise(async () => { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user") continue + return item + } + throw new Error("Impossible") + }) + }) + + type State = { loops: Map; shells: Map> } + + const startLoop = Effect.fnUntraced(function* (s: State, sessionID: SessionID) { + const fiber = yield* runLoop(sessionID).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + const entry = s.loops.get(sessionID) + if (entry) { + for (const d of entry.queue) yield* Deferred.done(d, exit) + } + s.loops.delete(sessionID) + yield* status.set(sessionID, { type: "idle" }) + }), + ), + Effect.forkChild, + ) + const entry = s.loops.get(sessionID) + if (entry) { + // Queue already exists (created while shell was running) — attach fiber + entry.fiber = fiber + } else { + s.loops.set(sessionID, { fiber, queue: [] }) + } + return yield* Fiber.join(fiber).pipe(Effect.orDie) + }) + + const loopE = Effect.fn("SessionPrompt.loop")(function* (input: z.infer) { + const s = yield* InstanceState.get(cache) + const existing = s.loops.get(input.sessionID) + + if (existing) { + const d = yield* Deferred.make() + existing.queue.push(d) + return yield* Deferred.await(d).pipe(Effect.orDie) + } + + // If a shell is running, queue — shell cleanup will start the loop + if (s.shells.has(input.sessionID)) { + const d = yield* Deferred.make() + s.loops.set(input.sessionID, { queue: [d] }) + return yield* Deferred.await(d).pipe(Effect.orDie) + } + + return yield* startLoop(s, input.sessionID) + }) + + const shellE = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) { + const s = yield* InstanceState.get(cache) + if (s.loops.has(input.sessionID) || s.shells.has(input.sessionID)) { + throw new Session.BusyError(input.sessionID) + } + + const fiber = yield* Effect.promise((signal) => shellImpl(input, signal)).pipe( + Effect.ensuring( + Effect.gen(function* () { + const s = yield* InstanceState.get(cache) + s.shells.delete(input.sessionID) + // If callers queued a loop while the shell was running, start it + const pending = s.loops.get(input.sessionID) + if (pending && pending.queue.length > 0) { + yield* startLoop(s, input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope)) + } else { + yield* status.set(input.sessionID, { type: "idle" }) + } + }), + ), + Effect.forkChild, + ) + + s.shells.set(input.sessionID, fiber) + return yield* Fiber.join(fiber).pipe(Effect.orDie) + }) + + const commandE = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { + const resolved = yield* Effect.promise(() => resolveCommand(input)) + const result = yield* promptE(resolved.promptInput) + yield* bus.publish(Command.Event.Executed, { + name: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: result.info.id, + }) + return result + }) + + return Service.of({ + assertNotBusy, + cancel, + prompt: promptE, + loop: loopE, + shell: shellE, + command: commandE, + resolvePromptParts: resolvePromptPartsE, + }) + }), + ) + + const defaultLayer = layer.pipe( + Layer.provide(SessionStatus.layer), + Layer.provide(Bus.layer), ) + const { runPromise } = makeRuntime(Service, defaultLayer) - export function assertNotBusy(sessionID: SessionID) { - const match = state()[sessionID] - if (match) throw new Session.BusyError(sessionID) + export async function assertNotBusy(sessionID: SessionID) { + return runPromise((svc) => svc.assertNotBusy(sessionID)) } export const PromptInput = z.object({ @@ -159,36 +583,78 @@ export namespace SessionPrompt { }) export type PromptInput = z.infer - export const prompt = fn(PromptInput, async (input) => { - const session = await Session.get(input.sessionID) - await SessionRevert.cleanup(session) - - const message = await createUserMessage(input) - await Session.touch(input.sessionID) - - // this is backwards compatibility for allowing `tools` to be specified when - // prompting - const permissions: Permission.Ruleset = [] - for (const [tool, enabled] of Object.entries(input.tools ?? {})) { - permissions.push({ - permission: tool, - action: enabled ? "allow" : "deny", - pattern: "*", + export async function prompt(input: PromptInput) { + return runPromise((svc) => svc.prompt(input)) + } + + export async function resolvePromptParts(template: string) { + return runPromise((svc) => svc.resolvePromptParts(template)) + } + + export async function cancel(sessionID: SessionID) { + return runPromise((svc) => svc.cancel(sessionID)) + } + + export const LoopInput = z.object({ + sessionID: SessionID.zod, + }) + + export async function loop(input: z.infer) { + return runPromise((svc) => svc.loop(input)) + } + + export const ShellInput = z.object({ + sessionID: SessionID.zod, + agent: z.string(), + model: z + .object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, }) - } - if (permissions.length > 0) { - session.permission = permissions - await Session.setPermission({ sessionID: session.id, permission: permissions }) - } + .optional(), + command: z.string(), + }) + export type ShellInput = z.infer - if (input.noReply === true) { - return message - } + export async function shell(input: ShellInput) { + return runPromise((svc) => svc.shell(input)) + } - return loop({ sessionID: input.sessionID }) + export const CommandInput = z.object({ + messageID: MessageID.zod.optional(), + sessionID: SessionID.zod, + agent: z.string().optional(), + model: z.string().optional(), + arguments: z.string(), + command: z.string(), + variant: z.string().optional(), + parts: z + .array( + z.discriminatedUnion("type", [ + MessageV2.FilePart.omit({ + messageID: true, + sessionID: true, + }).partial({ + id: true, + }), + ]), + ) + .optional(), }) + export type CommandInput = z.infer + + export async function command(input: CommandInput) { + return runPromise((svc) => svc.command(input)) + } - export async function resolvePromptParts(template: string): Promise { + async function lastModelImpl(sessionID: SessionID) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + return Provider.defaultModel() + } + + async function resolvePromptPartsImpl(template: string): Promise { const parts: PromptInput["parts"] = [ { type: "text", @@ -239,533 +705,152 @@ export namespace SessionPrompt { return parts } - function start(sessionID: SessionID) { - const s = state() - if (s[sessionID]) return - const controller = new AbortController() - s[sessionID] = { - abort: controller, - callbacks: [], - } - return controller.signal - } - - function resume(sessionID: SessionID) { - const s = state() - if (!s[sessionID]) return - - return s[sessionID].abort.signal - } - - export async function cancel(sessionID: SessionID) { - log.info("cancel", { sessionID }) - const s = state() - const match = s[sessionID] - if (!match) { - await SessionStatus.set(sessionID, { type: "idle" }) - return + async function handleSubtask(input: { + task: MessageV2.SubtaskPart + model: Provider.Model + lastUser: MessageV2.User + sessionID: SessionID + session: Session.Info + msgs: MessageV2.WithParts[] + signal: AbortSignal + }) { + const { task, model, lastUser, sessionID, session, msgs, signal } = input + const taskTool = await TaskTool.init() + const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model + const assistantMessage = (await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: lastUser.id, + sessionID, + mode: task.agent, + agent: task.agent, + variant: lastUser.variant, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: taskModel.id, + providerID: taskModel.providerID, + time: { created: Date.now() }, + })) as MessageV2.Assistant + let part = (await Session.updatePart({ + id: PartID.ascending(), + 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, command: task.command }, + time: { start: Date.now() }, + }, + })) as MessageV2.ToolPart + const taskArgs = { + prompt: task.prompt, + description: task.description, + subagent_type: task.agent, + command: task.command, } - match.abort.abort() - delete s[sessionID] - await SessionStatus.set(sessionID, { type: "idle" }) - return - } - - export const LoopInput = z.object({ - sessionID: SessionID.zod, - resume_existing: z.boolean().optional(), - }) - export const loop = fn(LoopInput, async (input) => { - const { sessionID, resume_existing } = input - - const abort = resume_existing ? resume(sessionID) : start(sessionID) - if (!abort) { - return new Promise((resolve, reject) => { - const callbacks = state()[sessionID].callbacks - callbacks.push({ resolve, reject }) - }) + await Plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs }) + let executionError: Error | undefined + const taskAgent = await Agent.get(task.agent) + if (!taskAgent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + throw error } - - await using _ = defer(() => cancel(sessionID)) - - // Structured output state - // Note: On session resumption, state is reset but outputFormat is preserved - // on the user message and will be retrieved from lastUser below - let structuredOutput: unknown | undefined - - let step = 0 - const session = await Session.get(sessionID) - while (true) { - await SessionStatus.set(sessionID, { type: "busy" }) - log.info("loop", { step, sessionID }) - if (abort.aborted) break - let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) - - let lastUser: MessageV2.User | undefined - let lastAssistant: MessageV2.Assistant | undefined - let lastFinished: MessageV2.Assistant | undefined - 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 - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = 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 task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") - if (task && !lastFinished) { - tasks.push(...task) - } - } - - if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - if ( - lastAssistant?.finish && - ![ - "tool-calls", - // in v6 unknown became other but other existed in v5 too and was distinctly different - // I think there are certain providers that used to have bad stop reasons, not rlly sure which - // ones if any still have this? - // "unknown", - ].includes(lastAssistant.finish) && - lastUser.id < lastAssistant.id - ) { - log.info("exiting loop", { sessionID }) - break - } - - step++ - if (step === 1) - ensureTitle({ - session, - modelID: lastUser.model.modelID, - providerID: lastUser.model.providerID, - history: msgs, - }) - - const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { - if (Provider.ModelNotFoundError.isInstance(e)) { - const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ - message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, - }).toObject(), - }) - } - throw e - }) - const task = tasks.pop() - - // pending subtask - // TODO: centralize "invoke tool" logic - if (task?.type === "subtask") { - const taskTool = await TaskTool.init() - const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model - const assistantMessage = (await Session.updateMessage({ - id: MessageID.ascending(), - role: "assistant", - parentID: lastUser.id, - sessionID, - mode: task.agent, - agent: task.agent, - variant: lastUser.variant, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: taskModel.id, - providerID: taskModel.providerID, - time: { - created: Date.now(), - }, - })) as MessageV2.Assistant - let part = (await Session.updatePart({ - id: PartID.ascending(), - messageID: assistantMessage.id, - sessionID: assistantMessage.sessionID, + const taskCtx: Tool.Context = { + agent: task.agent, + messageID: assistantMessage.id, + sessionID, + abort: signal, + callID: part.callID, + extra: { bypassAgentCheck: true }, + messages: msgs, + async metadata(val) { + part = (await Session.updatePart({ + ...part, type: "tool", - callID: ulid(), - tool: TaskTool.id, - state: { - status: "running", - input: { - prompt: task.prompt, - description: task.description, - subagent_type: task.agent, - command: task.command, - }, - time: { - start: Date.now(), - }, - }, - })) as MessageV2.ToolPart - const taskArgs = { - prompt: task.prompt, - description: task.description, - subagent_type: task.agent, - command: task.command, - } - await Plugin.trigger( - "tool.execute.before", - { - tool: "task", - sessionID, - callID: part.id, - }, - { args: taskArgs }, - ) - let executionError: Error | undefined - const taskAgent = await Agent.get(task.agent) - if (!taskAgent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID, - error: error.toObject(), - }) - throw error - } - const taskCtx: Tool.Context = { - agent: task.agent, - messageID: assistantMessage.id, - sessionID: sessionID, - abort, - callID: part.callID, - extra: { bypassAgentCheck: true }, - messages: msgs, - async metadata(input) { - part = (await Session.updatePart({ - ...part, - type: "tool", - state: { - ...part.state, - ...input, - }, - } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart - }, - async ask(req) { - await Permission.ask({ - ...req, - sessionID: sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }) - }, - } - const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { - executionError = error - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }) - const attachments = result?.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID, - messageID: assistantMessage.id, - })) - await Plugin.trigger( - "tool.execute.after", - { - tool: "task", - sessionID, - callID: part.id, - args: taskArgs, - }, - result, - ) - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - await Session.updateMessage(assistantMessage) - if (result && part.state.status === "running") { - await Session.updatePart({ - ...part, - state: { - status: "completed", - input: part.state.input, - title: result.title, - metadata: result.metadata, - output: result.output, - attachments, - time: { - ...part.state.time, - end: Date.now(), - }, - }, - } satisfies MessageV2.ToolPart) - } - if (!result) { - await Session.updatePart({ - ...part, - state: { - status: "error", - error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", - time: { - start: part.state.status === "running" ? part.state.time.start : Date.now(), - end: Date.now(), - }, - metadata: "metadata" in part.state ? part.state.metadata : undefined, - input: part.state.input, - }, - } satisfies MessageV2.ToolPart) - } - - if (task.command) { - // Add synthetic user message to prevent certain reasoning models from erroring - // If we create assistant messages w/ out user ones following mid loop thinking signatures - // will be missing and it can cause errors for models like gemini for example - const summaryUserMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: lastUser.agent, - model: lastUser.model, - } - await Session.updateMessage(summaryUserMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: summaryUserMsg.id, - sessionID, - type: "text", - text: "Summarize the task tool output above and continue with your task.", - synthetic: true, - } satisfies MessageV2.TextPart) - } - - continue - } - - // pending compaction - if (task?.type === "compaction") { - const result = await SessionCompaction.process({ - messages: msgs, - parentID: lastUser.id, - abort, - sessionID, - auto: task.auto, - overflow: task.overflow, - }) - if (result === "stop") break - continue - } - - // context overflow, needs compaction - if ( - lastFinished && - lastFinished.summary !== true && - (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) - ) { - await SessionCompaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - }) - continue - } - - // normal processing - const agent = await Agent.get(lastUser.agent) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { + state: { ...part.state, ...val }, + } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart + }, + async ask(req) { + await Permission.ask({ + ...req, sessionID, - error: error.toObject(), + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), }) - throw error - } - const maxSteps = agent.steps ?? Infinity - const isLastStep = step >= maxSteps - msgs = await insertReminders({ - messages: msgs, - agent, - session, - }) - - const processor = await SessionProcessor.create({ - assistantMessage: (await Session.updateMessage({ - id: MessageID.ascending(), - parentID: lastUser.id, - role: "assistant", - mode: agent.name, - agent: agent.name, - variant: lastUser.variant, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: model.id, - providerID: model.providerID, + }, + } + const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { + executionError = error + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }) + const attachments = result?.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID, + messageID: assistantMessage.id, + })) + await Plugin.trigger( + "tool.execute.after", + { tool: "task", sessionID, callID: part.id, args: taskArgs }, + result, + ) + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + await Session.updateMessage(assistantMessage) + if (result && part.state.status === "running") { + await Session.updatePart({ + ...part, + state: { + status: "completed", + input: part.state.input, + title: result.title, + metadata: result.metadata, + output: result.output, + attachments, + time: { ...part.state.time, end: Date.now() }, + }, + } satisfies MessageV2.ToolPart) + } + if (!result) { + await Session.updatePart({ + ...part, + state: { + status: "error", + error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", time: { - created: Date.now(), - }, - sessionID, - })) as MessageV2.Assistant, - sessionID: sessionID, - model, - abort, - }) - using _ = defer(() => InstructionPrompt.clear(processor.message.id)) - - // Check if user explicitly invoked an agent via @ in this turn - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") - const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - - const tools = await resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor, - bypassAgentCheck, - messages: msgs, - }) - - // Inject StructuredOutput tool if JSON schema mode enabled - if (lastUser.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser.format.schema, - onSuccess(output) { - structuredOutput = output + start: part.state.status === "running" ? part.state.time.start : Date.now(), + end: Date.now(), }, - }) - } - - if (step === 1) { - SessionSummary.summarize({ - sessionID: sessionID, - messageID: lastUser.id, - }) - } - - // Ephemerally wrap queued user messages with a reminder to stay on track - if (step > 1 && lastFinished) { - for (const msg of msgs) { - if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue - for (const part of msg.parts) { - if (part.type !== "text" || part.ignored || part.synthetic) continue - if (!part.text.trim()) continue - part.text = [ - "", - "The user sent the following message:", - part.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } - } - } - - await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - - // Build system prompt, adding structured output instruction if needed - const skills = await SystemPrompt.skills(agent) - const system = [ - ...(await SystemPrompt.environment(model)), - ...(skills ? [skills] : []), - ...(await InstructionPrompt.system()), - ] - const format = lastUser.format ?? { type: "text" } - if (format.type === "json_schema") { - system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - } - - const result = await processor.process({ - user: lastUser, - agent, - permission: session.permission, - abort, - sessionID, - system, - messages: [ - ...(await MessageV2.toModelMessages(msgs, model)), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ], - tools, - model, - toolChoice: format.type === "json_schema" ? "required" : undefined, - }) - - // If structured output was captured, save it and exit immediately - // This takes priority because the StructuredOutput tool was called successfully - if (structuredOutput !== undefined) { - processor.message.structured = structuredOutput - processor.message.finish = processor.message.finish ?? "stop" - await Session.updateMessage(processor.message) - break - } - - // Check if model finished (finish reason is not "tool-calls" or "unknown") - const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish) - - if (modelFinished && !processor.message.error) { - if (format.type === "json_schema") { - // Model stopped without calling StructuredOutput tool - processor.message.error = new MessageV2.StructuredOutputError({ - message: "Model did not produce structured output", - retries: 0, - }).toObject() - await Session.updateMessage(processor.message) - break - } - } - - if (result === "stop") break - if (result === "compact") { - await SessionCompaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - overflow: !processor.message.finish, - }) - } - continue + metadata: "metadata" in part.state ? part.state.metadata : undefined, + input: part.state.input, + }, + } satisfies MessageV2.ToolPart) } - SessionCompaction.prune({ sessionID }) - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user") continue - const queued = state()[sessionID]?.callbacks ?? [] - for (const q of queued) { - q.resolve(item) + if (task.command) { + const summaryUserMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, } - return item - } - throw new Error("Impossible") - }) - - async function lastModel(sessionID: SessionID) { - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model + await Session.updateMessage(summaryUserMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: summaryUserMsg.id, + sessionID, + type: "text", + text: "Summarize the task tool output above and continue with your task.", + synthetic: true, + } satisfies MessageV2.TextPart) } - return Provider.defaultModel() } /** @internal Exported for testing */ @@ -1004,7 +1089,7 @@ export namespace SessionPrompt { throw error } - const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID)) const full = !input.variant && agent.variant ? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined) @@ -1533,37 +1618,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return input.messages } - export const ShellInput = z.object({ - sessionID: SessionID.zod, - agent: z.string(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - command: z.string(), - }) - export type ShellInput = z.infer - export async function shell(input: ShellInput) { - const abort = start(input.sessionID) - if (!abort) { - throw new Session.BusyError(input.sessionID) - } - - using _ = defer(() => { - // If no queued callbacks, cancel (the default) - const callbacks = state()[input.sessionID]?.callbacks ?? [] - if (callbacks.length === 0) { - cancel(input.sessionID) - } else { - // Otherwise, trigger the session loop to process queued items - loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => { - log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error }) - }) - } - }) - + async function shellImpl(input: ShellInput, signal: AbortSignal): Promise { const session = await Session.get(input.sessionID) if (session.revert) { await SessionRevert.cleanup(session) @@ -1579,7 +1634,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) throw error } - const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), sessionID: input.sessionID, @@ -1647,9 +1702,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } await Session.updatePart(part) - const shell = Shell.preferred() + const sh = Shell.preferred() const shellName = ( - process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) + process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) ).toLowerCase() const invocations: Record = { @@ -1708,7 +1763,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { cwd, sessionID: input.sessionID, callID: part.callID }, { env: {} }, ) - const proc = spawn(shell, args, { + const proc = spawn(sh, args, { cwd, detached: process.platform !== "win32", windowsHide: process.platform === "win32", @@ -1749,7 +1804,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const kill = () => Shell.killTree(proc, { exited: () => exited }) - if (abort.aborted) { + if (signal.aborted) { aborted = true await kill() } @@ -1759,12 +1814,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the void kill() } - abort.addEventListener("abort", abortHandler, { once: true }) + signal.addEventListener("abort", abortHandler, { once: true }) await new Promise((resolve) => { proc.on("close", () => { exited = true - abort.removeEventListener("abort", abortHandler) + signal.removeEventListener("abort", abortHandler) resolve() }) }) @@ -1794,43 +1849,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info: msg, parts: [part] } } - export const CommandInput = z.object({ - messageID: MessageID.zod.optional(), - sessionID: SessionID.zod, - agent: z.string().optional(), - model: z.string().optional(), - arguments: z.string(), - command: z.string(), - variant: z.string().optional(), - parts: z - .array( - z.discriminatedUnion("type", [ - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }).partial({ - id: true, - }), - ]), - ) - .optional(), - }) - export type CommandInput = z.infer const bashRegex = /!`([^`]+)`/g // Match [Image N] as single token, quoted strings, or non-space sequences const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g - /** - * Regular expression to match @ file references in text - * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks - * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references) - */ - export async function command(input: CommandInput) { + async function resolveCommand(input: CommandInput): Promise<{ promptInput: PromptInput }> { log.info("command", input) - const command = await Command.get(input.command) - if (!command) { + const cmd = await Command.get(input.command) + if (!cmd) { const available = await Command.list().then((cmds) => cmds.map((c) => c.name)) const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) @@ -1840,12 +1868,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) throw error } - const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) + const agentName = cmd.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) - const templateCommand = await command.template + const templateCommand = await cmd.template const placeholders = templateCommand.match(placeholderRegex) ?? [] let last = 0 @@ -1854,7 +1882,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (value > last) last = value } - // Let the final placeholder swallow any extra arguments so prompts read naturally const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { const position = Number(index) const argIndex = position - 1 @@ -1865,8 +1892,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) - // If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders) - // but user provided arguments, append them to the template if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { template = template + "\n\n" + input.arguments } @@ -1886,17 +1911,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the template = template.trim() const taskModel = await (async () => { - if (command.model) { - return Provider.parseModel(command.model) - } - if (command.agent) { - const cmdAgent = await Agent.get(command.agent) - if (cmdAgent?.model) { - return cmdAgent.model - } + if (cmd.model) return Provider.parseModel(cmd.model) + if (cmd.agent) { + const cmdAgent = await Agent.get(cmd.agent) + if (cmdAgent?.model) return cmdAgent.model } if (input.model) return Provider.parseModel(input.model) - return await lastModel(input.sessionID) + return await lastModelImpl(input.sessionID) })() try { @@ -1924,20 +1945,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - const templateParts = await resolvePromptParts(template) - const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true + const templateParts = await resolvePromptPartsImpl(template) + const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true const parts = isSubtask ? [ { type: "subtask" as const, agent: agent.name, - description: command.description ?? "", + description: cmd.description ?? "", command: input.command, - model: { - providerID: taskModel.providerID, - modelID: taskModel.modelID, - }, - // TODO: how can we make task tool accept a more complex input? + model: { providerID: taskModel.providerID, modelID: taskModel.modelID }, prompt: templateParts.find((y) => y.type === "text")?.text ?? "", }, ] @@ -1947,36 +1964,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) - : await lastModel(input.sessionID) + : await lastModelImpl(input.sessionID) : taskModel await Plugin.trigger( "command.execute.before", - { - command: input.command, - sessionID: input.sessionID, - arguments: input.arguments, - }, + { command: input.command, sessionID: input.sessionID, arguments: input.arguments }, { parts }, ) - const result = (await prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - model: userModel, - agent: userAgent, - parts, - variant: input.variant, - })) as MessageV2.WithParts - - Bus.publish(Command.Event.Executed, { - name: input.command, - sessionID: input.sessionID, - arguments: input.arguments, - messageID: result.info.id, - }) - - return result + return { + promptInput: { + sessionID: input.sessionID, + messageID: input.messageID, + model: userModel, + agent: userAgent, + parts, + variant: input.variant, + }, + } } async function ensureTitle(input: { diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 6df8b3d53fee..a80ee45201b0 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -21,7 +21,7 @@ export namespace SessionRevert { export type RevertInput = z.infer export async function revert(input: RevertInput) { - SessionPrompt.assertNotBusy(input.sessionID) + await SessionPrompt.assertNotBusy(input.sessionID) const all = await Session.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined const session = await Session.get(input.sessionID) @@ -80,7 +80,7 @@ export namespace SessionRevert { export async function unrevert(input: { sessionID: SessionID }) { log.info("unreverting", input) - SessionPrompt.assertNotBusy(input.sessionID) + await 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/test/session/prompt-concurrency.test.ts b/packages/opencode/test/session/prompt-concurrency.test.ts new file mode 100644 index 000000000000..22bfc96d6f13 --- /dev/null +++ b/packages/opencode/test/session/prompt-concurrency.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +// Helper: seed a session with a user message + finished assistant message +// so loop() exits immediately without calling any LLM +async function seed(sessionID: SessionID) { + const userMsg: MessageV2.Info = { + id: MessageID.ascending(), + role: "user", + sessionID, + time: { created: Date.now() }, + agent: "build", + model: { providerID: "openai" as any, modelID: "gpt-5.2" as any }, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID, + type: "text", + text: "hello", + }) + + const assistantMsg: MessageV2.Info = { + id: MessageID.ascending(), + role: "assistant", + parentID: userMsg.id, + sessionID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "gpt-5.2" as any, + providerID: "openai" as any, + time: { created: Date.now(), completed: Date.now() }, + finish: "stop", + } + await Session.updateMessage(assistantMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: assistantMsg.id, + sessionID, + type: "text", + text: "hi there", + }) + + return { userMsg, assistantMsg } +} + +describe("session.prompt concurrency", () => { + test("loop returns assistant message and sets status to idle", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await seed(session.id) + + const result = await SessionPrompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") expect(result.info.finish).toBe("stop") + + const status = await SessionStatus.get(session.id) + expect(status.type).toBe("idle") + }, + }) + }) + + test("concurrent loop callers get the same result", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await seed(session.id) + + const [a, b] = await Promise.all([ + SessionPrompt.loop({ sessionID: session.id }), + SessionPrompt.loop({ sessionID: session.id }), + ]) + + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + }, + }) + }) + + test("assertNotBusy throws when loop is running", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await seed(session.id) + + // Start loop — it'll finish fast but we can race assertNotBusy + const loopPromise = SessionPrompt.loop({ sessionID: session.id }) + + // Give the loop fiber a tick to register in the map + await new Promise((r) => setTimeout(r, 5)) + + // The loop may already be done on fast machines, so this test + // verifies the mechanism works — if the loop is still running, + // assertNotBusy should throw + const busyCheck = SessionPrompt.assertNotBusy(session.id).then( + () => "not-busy", + (e) => (e instanceof Session.BusyError ? "busy" : "other-error"), + ) + + await loopPromise + // After loop completes, assertNotBusy should succeed + await SessionPrompt.assertNotBusy(session.id) + }, + }) + }) + + test("cancel sets status to idle", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + // Don't seed an assistant message — loop will try to call Provider.getModel + // and hang. We cancel before it can fail. + const userMsg: MessageV2.Info = { + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + time: { created: Date.now() }, + agent: "build", + model: { providerID: "openai" as any, modelID: "gpt-5.2" as any }, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: session.id, + type: "text", + text: "hello", + }) + + // Start loop — it will try to get model and either fail or hang + const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined) + + // Give it a tick to start + await new Promise((r) => setTimeout(r, 10)) + + await SessionPrompt.cancel(session.id) + + const status = await SessionStatus.get(session.id) + expect(status.type).toBe("idle") + + // Wait for the loop to settle + await loopPromise + }, + }) + }, 10000) + + test("cancel on idle session just sets idle", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await SessionPrompt.cancel(session.id) + const status = await SessionStatus.get(session.id) + expect(status.type).toBe("idle") + }, + }) + }) +}) From 76373f0c73048ef0da13f7a0315804e5a4a99661 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 12:15:53 -0400 Subject: [PATCH 02/66] use Session.Service and Agent.Service directly instead of Effect.promise wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yield the effectified services in the layer and call their methods directly, eliminating the double Effect→Promise→Effect bounce through async facades. Layer.unwrap(Effect.sync(...)) breaks the circular import. Also improves the assertNotBusy test with a proper gate/spy so it deterministically catches the busy state. --- packages/opencode/src/session/prompt.ts | 68 ++++++++++--------- .../test/session/prompt-concurrency.test.ts | 58 ++++++++++++---- 2 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5d47e7ba4245..41f54662038e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -90,6 +90,8 @@ export namespace SessionPrompt { Effect.gen(function* () { const bus = yield* Bus.Service const status = yield* SessionStatus.Service + const sessions = yield* Session.Service + const agents = yield* Agent.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -137,10 +139,10 @@ export namespace SessionPrompt { }) const promptE = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { - const session = yield* Effect.promise(() => Session.get(input.sessionID)) + const session = yield* sessions.get(input.sessionID) yield* Effect.promise(() => SessionRevert.cleanup(session)) const message = yield* Effect.promise(() => createUserMessage(input)) - yield* Effect.promise(() => Session.touch(input.sessionID)) + yield* sessions.touch(input.sessionID) const permissions: Permission.Ruleset = [] for (const [t, enabled] of Object.entries(input.tools ?? {})) { @@ -148,7 +150,7 @@ export namespace SessionPrompt { } if (permissions.length > 0) { session.permission = permissions - yield* Effect.promise(() => Session.setPermission({ sessionID: session.id, permission: permissions })) + yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) } if (input.noReply === true) return message @@ -158,7 +160,7 @@ export namespace SessionPrompt { const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) { let structured: unknown | undefined let step = 0 - const session = yield* Effect.promise(() => Session.get(sessionID)) + const session = yield* sessions.get(sessionID) while (true) { yield* status.set(sessionID, { type: "busy" }) @@ -251,11 +253,9 @@ export namespace SessionPrompt { continue } - const agent = yield* Effect.promise(() => Agent.get(lastUser!.agent)) + const agent = yield* agents.get(lastUser!.agent) if (!agent) { - const available = yield* Effect.promise(() => - Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)), - ) + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser!.agent}".${hint}` }) yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) @@ -265,30 +265,28 @@ export namespace SessionPrompt { const isLastStep = step >= maxSteps msgs = yield* Effect.promise(() => insertReminders({ messages: msgs, agent, session })) - const msg = yield* Effect.promise(() => - Session.updateMessage({ - id: MessageID.ascending(), - parentID: lastUser!.id, - role: "assistant", - mode: agent.name, - agent: agent.name, - variant: lastUser!.variant, - path: { cwd: Instance.directory, root: Instance.worktree }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: model.id, - providerID: model.providerID, - time: { created: Date.now() }, - sessionID, - }), - ) + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + parentID: lastUser!.id, + role: "assistant", + mode: agent.name, + agent: agent.name, + variant: lastUser!.variant, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.id, + providerID: model.providerID, + time: { created: Date.now() }, + sessionID, + }) const processor = yield* Effect.promise((signal) => - Promise.resolve(SessionProcessor.create({ + SessionProcessor.create({ assistantMessage: msg as MessageV2.Assistant, sessionID, model, abort: signal, - })), + }), ) const outcome: "break" | "continue" = yield* Effect.ensuring( @@ -363,7 +361,7 @@ export namespace SessionPrompt { if (structured !== undefined) { processor.message.structured = structured processor.message.finish = processor.message.finish ?? "stop" - yield* Effect.promise(() => Session.updateMessage(processor.message)) + yield* sessions.updateMessage(processor.message) return "break" as const } @@ -374,7 +372,7 @@ export namespace SessionPrompt { message: "Model did not produce structured output", retries: 0, }).toObject() - yield* Effect.promise(() => Session.updateMessage(processor.message)) + yield* sessions.updateMessage(processor.message) return "break" as const } } @@ -506,9 +504,15 @@ export namespace SessionPrompt { }), ) - const defaultLayer = layer.pipe( - Layer.provide(SessionStatus.layer), - Layer.provide(Bus.layer), + const defaultLayer = Layer.unwrap( + Effect.sync(() => + layer.pipe( + Layer.provide(SessionStatus.layer), + Layer.provide(Session.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Bus.layer), + ), + ), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/test/session/prompt-concurrency.test.ts b/packages/opencode/test/session/prompt-concurrency.test.ts index 22bfc96d6f13..050435f1c81a 100644 --- a/packages/opencode/test/session/prompt-concurrency.test.ts +++ b/packages/opencode/test/session/prompt-concurrency.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" @@ -10,6 +11,14 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + // Helper: seed a session with a user message + finished assistant message // so loop() exits immediately without calling any LLM async function seed(sessionID: SessionID) { @@ -101,23 +110,44 @@ describe("session.prompt concurrency", () => { directory: tmp.path, fn: async () => { const session = await Session.create({}) - await seed(session.id) + const userMsg: MessageV2.Info = { + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + time: { created: Date.now() }, + agent: "build", + model: { providerID: "openai" as any, modelID: "gpt-5.2" as any }, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: session.id, + type: "text", + text: "hello", + }) + + const ready = deferred() + const gate = deferred() + const getModel = spyOn(Provider, "getModel").mockImplementation(async () => { + ready.resolve() + await gate.promise + throw new Error("test stop") + }) - // Start loop — it'll finish fast but we can race assertNotBusy - const loopPromise = SessionPrompt.loop({ sessionID: session.id }) + try { + const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined) + await ready.promise - // Give the loop fiber a tick to register in the map - await new Promise((r) => setTimeout(r, 5)) + await expect(SessionPrompt.assertNotBusy(session.id)).rejects.toBeInstanceOf(Session.BusyError) - // The loop may already be done on fast machines, so this test - // verifies the mechanism works — if the loop is still running, - // assertNotBusy should throw - const busyCheck = SessionPrompt.assertNotBusy(session.id).then( - () => "not-busy", - (e) => (e instanceof Session.BusyError ? "busy" : "other-error"), - ) + gate.resolve() + await loopPromise + } finally { + gate.resolve() + getModel.mockRestore() + } - await loopPromise // After loop completes, assertNotBusy should succeed await SessionPrompt.assertNotBusy(session.id) }, From be20ea0f42db526943d533ecdef41c5f1fe43639 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 12:29:17 -0400 Subject: [PATCH 03/66] use SessionProcessor.Service, Session.Service, Agent.Service directly in layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yield effectified services instead of going through async facades. Eliminates Effect→Promise→Effect double-bounce for processor.create, processor.process, Session.get/touch/setPermission/updateMessage, and Agent.get/list. Await cancel in session route. Remove redundant InstanceState.get in shellE ensuring block. --- .../opencode/src/server/routes/session.ts | 2 +- packages/opencode/src/session/prompt.ts | 117 ++++++++++-------- .../test/session/prompt-concurrency.test.ts | 58 +++++++-- 3 files changed, 115 insertions(+), 62 deletions(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 4288703689c8..23615d39abcc 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -381,7 +381,7 @@ export const SessionRoutes = lazy(() => }), ), async (c) => { - SessionPrompt.cancel(c.req.valid("param").sessionID) + await SessionPrompt.cancel(c.req.valid("param").sessionID) return c.json(true) }, ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 41f54662038e..95ecf451a69e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,7 +48,7 @@ import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Deferred, Effect, Fiber, Layer, Scope, ServiceMap } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -92,6 +92,7 @@ export namespace SessionPrompt { const status = yield* SessionStatus.Service const sessions = yield* Session.Service const agents = yield* Agent.Service + const processor = yield* SessionProcessor.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -157,6 +158,15 @@ export namespace SessionPrompt { return yield* loopE({ sessionID: input.sessionID }) }) + const lastAssistant = (sessionID: SessionID) => + Effect.promise(async () => { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user") continue + return item + } + throw new Error("Impossible") + }) + const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) { let structured: unknown | undefined let step = 0 @@ -280,22 +290,21 @@ export namespace SessionPrompt { time: { created: Date.now() }, sessionID, }) - const processor = yield* Effect.promise((signal) => - SessionProcessor.create({ - assistantMessage: msg as MessageV2.Assistant, - sessionID, - model, - abort: signal, - }), - ) + const ctrl = new AbortController() + const handle = yield* processor.create({ + assistantMessage: msg as MessageV2.Assistant, + sessionID, + model, + abort: ctrl.signal, + }) - const outcome: "break" | "continue" = yield* Effect.ensuring( + const outcome: "break" | "continue" = yield* Effect.onExit( Effect.gen(function* () { const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const tools = yield* Effect.promise(() => - resolveTools({ agent, session, model, tools: lastUser!.tools, processor, bypassAgentCheck, messages: msgs }), + resolveTools({ agent, session, model, tools: lastUser!.tools, processor: handle, bypassAgentCheck, messages: msgs }), ) if (lastUser!.format?.type === "json_schema") { @@ -340,39 +349,37 @@ export namespace SessionPrompt { const system = [...env, ...(skills ? [skills] : []), ...instructions] const format = lastUser!.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - const result = yield* Effect.promise((signal) => - processor.process({ - user: lastUser!, - agent, - permission: session.permission, - abort: signal, - sessionID, - system, - messages: [ - ...modelMsgs, - ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), - ], - tools, - model, - toolChoice: format.type === "json_schema" ? "required" : undefined, - }), - ) + const result = yield* handle.process({ + user: lastUser!, + agent, + permission: session.permission, + abort: ctrl.signal, + sessionID, + system, + messages: [ + ...modelMsgs, + ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), + ], + tools, + model, + toolChoice: format.type === "json_schema" ? "required" : undefined, + }) if (structured !== undefined) { - processor.message.structured = structured - processor.message.finish = processor.message.finish ?? "stop" - yield* sessions.updateMessage(processor.message) + handle.message.structured = structured + handle.message.finish = handle.message.finish ?? "stop" + yield* sessions.updateMessage(handle.message) return "break" as const } - const finished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish) - if (finished && !processor.message.error) { + const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) + if (finished && !handle.message.error) { if (format.type === "json_schema") { - processor.message.error = new MessageV2.StructuredOutputError({ + handle.message.error = new MessageV2.StructuredOutputError({ message: "Model did not produce structured output", retries: 0, }).toObject() - yield* sessions.updateMessage(processor.message) + yield* sessions.updateMessage(handle.message) return "break" as const } } @@ -385,37 +392,48 @@ export namespace SessionPrompt { agent: lastUser!.agent, model: lastUser!.model, auto: true, - overflow: !processor.message.finish, + overflow: !handle.message.finish, }), ) } return "continue" as const }), - Effect.sync(() => InstructionPrompt.clear(processor.message.id)), + (exit) => + Effect.gen(function* () { + ctrl.abort() + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() + InstructionPrompt.clear(handle.message.id) + }), ) if (outcome === "break") break continue } SessionCompaction.prune({ sessionID }) - return yield* Effect.promise(async () => { - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user") continue - return item - } - throw new Error("Impossible") - }) + return yield* lastAssistant(sessionID) }) type State = { loops: Map; shells: Map> } + const awaitFiber = (fiber: Fiber.Fiber, fallback: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause)) return yield* fallback + return yield* Effect.failCause(exit.cause as Cause.Cause) + }) + const startLoop = Effect.fnUntraced(function* (s: State, sessionID: SessionID) { const fiber = yield* runLoop(sessionID).pipe( Effect.onExit((exit) => Effect.gen(function* () { const entry = s.loops.get(sessionID) if (entry) { - for (const d of entry.queue) yield* Deferred.done(d, exit) + // On interrupt, resolve queued callers with the last assistant message + const resolved = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Exit.succeed(yield* lastAssistant(sessionID)) + : exit + for (const d of entry.queue) yield* Deferred.done(d, resolved) } s.loops.delete(sessionID) yield* status.set(sessionID, { type: "idle" }) @@ -425,12 +443,11 @@ export namespace SessionPrompt { ) const entry = s.loops.get(sessionID) if (entry) { - // Queue already exists (created while shell was running) — attach fiber entry.fiber = fiber } else { s.loops.set(sessionID, { fiber, queue: [] }) } - return yield* Fiber.join(fiber).pipe(Effect.orDie) + return yield* awaitFiber(fiber, lastAssistant(sessionID)) }) const loopE = Effect.fn("SessionPrompt.loop")(function* (input: z.infer) { @@ -462,7 +479,6 @@ export namespace SessionPrompt { const fiber = yield* Effect.promise((signal) => shellImpl(input, signal)).pipe( Effect.ensuring( Effect.gen(function* () { - const s = yield* InstanceState.get(cache) s.shells.delete(input.sessionID) // If callers queued a loop while the shell was running, start it const pending = s.loops.get(input.sessionID) @@ -477,7 +493,7 @@ export namespace SessionPrompt { ) s.shells.set(input.sessionID, fiber) - return yield* Fiber.join(fiber).pipe(Effect.orDie) + return yield* awaitFiber(fiber, lastAssistant(input.sessionID)) }) const commandE = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { @@ -508,6 +524,7 @@ export namespace SessionPrompt { Effect.sync(() => layer.pipe( Layer.provide(SessionStatus.layer), + Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Bus.layer), @@ -863,7 +880,7 @@ export namespace SessionPrompt { model: Provider.Model session: Session.Info tools?: Record - processor: SessionProcessor.Info + processor: Pick bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { diff --git a/packages/opencode/test/session/prompt-concurrency.test.ts b/packages/opencode/test/session/prompt-concurrency.test.ts index 050435f1c81a..19e1c4bf59cf 100644 --- a/packages/opencode/test/session/prompt-concurrency.test.ts +++ b/packages/opencode/test/session/prompt-concurrency.test.ts @@ -160,8 +160,7 @@ describe("session.prompt concurrency", () => { directory: tmp.path, fn: async () => { const session = await Session.create({}) - // Don't seed an assistant message — loop will try to call Provider.getModel - // and hang. We cancel before it can fail. + // Seed only a user message — loop must call getModel to proceed const userMsg: MessageV2.Info = { id: MessageID.ascending(), role: "user", @@ -178,20 +177,57 @@ describe("session.prompt concurrency", () => { type: "text", text: "hello", }) + // Also seed an assistant message so lastAssistant() fallback can find it + const assistantMsg: MessageV2.Info = { + id: MessageID.ascending(), + role: "assistant", + parentID: userMsg.id, + sessionID: session.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "gpt-5.2" as any, + providerID: "openai" as any, + time: { created: Date.now() }, + } + await Session.updateMessage(assistantMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: assistantMsg.id, + sessionID: session.id, + type: "text", + text: "hi there", + }) - // Start loop — it will try to get model and either fail or hang - const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined) + const ready = deferred() + const gate = deferred() + const getModel = spyOn(Provider, "getModel").mockImplementation(async () => { + ready.resolve() + await gate.promise + throw new Error("test stop") + }) - // Give it a tick to start - await new Promise((r) => setTimeout(r, 10)) + try { + // Start loop — it will block in getModel (assistant has no finish, so loop continues) + const loopPromise = SessionPrompt.loop({ sessionID: session.id }) - await SessionPrompt.cancel(session.id) + await ready.promise - const status = await SessionStatus.get(session.id) - expect(status.type).toBe("idle") + await SessionPrompt.cancel(session.id) + + const status = await SessionStatus.get(session.id) + expect(status.type).toBe("idle") - // Wait for the loop to settle - await loopPromise + // loop should resolve cleanly, not throw "All fibers interrupted" + const result = await loopPromise + expect(result.info.role).toBe("assistant") + expect(result.info.id).toBe(assistantMsg.id) + } finally { + gate.resolve() + getModel.mockRestore() + } }, }) }, 10000) From 51ad4801e87453e5e2b237c69dfb73cfe42b1ec7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 17:47:48 -0400 Subject: [PATCH 04/66] add Effect-based prompt tests covering loop lifecycle, cancel, concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 tests using testEffect + TestLLM pattern (no HTTP server): - loop exits on stop finish, calls LLM for new messages, continues on tool-calls - loop sets status busy→idle - cancel interrupts cleanly, returns last assistant, records MessageAbortedError - cancel with queued callers resolves all cleanly - concurrent callers get same result via Deferred queue - concurrent callers receive same error on failure - assertNotBusy throws BusyError when running, succeeds when idle - shell rejects with BusyError when loop running --- .../test/session/prompt-effect.test.ts | 614 ++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 packages/opencode/test/session/prompt-effect.test.ts diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts new file mode 100644 index 000000000000..4b70394f7736 --- /dev/null +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -0,0 +1,614 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { expect } from "bun:test" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, ServiceMap } from "effect" +import * as Stream from "effect/Stream" +import path from "path" +import type { Agent } from "../../src/agent/agent" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import type { Provider } from "../../src/provider/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProcessor } from "../../src/session/processor" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { Snapshot } from "../../src/snapshot" +import { Log } from "../../src/util/log" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +type Script = Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) + +class TestLLM extends ServiceMap.Service< + TestLLM, + { + readonly push: (stream: Script) => Effect.Effect + readonly reply: (...items: LLM.Event[]) => Effect.Effect + readonly calls: Effect.Effect + readonly inputs: Effect.Effect + } +>()("@test/PromptLLM") {} + +function stream(...items: LLM.Event[]) { + return Stream.make(...items) +} + +function usage(input = 1, output = 1, total = input + output) { + return { + inputTokens: input, + outputTokens: output, + totalTokens: total, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + } +} + +function start(): LLM.Event { + return { type: "start" } +} + +function textStart(id = "t"): LLM.Event { + return { type: "text-start", id } +} + +function textDelta(id: string, text: string): LLM.Event { + return { type: "text-delta", id, text } +} + +function textEnd(id = "t"): LLM.Event { + return { type: "text-end", id } +} + +function finishStep(): LLM.Event { + return { + type: "finish-step", + finishReason: "stop", + rawFinishReason: "stop", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: usage(), + } +} + +function finish(): LLM.Event { + return { type: "finish", finishReason: "stop", rawFinishReason: "stop", totalUsage: usage() } +} + +function wait(abort: AbortSignal) { + return Effect.promise( + () => + new Promise((done) => { + abort.addEventListener("abort", () => done(), { once: true }) + }), + ) +} + +function hang(input: LLM.StreamInput, ...items: LLM.Event[]) { + return stream(...items).pipe( + Stream.concat( + Stream.unwrap(wait(input.abort).pipe(Effect.as(Stream.fail(new DOMException("Aborted", "AbortError"))))), + ), + ) +} + +function defer() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +const llm = Layer.unwrap( + Effect.gen(function* () { + const queue: Script[] = [] + const inputs: LLM.StreamInput[] = [] + let calls = 0 + + const push = Effect.fn("TestLLM.push")((item: Script) => { + queue.push(item) + return Effect.void + }) + + const reply = Effect.fn("TestLLM.reply")((...items: LLM.Event[]) => push(stream(...items))) + return Layer.mergeAll( + Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: (input) => { + calls += 1 + inputs.push(input) + const item = queue.shift() ?? Stream.empty + return typeof item === "function" ? item(input) : item + }, + }), + ), + Layer.succeed( + TestLLM, + TestLLM.of({ + push, + reply, + calls: Effect.sync(() => calls), + inputs: Effect.sync(() => [...inputs]), + }), + ), + ) + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + AgentSvc.defaultLayer, + Permission.layer, + Plugin.defaultLayer, + Config.defaultLayer, + status, + llm, +).pipe(Layer.provideMerge(infra)) +const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) +const env = SessionPrompt.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + +const it = testEffect(env) + +// Config that registers a custom "test" provider with a "test-model" model +// so Provider.getModel("test", "test-model") succeeds inside the loop. +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, +} + +const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { + const session = yield* Session.Service + const msg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +}) + +const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { + const session = yield* Session.Service + const msg = yield* user(sessionID, "hello") + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: msg.id, + sessionID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + ...(opts?.finish ? { finish: opts.finish } : {}), + } + yield* session.updateMessage(assistant) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID, + type: "text", + text: "hi there", + }) + return { user: msg, assistant } +}) + +// Priority 1: Loop lifecycle + +it.effect("loop exits immediately when last assistant has stop finish", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + yield* seed(chat.id, { finish: "stop" }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") expect(result.info.finish).toBe("stop") + expect(yield* test.calls).toBe(0) + }), + { git: true }, + ), +) + +it.effect("loop calls LLM and returns assistant message", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.reply(start(), textStart(), textDelta("t", "world"), textEnd(), finishStep(), finish()) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + const parts = result.parts.filter((p) => p.type === "text") + expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true) + expect(yield* test.calls).toBe(1) + }), + { git: true, config: cfg }, + ), +) + +it.effect("loop continues when finish is tool-calls", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + // First reply finishes with tool-calls, second with stop + yield* test.reply( + start(), + textStart(), + textDelta("t", "first"), + textEnd(), + { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: usage(), + }, + { type: "finish", finishReason: "tool-calls", rawFinishReason: "tool_calls", totalUsage: usage() }, + ) + yield* test.reply(start(), textStart(), textDelta("t", "second"), textEnd(), finishStep(), finish()) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(yield* test.calls).toBe(2) + expect(result.info.role).toBe("assistant") + }), + { git: true, config: cfg }, + ), +) + +it.effect("loop sets status to busy then idle", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const bus = yield* Bus.Service + + yield* test.reply(start(), textStart(), textDelta("t", "ok"), textEnd(), finishStep(), finish()) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const types: string[] = [] + const idle = defer() + const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { + if (evt.properties.sessionID !== chat.id) return + types.push(evt.properties.status.type) + if (evt.properties.status.type === "idle") idle.resolve() + }) + + yield* prompt.loop({ sessionID: chat.id }) + yield* Effect.promise(() => idle.promise) + off() + + expect(types).toContain("busy") + expect(types[types.length - 1]).toBe("idle") + }), + { git: true, config: cfg }, + ), +) + +// Priority 2: Cancel safety + +it.effect("cancel interrupts loop and returns last assistant", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + yield* seed(chat.id) + + // Make LLM hang so the loop blocks + yield* test.push((input) => hang(input, start())) + + // Seed a new user message so the loop enters the LLM path + yield* user(chat.id, "more") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + // Give the loop time to start + yield* Effect.promise(() => new Promise((r) => setTimeout(r, 200))) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + } + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect("cancel records MessageAbortedError on interrupted process", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const info = exit.value.info + if (info.role === "assistant") { + expect(info.error?.name).toBe("MessageAbortedError") + } + } + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect("cancel with queued callers resolves all cleanly", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + // Queue a second caller + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50))) + + yield* prompt.cancel(chat.id) + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + // Both should resolve (success or interrupt, not error) + expect(Exit.isFailure(exitA) && !Cause.hasInterruptsOnly(exitA.cause)).toBe(false) + expect(Exit.isFailure(exitB) && !Cause.hasInterruptsOnly(exitB.cause)).toBe(false) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +// Priority 3: Deferred queue + +it.effect("concurrent loop callers get same result", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + yield* seed(chat.id, { finish: "stop" }) + + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) + + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + }), + { git: true }, + ), +) + +it.effect("concurrent loop callers all receive same error result", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + // Push a stream that fails — the loop records the error on the assistant message + yield* test.push(Stream.fail(new Error("boom"))) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const [a, b] = yield* Effect.all( + [prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], + { concurrency: "unbounded" }, + ) + + // Both callers get the same assistant with an error recorded + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + if (a.info.role === "assistant") { + expect(a.info.error).toBeDefined() + } + }), + { git: true, config: cfg }, + ), +) + +it.effect("assertNotBusy throws BusyError when loop running", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + + const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect("assertNotBusy succeeds when idle", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { git: true }, + ), +) + +// Priority 4: Shell basics + +it.effect("shell rejects with BusyError when loop running", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + + const exit = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "echo hi" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: cfg }, + ), + 30_000, +) From 2d9a310be1336aba8cd8c890a1f243dafe5a2644 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 17:54:10 -0400 Subject: [PATCH 05/66] use shorthand properties in Service.of, Fiber.interruptAll in finalizer --- packages/opencode/src/session/prompt.ts | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 95ecf451a69e..533ed0c2b7be 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -100,10 +100,10 @@ export namespace SessionPrompt { const loops = new Map() const shells = new Map>() yield* Effect.addFinalizer(() => - Effect.forEach( - [...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []), ...shells.values()], - (fiber) => Fiber.interrupt(fiber), - ), + Fiber.interruptAll([ + ...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []), + ...shells.values(), + ]), ) return { loops, shells } }), @@ -135,11 +135,11 @@ export namespace SessionPrompt { yield* status.set(sessionID, { type: "idle" }) }) - const resolvePromptPartsE = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { return yield* Effect.promise(() => resolvePromptPartsImpl(template)) }) - const promptE = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { + const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) yield* Effect.promise(() => SessionRevert.cleanup(session)) const message = yield* Effect.promise(() => createUserMessage(input)) @@ -155,7 +155,7 @@ export namespace SessionPrompt { } if (input.noReply === true) return message - return yield* loopE({ sessionID: input.sessionID }) + return yield* loop({ sessionID: input.sessionID }) }) const lastAssistant = (sessionID: SessionID) => @@ -450,7 +450,7 @@ export namespace SessionPrompt { return yield* awaitFiber(fiber, lastAssistant(sessionID)) }) - const loopE = Effect.fn("SessionPrompt.loop")(function* (input: z.infer) { + const loop = Effect.fn("SessionPrompt.loop")(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const existing = s.loops.get(input.sessionID) @@ -470,7 +470,7 @@ export namespace SessionPrompt { return yield* startLoop(s, input.sessionID) }) - const shellE = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) { + const shell = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) { const s = yield* InstanceState.get(cache) if (s.loops.has(input.sessionID) || s.shells.has(input.sessionID)) { throw new Session.BusyError(input.sessionID) @@ -496,9 +496,9 @@ export namespace SessionPrompt { return yield* awaitFiber(fiber, lastAssistant(input.sessionID)) }) - const commandE = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { + const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { const resolved = yield* Effect.promise(() => resolveCommand(input)) - const result = yield* promptE(resolved.promptInput) + const result = yield* prompt(resolved.promptInput) yield* bus.publish(Command.Event.Executed, { name: input.command, sessionID: input.sessionID, @@ -511,11 +511,11 @@ export namespace SessionPrompt { return Service.of({ assertNotBusy, cancel, - prompt: promptE, - loop: loopE, - shell: shellE, - command: commandE, - resolvePromptParts: resolvePromptPartsE, + prompt, + loop, + shell, + command, + resolvePromptParts, }) }), ) From f5bf694056f38a8f189c75b1958d5dbd27f209e2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 19:33:48 -0400 Subject: [PATCH 06/66] use SessionCompaction.Service and Plugin.Service directly in layer --- packages/opencode/src/session/prompt.ts | 30 +++++++++---------- .../test/session/prompt-effect.test.ts | 4 ++- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 533ed0c2b7be..eb005c50efc8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -93,6 +93,8 @@ export namespace SessionPrompt { const sessions = yield* Session.Service const agents = yield* Agent.Service const processor = yield* SessionProcessor.Service + const compaction = yield* SessionCompaction.Service + const plugin = yield* Plugin.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -255,11 +257,9 @@ export namespace SessionPrompt { if ( lastFinished && lastFinished.summary !== true && - (yield* Effect.promise(() => SessionCompaction.isOverflow({ tokens: lastFinished!.tokens, model }))) + (yield* compaction.isOverflow({ tokens: lastFinished!.tokens, model })) ) { - yield* Effect.promise(() => - SessionCompaction.create({ sessionID, agent: lastUser!.agent, model: lastUser!.model, auto: true }), - ) + yield* compaction.create({ sessionID, agent: lastUser!.agent, model: lastUser!.model, auto: true }) continue } @@ -336,7 +336,7 @@ export namespace SessionPrompt { } } - yield* Effect.promise(() => Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() => Promise.all([ @@ -386,15 +386,13 @@ export namespace SessionPrompt { if (result === "stop") return "break" as const if (result === "compact") { - yield* Effect.promise(() => - SessionCompaction.create({ - sessionID, - agent: lastUser!.agent, - model: lastUser!.model, - auto: true, - overflow: !handle.message.finish, - }), - ) + yield* compaction.create({ + sessionID, + agent: lastUser!.agent, + model: lastUser!.model, + auto: true, + overflow: !handle.message.finish, + }) } return "continue" as const }), @@ -409,7 +407,7 @@ export namespace SessionPrompt { continue } - SessionCompaction.prune({ sessionID }) + yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) return yield* lastAssistant(sessionID) }) @@ -524,7 +522,9 @@ export namespace SessionPrompt { Effect.sync(() => layer.pipe( Layer.provide(SessionStatus.layer), + Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Bus.layer), diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 4b70394f7736..6a9082ef3600 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -14,6 +14,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" +import { SessionCompaction } from "../../src/session/compaction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -170,7 +171,8 @@ const deps = Layer.mergeAll( llm, ).pipe(Layer.provideMerge(infra)) const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) -const env = SessionPrompt.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) +const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) +const env = SessionPrompt.layer.pipe(Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(deps)) const it = testEffect(env) From 4f784276b4fe12ef3525fad010354d77786463bf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 19:42:07 -0400 Subject: [PATCH 07/66] effectify resolvePromptParts: use AppFileSystem and Agent service directly --- packages/opencode/src/session/prompt.ts | 35 ++++++++++++++++++- .../test/session/prompt-effect.test.ts | 2 ++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb005c50efc8..f8a358a5732c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,7 @@ import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" +import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" @@ -95,6 +96,7 @@ export namespace SessionPrompt { const processor = yield* SessionProcessor.Service const compaction = yield* SessionCompaction.Service const plugin = yield* Plugin.Service + const fsys = yield* AppFileSystem.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -138,7 +140,37 @@ export namespace SessionPrompt { }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { - return yield* Effect.promise(() => resolvePromptPartsImpl(template)) + const parts: PromptInput["parts"] = [{ type: "text", text: template }] + const files = ConfigMarkdown.files(template) + const seen = new Set() + yield* Effect.forEach( + files, + (match) => + Effect.gen(function* () { + const name = match[1] + if (seen.has(name)) return + seen.add(name) + const filepath = name.startsWith("~/") + ? path.join(os.homedir(), name.slice(2)) + : path.resolve(Instance.worktree, name) + + const info = yield* fsys.stat(filepath).pipe(Effect.option) + if (!info._tag || info._tag === "None") { + const found = yield* agents.get(name) + if (found) parts.push({ type: "agent", name: found.name }) + return + } + const stat = info.value + parts.push({ + type: "file", + url: pathToFileURL(filepath).href, + filename: name, + mime: stat.type === "Directory" ? "application/x-directory" : "text/plain", + }) + }), + { concurrency: "unbounded" }, + ) + return parts }) const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { @@ -524,6 +556,7 @@ export namespace SessionPrompt { Layer.provide(SessionStatus.layer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Agent.defaultLayer), diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 6a9082ef3600..1b4bb2d19ce6 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -14,6 +14,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" +import { AppFileSystem } from "../../src/filesystem" import { SessionCompaction } from "../../src/session/compaction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" @@ -167,6 +168,7 @@ const deps = Layer.mergeAll( Permission.layer, Plugin.defaultLayer, Config.defaultLayer, + AppFileSystem.defaultLayer, status, llm, ).pipe(Layer.provideMerge(infra)) From 732bbde850cda6b2723ef0e866e4be64b92ced54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 19:58:14 -0400 Subject: [PATCH 08/66] effectify resolveCommand: use Command.Service, Agent, Plugin directly in layer --- packages/opencode/src/session/prompt.ts | 119 ++++- .../test/session/prompt-effect.test.ts | 459 ++++++++++++------ 2 files changed, 422 insertions(+), 156 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f8a358a5732c..e0e76bf3fa0d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -96,6 +96,7 @@ export namespace SessionPrompt { const processor = yield* SessionProcessor.Service const compaction = yield* SessionCompaction.Service const plugin = yield* Plugin.Service + const commands = yield* Command.Service const fsys = yield* AppFileSystem.Service const scope = yield* Scope.Scope @@ -527,8 +528,121 @@ export namespace SessionPrompt { }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { - const resolved = yield* Effect.promise(() => resolveCommand(input)) - const result = yield* prompt(resolved.promptInput) + log.info("command", input) + const cmd = yield* commands.get(input.command) + if (!cmd) { + const available = (yield* commands.list()).map((c) => c.name) + const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent()) + + const raw = input.arguments.match(argsRegex) ?? [] + const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) + const templateCommand = yield* Effect.promise(() => Promise.resolve(cmd.template)) + + const placeholders = templateCommand.match(placeholderRegex) ?? [] + let last = 0 + for (const item of placeholders) { + const value = Number(item.slice(1)) + if (value > last) last = value + } + + const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") + let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) + + if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { + template = template + "\n\n" + input.arguments + } + + const shellMatches = ConfigMarkdown.shell(template) + if (shellMatches.length > 0) { + const sh = Shell.preferred() + const results = yield* Effect.promise(() => + Promise.all(shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text)), + ) + let index = 0 + template = template.replace(bashRegex, () => results[index++]) + } + template = template.trim() + + const taskModel = yield* Effect.promise(async () => { + if (cmd.model) return Provider.parseModel(cmd.model) + if (cmd.agent) { + const cmdAgent = await Agent.get(cmd.agent) + if (cmdAgent?.model) return cmdAgent.model + } + if (input.model) return Provider.parseModel(input.model) + return await lastModelImpl(input.sessionID) + }) + + yield* Effect.promise(() => + Provider.getModel(taskModel.providerID, taskModel.modelID).catch((e) => { + if (Provider.ModelNotFoundError.isInstance(e)) { + const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}` }).toObject(), + }) + } + throw e + }), + ) + + const agent = yield* agents.get(agentName) + if (!agent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + + const templateParts = yield* resolvePromptParts(template) + const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true + const parts = isSubtask + ? [ + { + type: "subtask" as const, + agent: agent.name, + description: cmd.description ?? "", + command: input.command, + model: { providerID: taskModel.providerID, modelID: taskModel.modelID }, + prompt: templateParts.find((y) => y.type === "text")?.text ?? "", + }, + ] + : [...templateParts, ...(input.parts ?? [])] + + const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName + const userModel = isSubtask + ? input.model + ? Provider.parseModel(input.model) + : yield* Effect.promise(() => lastModelImpl(input.sessionID)) + : taskModel + + yield* plugin.trigger( + "command.execute.before", + { command: input.command, sessionID: input.sessionID, arguments: input.arguments }, + { parts }, + ) + + const result = yield* prompt({ + sessionID: input.sessionID, + messageID: input.messageID, + model: userModel, + agent: userAgent, + parts, + variant: input.variant, + }) yield* bus.publish(Command.Event.Executed, { name: input.command, sessionID: input.sessionID, @@ -556,6 +670,7 @@ export namespace SessionPrompt { Layer.provide(SessionStatus.layer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Command.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 1b4bb2d19ce6..928089eac710 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -6,6 +6,7 @@ import path from "path" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" +import { Command } from "../../src/command" import { Config } from "../../src/config/config" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -165,6 +166,7 @@ const deps = Layer.mergeAll( Session.defaultLayer, Snapshot.defaultLayer, AgentSvc.defaultLayer, + Command.defaultLayer, Permission.layer, Plugin.defaultLayer, Config.defaultLayer, @@ -375,108 +377,114 @@ it.effect("loop sets status to busy then idle", () => // Priority 2: Cancel safety -it.effect("cancel interrupts loop and returns last assistant", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) - yield* seed(chat.id) - - // Make LLM hang so the loop blocks - yield* test.push((input) => hang(input, start())) - - // Seed a new user message so the loop enters the LLM path - yield* user(chat.id, "more") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - // Give the loop time to start - yield* Effect.promise(() => new Promise((r) => setTimeout(r, 200))) - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - } - }), - { git: true, config: cfg }, - ), +it.effect( + "cancel interrupts loop and returns last assistant", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + yield* seed(chat.id) + + // Make LLM hang so the loop blocks + yield* test.push((input) => hang(input, start())) + + // Seed a new user message so the loop enters the LLM path + yield* user(chat.id, "more") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + // Give the loop time to start + yield* Effect.promise(() => new Promise((r) => setTimeout(r, 200))) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + } + }), + { git: true, config: cfg }, + ), 30_000, ) -it.effect("cancel records MessageAbortedError on interrupted process", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - yield* test.push((input) => - hang(input, start()).pipe( - Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), - ), - ) - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hello") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => ready.promise) - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - const info = exit.value.info - if (info.role === "assistant") { - expect(info.error?.name).toBe("MessageAbortedError") +it.effect( + "cancel records MessageAbortedError on interrupted process", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const info = exit.value.info + if (info.role === "assistant") { + expect(info.error?.name).toBe("MessageAbortedError") + } } - } - }), - { git: true, config: cfg }, - ), + }), + { git: true, config: cfg }, + ), 30_000, ) -it.effect("cancel with queued callers resolves all cleanly", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - yield* test.push((input) => - hang(input, start()).pipe( - Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), - ), - ) - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hello") - - const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => ready.promise) - // Queue a second caller - const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50))) - - yield* prompt.cancel(chat.id) - - const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - // Both should resolve (success or interrupt, not error) - expect(Exit.isFailure(exitA) && !Cause.hasInterruptsOnly(exitA.cause)).toBe(false) - expect(Exit.isFailure(exitB) && !Cause.hasInterruptsOnly(exitB.cause)).toBe(false) - }), - { git: true, config: cfg }, - ), +it.effect( + "cancel with queued callers resolves all cleanly", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hello") + + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + // Queue a second caller + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50))) + + yield* prompt.cancel(chat.id) + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + // Both should resolve (success or interrupt, not error) + expect(Exit.isFailure(exitA) && !Cause.hasInterruptsOnly(exitA.cause)).toBe(false) + expect(Exit.isFailure(exitB) && !Cause.hasInterruptsOnly(exitB.cause)).toBe(false) + }), + { git: true, config: cfg }, + ), 30_000, ) @@ -518,10 +526,9 @@ it.effect("concurrent loop callers all receive same error result", () => const chat = yield* sessions.create({}) yield* user(chat.id, "hello") - const [a, b] = yield* Effect.all( - [prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], - { concurrency: "unbounded" }, - ) + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) // Both callers get the same assistant with an error recorded expect(a.info.id).toBe(b.info.id) @@ -534,35 +541,37 @@ it.effect("concurrent loop callers all receive same error result", () => ), ) -it.effect("assertNotBusy throws BusyError when loop running", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - yield* test.push((input) => - hang(input, start()).pipe( - Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), - ), - ) - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => ready.promise) - - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: cfg }, - ), +it.effect( + "assertNotBusy throws BusyError when loop running", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + + const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: cfg }, + ), 30_000, ) @@ -583,36 +592,178 @@ it.effect("assertNotBusy succeeds when idle", () => // Priority 4: Shell basics -it.effect("shell rejects with BusyError when loop running", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service +it.effect( + "shell rejects with BusyError when loop running", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + + const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: cfg }, + ), + 30_000, +) - yield* test.push((input) => - hang(input, start()).pipe( - Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), - ), - ) +it.effect( + "loop waits while shell runs and starts after shell exits", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") + yield* test.reply(start(), textStart(), textDelta("t", "after-shell"), textEnd(), finishStep(), finish()) - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => ready.promise) + const chat = yield* sessions.create({}) - const exit = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "echo hi" }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) + .pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: cfg }, - ), + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + + expect(yield* test.calls).toBe(0) + + yield* Fiber.await(sh) + const exit = yield* Fiber.await(run) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true) + } + expect(yield* test.calls).toBe(1) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect( + "shell completion resumes queued loop callers", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + yield* test.reply(start(), textStart(), textDelta("t", "done"), textEnd(), finishStep(), finish()) + + const chat = yield* sessions.create({}) + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) + .pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + + expect(yield* test.calls).toBe(0) + + yield* Fiber.await(sh) + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) { + expect(ea.value.info.id).toBe(eb.value.info.id) + expect(ea.value.info.role).toBe("assistant") + } + expect(yield* test.calls).toBe(1) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect( + "cancel interrupts shell and resolves cleanly", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + expect(exit.value.parts.some((part) => part.type === "tool")).toBe(true) + } + + const status = yield* SessionStatus.Service + expect((yield* status.get(chat.id)).type).toBe("idle") + const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(busy)).toBe(true) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect( + "shell rejects when another shell is already running", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + + const a = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + + const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* prompt.cancel(chat.id) + yield* Fiber.await(a) + }), + { git: true, config: cfg }, + ), 30_000, ) From 8c10edc8c6f5cb099dd3aa06da450d93f62c8fa5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 20:02:13 -0400 Subject: [PATCH 09/66] remove dead resolveCommand and resolvePromptPartsImpl --- packages/opencode/src/session/prompt.ts | 181 ------------------------ 1 file changed, 181 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e0e76bf3fa0d..35cbff2c5353 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -823,57 +823,6 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolvePromptPartsImpl(template: string): Promise { - const parts: PromptInput["parts"] = [ - { - type: "text", - text: template, - }, - ] - const files = ConfigMarkdown.files(template) - const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return - seen.add(name) - const filepath = name.startsWith("~/") - ? path.join(os.homedir(), name.slice(2)) - : path.resolve(Instance.worktree, name) - - const stats = await fs.stat(filepath).catch(() => undefined) - if (!stats) { - const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return - } - - if (stats.isDirectory()) { - parts.push({ - type: "file", - url: pathToFileURL(filepath).href, - filename: name, - mime: "application/x-directory", - }) - return - } - - parts.push({ - type: "file", - url: pathToFileURL(filepath).href, - filename: name, - mime: "text/plain", - }) - }), - ) - return parts - } - async function handleSubtask(input: { task: MessageV2.SubtaskPart model: Provider.Model @@ -2024,136 +1973,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g - async function resolveCommand(input: CommandInput): Promise<{ promptInput: PromptInput }> { - log.info("command", input) - const cmd = await Command.get(input.command) - if (!cmd) { - const available = await Command.list().then((cmds) => cmds.map((c) => c.name)) - const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } - const agentName = cmd.agent ?? input.agent ?? (await Agent.defaultAgent()) - - const raw = input.arguments.match(argsRegex) ?? [] - const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) - - const templateCommand = await cmd.template - - const placeholders = templateCommand.match(placeholderRegex) ?? [] - let last = 0 - for (const item of placeholders) { - const value = Number(item.slice(1)) - if (value > last) last = value - } - - const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { - const position = Number(index) - const argIndex = position - 1 - if (argIndex >= args.length) return "" - if (position === last) return args.slice(argIndex).join(" ") - return args[argIndex] - }) - const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") - let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) - - if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { - template = template + "\n\n" + input.arguments - } - - const shellMatches = ConfigMarkdown.shell(template) - if (shellMatches.length > 0) { - const sh = Shell.preferred() - const results = await Promise.all( - shellMatches.map(async ([, cmd]) => { - const out = await Process.text([cmd], { shell: sh, nothrow: true }) - return out.text - }), - ) - let index = 0 - template = template.replace(bashRegex, () => results[index++]) - } - template = template.trim() - - const taskModel = await (async () => { - if (cmd.model) return Provider.parseModel(cmd.model) - if (cmd.agent) { - const cmdAgent = await Agent.get(cmd.agent) - if (cmdAgent?.model) return cmdAgent.model - } - if (input.model) return Provider.parseModel(input.model) - return await lastModelImpl(input.sessionID) - })() - - try { - await Provider.getModel(taskModel.providerID, taskModel.modelID) - } catch (e) { - if (Provider.ModelNotFoundError.isInstance(e)) { - const { providerID, modelID, suggestions } = e.data - const hint = suggestions?.length ? ` Did you mean: ${suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(), - }) - } - throw e - } - const agent = await Agent.get(agentName) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } - - const templateParts = await resolvePromptPartsImpl(template) - const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true - const parts = isSubtask - ? [ - { - type: "subtask" as const, - agent: agent.name, - description: cmd.description ?? "", - command: input.command, - model: { providerID: taskModel.providerID, modelID: taskModel.modelID }, - prompt: templateParts.find((y) => y.type === "text")?.text ?? "", - }, - ] - : [...templateParts, ...(input.parts ?? [])] - - const userAgent = isSubtask ? (input.agent ?? (await Agent.defaultAgent())) : agentName - const userModel = isSubtask - ? input.model - ? Provider.parseModel(input.model) - : await lastModelImpl(input.sessionID) - : taskModel - - await Plugin.trigger( - "command.execute.before", - { command: input.command, sessionID: input.sessionID, arguments: input.arguments }, - { parts }, - ) - - return { - promptInput: { - sessionID: input.sessionID, - messageID: input.messageID, - model: userModel, - agent: userAgent, - parts, - variant: input.variant, - }, - } - } - async function ensureTitle(input: { session: Session.Info history: MessageV2.WithParts[] From edc98d5128e9f96409ef2af7ba4e1a0d9bab3a9e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 20:20:58 -0400 Subject: [PATCH 10/66] effectify ensureTitle: move into layer, use agents/sessions services, remove dead code --- packages/opencode/src/session/prompt.ts | 198 +++++++++++------------- 1 file changed, 91 insertions(+), 107 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 35cbff2c5353..99cfbaa6e8de 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -43,7 +43,6 @@ import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" -import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" @@ -105,10 +104,7 @@ export namespace SessionPrompt { const loops = new Map() const shells = new Map>() yield* Effect.addFinalizer(() => - Fiber.interruptAll([ - ...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []), - ...shells.values(), - ]), + Fiber.interruptAll([...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), ...shells.values()]), ) return { loops, shells } }), @@ -174,6 +170,68 @@ export namespace SessionPrompt { return parts }) + const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { + session: Session.Info + history: MessageV2.WithParts[] + providerID: ProviderID + modelID: ModelID + }) { + if (input.session.parentID) return + if (!Session.isDefaultTitle(input.session.title)) return + + const firstRealUserIdx = input.history.findIndex( + (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), + ) + if (firstRealUserIdx === -1) return + + const isFirst = + input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) + .length === 1 + if (!isFirst) return + + const context = input.history.slice(0, firstRealUserIdx + 1) + const firstUser = context[firstRealUserIdx] + if (!firstUser || firstUser.info.role !== "user") return + + const subtasks = firstUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[] + const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask") + + const ag = yield* agents.get("title") + if (!ag) return + const mdl = yield* Effect.promise(async () => { + if (ag.model) return Provider.getModel(ag.model.providerID, ag.model.modelID) + return (await Provider.getSmallModel(input.providerID)) ?? Provider.getModel(input.providerID, input.modelID) + }) + const result = yield* Effect.promise(async () => { + const msgs = onlySubtasks + ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] + : await MessageV2.toModelMessages(context, mdl) + return LLM.stream({ + agent: ag, + user: firstUser.info as MessageV2.User, + system: [], + small: true, + tools: {}, + model: mdl, + abort: new AbortController().signal, + sessionID: input.session.id, + retries: 2, + messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], + }) + }) + const text = yield* Effect.promise(() => result.text) + const cleaned = text + .replace(/[\s\S]*?<\/think>\s*/g, "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!cleaned) return + const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe( + Effect.catchCause(() => Effect.void), + ) + }) + const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) yield* Effect.promise(() => SessionRevert.cleanup(session)) @@ -240,14 +298,12 @@ export namespace SessionPrompt { step++ if (step === 1) - yield* Effect.promise(() => - ensureTitle({ - session, - modelID: lastUser.model.modelID, - providerID: lastUser.model.providerID, - history: msgs, - }), - ).pipe(Effect.ignore, Effect.forkIn(scope)) + yield* title({ + session, + modelID: lastUser.model.modelID, + providerID: lastUser.model.providerID, + history: msgs, + }).pipe(Effect.ignore, Effect.forkIn(scope)) const model = yield* Effect.promise(() => Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => { @@ -337,7 +393,15 @@ export namespace SessionPrompt { const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const tools = yield* Effect.promise(() => - resolveTools({ agent, session, model, tools: lastUser!.tools, processor: handle, bypassAgentCheck, messages: msgs }), + resolveTools({ + agent, + session, + model, + tools: lastUser!.tools, + processor: handle, + bypassAgentCheck, + messages: msgs, + }), ) if (lastUser!.format?.type === "json_schema") { @@ -389,10 +453,7 @@ export namespace SessionPrompt { abort: ctrl.signal, sessionID, system, - messages: [ - ...modelMsgs, - ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), - ], + messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, @@ -461,9 +522,10 @@ export namespace SessionPrompt { const entry = s.loops.get(sessionID) if (entry) { // On interrupt, resolve queued callers with the last assistant message - const resolved = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - ? Exit.succeed(yield* lastAssistant(sessionID)) - : exit + const resolved = + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Exit.succeed(yield* lastAssistant(sessionID)) + : exit for (const d of entry.queue) yield* Deferred.done(d, resolved) } s.loops.delete(sessionID) @@ -568,7 +630,9 @@ export namespace SessionPrompt { if (shellMatches.length > 0) { const sh = Shell.preferred() const results = yield* Effect.promise(() => - Promise.all(shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text)), + Promise.all( + shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text), + ), ) let index = 0 template = template.replace(bashRegex, () => results[index++]) @@ -591,7 +655,9 @@ export namespace SessionPrompt { const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" Bus.publish(Session.Event.Error, { sessionID: input.sessionID, - error: new NamedError.Unknown({ message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}` }).toObject(), + error: new NamedError.Unknown({ + message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, + }).toObject(), }) } throw e @@ -913,11 +979,7 @@ export namespace SessionPrompt { sessionID, messageID: assistantMessage.id, })) - await Plugin.trigger( - "tool.execute.after", - { tool: "task", sessionID, callID: part.id, args: taskArgs }, - result, - ) + await Plugin.trigger("tool.execute.after", { tool: "task", sessionID, callID: part.id, args: taskArgs }, result) assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) @@ -1821,9 +1883,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } await Session.updatePart(part) const sh = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) - ).toLowerCase() + const shellName = (process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)).toLowerCase() const invocations: Record = { nu: { @@ -1973,80 +2033,4 @@ NOTE: At any point in time through this workflow you should feel free to ask the const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g - async function ensureTitle(input: { - session: Session.Info - history: MessageV2.WithParts[] - providerID: ProviderID - modelID: ModelID - }) { - if (input.session.parentID) return - if (!Session.isDefaultTitle(input.session.title)) return - - // Find first non-synthetic user message - const firstRealUserIdx = input.history.findIndex( - (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), - ) - if (firstRealUserIdx === -1) return - - const isFirst = - input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) - .length === 1 - if (!isFirst) return - - // Gather all messages up to and including the first real user message for context - // This includes any shell/subtask executions that preceded the user's first prompt - const contextMessages = input.history.slice(0, firstRealUserIdx + 1) - const firstRealUser = contextMessages[firstRealUserIdx] - - // For subtask-only messages (from command invocations), extract the prompt directly - // since toModelMessage converts subtask parts to generic "The following tool was executed by the user" - const subtaskParts = firstRealUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[] - const hasOnlySubtaskParts = subtaskParts.length > 0 && firstRealUser.parts.every((p) => p.type === "subtask") - - const agent = await Agent.get("title") - if (!agent) return - const model = await iife(async () => { - if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID) - return ( - (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) - ) - }) - try { - const result = await LLM.stream({ - agent, - user: firstRealUser.info as MessageV2.User, - system: [], - small: true, - tools: {}, - model, - abort: new AbortController().signal, - sessionID: input.session.id, - retries: 2, - messages: [ - { - role: "user", - content: "Generate a title for this conversation:\n", - }, - ...(hasOnlySubtaskParts - ? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }] - : await MessageV2.toModelMessages(contextMessages, model)), - ], - }) - const text = await result.text - const cleaned = text - .replace(/[\s\S]*?<\/think>\s*/g, "") - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) - if (!cleaned) return - - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => { - if (NotFoundError.isInstance(err)) return - throw err - }) - } catch (error) { - log.error("failed to generate title", { error }) - } - } } From 06befef8d0b1d0aff6565d7aeae2c1dc669536fe Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 20:48:16 -0400 Subject: [PATCH 11/66] simplify title: consolidate Effect.promise calls, use fiber signal, shorten names --- packages/opencode/src/session/prompt.ts | 70 +++++++++++-------------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 99cfbaa6e8de..f98f78c4cdc7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -179,47 +179,43 @@ export namespace SessionPrompt { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return - const firstRealUserIdx = input.history.findIndex( - (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), - ) - if (firstRealUserIdx === -1) return - - const isFirst = - input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) - .length === 1 - if (!isFirst) return - - const context = input.history.slice(0, firstRealUserIdx + 1) - const firstUser = context[firstRealUserIdx] + const real = (m: MessageV2.WithParts) => + m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) + const idx = input.history.findIndex(real) + if (idx === -1) return + if (input.history.filter(real).length !== 1) return + + const context = input.history.slice(0, idx + 1) + const firstUser = context[idx] if (!firstUser || firstUser.info.role !== "user") return + const firstInfo = firstUser.info - const subtasks = firstUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[] + const subtasks = firstUser.parts.filter((p): p is MessageV2.SubtaskPart => p.type === "subtask") const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask") const ag = yield* agents.get("title") if (!ag) return - const mdl = yield* Effect.promise(async () => { - if (ag.model) return Provider.getModel(ag.model.providerID, ag.model.modelID) - return (await Provider.getSmallModel(input.providerID)) ?? Provider.getModel(input.providerID, input.modelID) - }) - const result = yield* Effect.promise(async () => { + const text = yield* Effect.promise(async (signal) => { + const mdl = ag.model + ? await Provider.getModel(ag.model.providerID, ag.model.modelID) + : (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) const msgs = onlySubtasks ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] : await MessageV2.toModelMessages(context, mdl) - return LLM.stream({ + const result = await LLM.stream({ agent: ag, - user: firstUser.info as MessageV2.User, + user: firstInfo, system: [], small: true, tools: {}, model: mdl, - abort: new AbortController().signal, + abort: signal, sessionID: input.session.id, retries: 2, messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], }) + return result.text }) - const text = yield* Effect.promise(() => result.text) const cleaned = text .replace(/[\s\S]*?<\/think>\s*/g, "") .split("\n") @@ -227,9 +223,7 @@ export namespace SessionPrompt { .find((line) => line.length > 0) if (!cleaned) return const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe( - Effect.catchCause(() => Effect.void), - ) + yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe(Effect.catchCause(() => Effect.void)) }) const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { @@ -277,10 +271,9 @@ export namespace SessionPrompt { 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 - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant - if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) - lastFinished = msg.info as MessageV2.Assistant + if (!lastUser && msg.info.role === "user") lastUser = msg.info + if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info + if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info if (lastUser && lastFinished) break const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") if (task && !lastFinished) tasks.push(...task) @@ -381,7 +374,7 @@ export namespace SessionPrompt { }) const ctrl = new AbortController() const handle = yield* processor.create({ - assistantMessage: msg as MessageV2.Assistant, + assistantMessage: msg, sessionID, model, abort: ctrl.signal, @@ -901,7 +894,7 @@ export namespace SessionPrompt { const { task, model, lastUser, sessionID, session, msgs, signal } = input const taskTool = await TaskTool.init() const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model - const assistantMessage = (await Session.updateMessage({ + const assistantMessage: MessageV2.Assistant = await Session.updateMessage({ id: MessageID.ascending(), role: "assistant", parentID: lastUser.id, @@ -915,8 +908,8 @@ export namespace SessionPrompt { modelID: taskModel.id, providerID: taskModel.providerID, time: { created: Date.now() }, - })) as MessageV2.Assistant - let part = (await Session.updatePart({ + }) + let part: MessageV2.ToolPart = await Session.updatePart({ id: PartID.ascending(), messageID: assistantMessage.id, sessionID: assistantMessage.sessionID, @@ -928,7 +921,7 @@ export namespace SessionPrompt { input: { prompt: task.prompt, description: task.description, subagent_type: task.agent, command: task.command }, time: { start: Date.now() }, }, - })) as MessageV2.ToolPart + }) const taskArgs = { prompt: task.prompt, description: task.description, @@ -954,11 +947,11 @@ export namespace SessionPrompt { extra: { bypassAgentCheck: true }, messages: msgs, async metadata(val) { - part = (await Session.updatePart({ + part = await Session.updatePart({ ...part, type: "tool", state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart + } satisfies MessageV2.ToolPart) }, async ask(req) { await Permission.ask({ @@ -1007,7 +1000,7 @@ export namespace SessionPrompt { start: part.state.status === "running" ? part.state.time.start : Date.now(), end: Date.now(), }, - metadata: "metadata" in part.state ? part.state.metadata : undefined, + metadata: part.state.status === "pending" ? undefined : part.state.metadata, input: part.state.input, }, } satisfies MessageV2.ToolPart) @@ -1334,7 +1327,7 @@ export namespace SessionPrompt { sessionID: input.sessionID, type: "text", synthetic: true, - text: content.text as string, + text: content.text, }) } else if ("blob" in content && content.blob) { // Handle binary content if needed @@ -2032,5 +2025,4 @@ NOTE: At any point in time through this workflow you should feel free to ask the const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi const placeholderRegex = /\$(\d+)/g const quoteTrimRegex = /^["']|["']$/g - } From b41bb7e90d044a1003f3adf88fefc86cce355aeb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 20:54:55 -0400 Subject: [PATCH 12/66] extract getModel helper, simplify Promise.resolve wrapper --- packages/opencode/src/session/prompt.ts | 46 +++++++++---------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f98f78c4cdc7..4643e85f9af2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -226,6 +226,20 @@ export namespace SessionPrompt { yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe(Effect.catchCause(() => Effect.void)) }) + const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) => + Effect.promise(() => + Provider.getModel(providerID, modelID).catch((e) => { + if (Provider.ModelNotFoundError.isInstance(e)) { + const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}` }).toObject(), + }) + } + throw e + }), + ) + const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) yield* Effect.promise(() => SessionRevert.cleanup(session)) @@ -298,20 +312,7 @@ export namespace SessionPrompt { history: msgs, }).pipe(Effect.ignore, Effect.forkIn(scope)) - const model = yield* Effect.promise(() => - Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => { - if (Provider.ModelNotFoundError.isInstance(e)) { - const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ - message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, - }).toObject(), - }) - } - throw e - }), - ) + const model = yield* getModel(lastUser!.model.providerID, lastUser!.model.modelID, sessionID) const task = tasks.pop() if (task?.type === "subtask") { @@ -596,7 +597,7 @@ export namespace SessionPrompt { const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) - const templateCommand = yield* Effect.promise(() => Promise.resolve(cmd.template)) + const templateCommand = yield* Effect.promise(async () => cmd.template) const placeholders = templateCommand.match(placeholderRegex) ?? [] let last = 0 @@ -642,20 +643,7 @@ export namespace SessionPrompt { return await lastModelImpl(input.sessionID) }) - yield* Effect.promise(() => - Provider.getModel(taskModel.providerID, taskModel.modelID).catch((e) => { - if (Provider.ModelNotFoundError.isInstance(e)) { - const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ - message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, - }).toObject(), - }) - } - throw e - }), - ) + yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) const agent = yield* agents.get(agentName) if (!agent) { From 7c9ccdeb1bd3331a6cf76968d73be13aabf26296 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 21:09:10 -0400 Subject: [PATCH 13/66] fix type errors after rebase: add casts for Session.updateMessage/updatePart returns --- packages/opencode/src/session/prompt.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4643e85f9af2..95871f22ebb6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -375,7 +375,7 @@ export namespace SessionPrompt { }) const ctrl = new AbortController() const handle = yield* processor.create({ - assistantMessage: msg, + assistantMessage: msg as MessageV2.Assistant, sessionID, model, abort: ctrl.signal, @@ -882,7 +882,7 @@ export namespace SessionPrompt { const { task, model, lastUser, sessionID, session, msgs, signal } = input const taskTool = await TaskTool.init() const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model - const assistantMessage: MessageV2.Assistant = await Session.updateMessage({ + const assistantMessage = (await Session.updateMessage({ id: MessageID.ascending(), role: "assistant", parentID: lastUser.id, @@ -896,8 +896,8 @@ export namespace SessionPrompt { modelID: taskModel.id, providerID: taskModel.providerID, time: { created: Date.now() }, - }) - let part: MessageV2.ToolPart = await Session.updatePart({ + })) as MessageV2.Assistant + let part = (await Session.updatePart({ id: PartID.ascending(), messageID: assistantMessage.id, sessionID: assistantMessage.sessionID, @@ -909,7 +909,7 @@ export namespace SessionPrompt { input: { prompt: task.prompt, description: task.description, subagent_type: task.agent, command: task.command }, time: { start: Date.now() }, }, - }) + })) as MessageV2.ToolPart const taskArgs = { prompt: task.prompt, description: task.description, @@ -935,11 +935,11 @@ export namespace SessionPrompt { extra: { bypassAgentCheck: true }, messages: msgs, async metadata(val) { - part = await Session.updatePart({ + part = (await Session.updatePart({ ...part, type: "tool", state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) + } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart }, async ask(req) { await Permission.ask({ From 724e599cb06d87e77ce3c655eabe8649a937e020 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 21:15:37 -0400 Subject: [PATCH 14/66] effectify createUserMessage: move into layer as createMessage, delete old 400-line async version --- packages/opencode/src/session/prompt.ts | 650 +++++++++--------------- 1 file changed, 245 insertions(+), 405 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 95871f22ebb6..8d418c88754b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -97,6 +97,9 @@ export namespace SessionPrompt { const plugin = yield* Plugin.Service const commands = yield* Command.Service const fsys = yield* AppFileSystem.Service + const mcp = yield* MCP.Service + const lsp = yield* LSP.Service + const filetime = yield* FileTime.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -240,10 +243,248 @@ export namespace SessionPrompt { }), ) + const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { + const agentName = input.agent || (yield* agents.defaultAgent()) + const ag = yield* agents.get(agentName) + if (!ag) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + + const model = input.model ?? ag.model ?? (yield* Effect.promise(() => lastModelImpl(input.sessionID))) + const full = + !input.variant && ag.variant + ? yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID).catch(() => undefined)) + : undefined + const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + + const info: MessageV2.Info = { + id: input.messageID ?? MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + tools: input.tools, + agent: ag.name, + model, + system: input.system, + format: input.format, + variant, + } + + type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never + const assign = (part: Draft): MessageV2.Part => ({ + ...part, + id: part.id ? PartID.make(part.id) : PartID.ascending(), + }) + + const parts = yield* Effect.promise(() => + Promise.all( + input.parts.map(async (part): Promise[]> => { + if (part.type === "file") { + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + const pieces: Draft[] = [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Reading MCP resource: ${part.filename} (${uri})` }, + ] + try { + const content = await MCP.readResource(clientName, uri) + if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + const items = Array.isArray(content.contents) ? content.contents : [content.contents] + for (const c of items) { + if ("text" in c && c.text) { + pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: c.text }) + } else if ("blob" in c && c.blob) { + const mime = "mimeType" in c ? c.mimeType : part.mime + pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `[Binary content: ${mime}]` }) + } + } + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } catch (error: unknown) { + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Failed to read MCP resource ${part.filename}: ${message}` }) + } + return pieces + } + const url = new URL(part.url) + switch (url.protocol) { + case "data:": + if (part.mime === "text/plain") { + return [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}` }, + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: decodeDataUrl(part.url) }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] + } + break + case "file:": { + log.info("file", { mime: part.mime }) + const filepath = fileURLToPath(part.url) + const s = Filesystem.stat(filepath) + if (s?.isDirectory()) part.mime = "application/x-directory" + + if (part.mime === "text/plain") { + let offset: number | undefined + let limit: number | undefined + const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } + if (range.start != null) { + const filePathURI = part.url.split("?")[0] + let start = parseInt(range.start) + let end = range.end ? parseInt(range.end) : undefined + if (start === end) { + const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) + for (const symbol of symbols) { + let r: LSP.Range | undefined + if ("range" in symbol) r = symbol.range + else if ("location" in symbol) r = symbol.location.range + if (r?.start?.line && r?.start?.line === start) { + start = r.start.line + end = r?.end?.line ?? start + break + } + } + } + offset = Math.max(start, 1) + if (end) limit = end - (offset - 1) + } + const args = { filePath: filepath, offset, limit } + const pieces: Draft[] = [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify(args)}` }, + ] + await ReadTool.init() + .then(async (t) => { + const mdl = await Provider.getModel(info.model.providerID, info.model.modelID) + const ctx: Tool.Context = { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true, model: mdl }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + } + const result = await t.execute(args, ctx) + pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: result.output }) + if (result.attachments?.length) { + pieces.push(...result.attachments.map((a) => ({ ...a, synthetic: true, filename: a.filename ?? part.filename, messageID: info.id, sessionID: input.sessionID }))) + } else { + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } + }) + .catch((error) => { + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : error.toString() + Bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: new NamedError.Unknown({ message }).toObject() }) + pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Read tool failed to read ${filepath} with the following error: ${message}` }) + }) + return pieces + } + + if (part.mime === "application/x-directory") { + const args = { filePath: filepath } + const ctx: Tool.Context = { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + } + const result = await ReadTool.init().then((t) => t.execute(args, ctx)) + return [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify(args)}` }, + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: result.output }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] + } + + await FileTime.read(input.sessionID, filepath) + return [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: {"filePath":"${filepath}"}` }, + { + id: part.id, + messageID: info.id, + sessionID: input.sessionID, + type: "file", + url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), + mime: part.mime, + filename: part.filename!, + source: part.source, + }, + ] + } + } + } + + if (part.type === "agent") { + const perm = Permission.evaluate("task", part.name, ag.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + return [ + { ...part, messageID: info.id, sessionID: input.sessionID }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: " Use the above message and context to generate a prompt and call the task tool with subagent: " + part.name + hint, + }, + ] + } + + return [{ ...part, messageID: info.id, sessionID: input.sessionID }] + }), + ).then((x) => x.flat().map(assign)), + ) + + yield* plugin.trigger("chat.message", { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID: input.messageID, + variant: input.variant, + }, { message: info, parts }) + + const parsed = MessageV2.Info.safeParse(info) + if (!parsed.success) { + log.error("invalid user message before save", { + sessionID: input.sessionID, + messageID: info.id, + agent: info.agent, + model: info.model, + issues: parsed.error.issues, + }) + } + parts.forEach((part, index) => { + const p = MessageV2.Part.safeParse(part) + if (p.success) return + log.error("invalid user part before save", { + sessionID: input.sessionID, + messageID: info.id, + partID: part.id, + partType: part.type, + index, + issues: p.error.issues, + part, + }) + }) + + yield* sessions.updateMessage(info) + for (const part of parts) yield* sessions.updatePart(part) + + return { info, parts } + }) + const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) yield* Effect.promise(() => SessionRevert.cleanup(session)) - const message = yield* Effect.promise(() => createUserMessage(input)) + const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) const permissions: Permission.Ruleset = [] @@ -718,6 +959,9 @@ export namespace SessionPrompt { Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Command.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), @@ -1235,410 +1479,6 @@ export namespace SessionPrompt { }, }) } - - async function createUserMessage(input: PromptInput) { - const agentName = input.agent || (await Agent.defaultAgent()) - const agent = await Agent.get(agentName) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } - - const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID)) - const full = - !input.variant && agent.variant - ? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined) - : undefined - const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined) - - const info: MessageV2.Info = { - id: input.messageID ?? MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - tools: input.tools, - agent: agent.name, - model, - system: input.system, - format: input.format, - variant, - } - using _ = defer(() => InstructionPrompt.clear(info.id)) - - type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ - ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), - }) - - const parts = await Promise.all( - input.parts.map(async (part): Promise[]> => { - if (part.type === "file") { - // before checking the protocol we check if this is an mcp resource because it needs special handling - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - - try { - const resourceContent = await MCP.readResource(clientName, uri) - if (!resourceContent) { - throw new Error(`Resource not found: ${clientName}/${uri}`) - } - - // Handle different content types - const contents = Array.isArray(resourceContent.contents) - ? resourceContent.contents - : [resourceContent.contents] - - for (const content of contents) { - if ("text" in content && content.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: content.text, - }) - } else if ("blob" in content && content.blob) { - // Handle binary content if needed - const mimeType = "mimeType" in content ? content.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mimeType}]`, - }) - } - } - - pieces.push({ - ...part, - messageID: info.id, - sessionID: input.sessionID, - }) - } catch (error: unknown) { - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) - } - - return pieces - } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - ] - } - break - case "file:": - log.info("file", { mime: part.mime }) - // have to normalize, symbol search returns absolute paths - // Decode the pathname since URL constructor doesn't automatically decode it - const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - - if (s?.isDirectory()) { - part.mime = "application/x-directory" - } - - if (part.mime === "text/plain") { - let offset: number | undefined = undefined - let limit: number | undefined = undefined - const range = { - start: url.searchParams.get("start"), - end: url.searchParams.get("end"), - } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - // some LSP servers (eg, gopls) don't give full range in - // workspace/symbol searches, so we'll try to find the - // symbol in the document to get the full range - if (start === end) { - const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) - for (const symbol of symbols) { - let range: LSP.Range | undefined - if ("range" in symbol) { - range = symbol.range - } else if ("location" in symbol) { - range = symbol.location.range - } - if (range?.start?.line && range?.start?.line === start) { - start = range.start.line - end = range?.end?.line ?? start - break - } - } - } - offset = Math.max(start, 1) - if (end) { - limit = end - (offset - 1) - } - } - const args = { filePath: filepath, offset, limit } - - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - - await ReadTool.init() - .then(async (t) => { - const model = await Provider.getModel(info.model.providerID, info.model.modelID) - const readCtx: Tool.Context = { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, model }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - } - const result = await t.execute(args, readCtx) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((attachment) => ({ - ...attachment, - synthetic: true, - filename: attachment.filename ?? part.filename, - messageID: info.id, - sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ - ...part, - messageID: info.id, - sessionID: input.sessionID, - }) - } - }) - .catch((error) => { - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : error.toString() - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ - message, - }).toObject(), - }) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) - }) - - return pieces - } - - if (part.mime === "application/x-directory") { - const args = { filePath: filepath } - const listCtx: Tool.Context = { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - } - const result = await ReadTool.init().then((t) => t.execute(args, listCtx)) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }, - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - ] - } - - await FileTime.read(input.sessionID, filepath) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, - synthetic: true, - }, - { - id: part.id, - messageID: info.id, - sessionID: input.sessionID, - type: "file", - url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), - mime: part.mime, - filename: part.filename!, - source: part.source, - }, - ] - } - } - - if (part.type === "agent") { - // Check if this agent would be denied by task permission - const perm = Permission.evaluate("task", part.name, agent.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - // An extra space is added here. Otherwise the 'Use' gets appended - // to user's last word; making a combined word - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } - - return [ - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - ] - }), - ).then((x) => x.flat().map(assign)) - - await Plugin.trigger( - "chat.message", - { - sessionID: input.sessionID, - agent: input.agent, - model: input.model, - messageID: input.messageID, - variant: input.variant, - }, - { - message: info, - parts, - }, - ) - - const parsedInfo = MessageV2.Info.safeParse(info) - if (!parsedInfo.success) { - log.error("invalid user message before save", { - sessionID: input.sessionID, - messageID: info.id, - agent: info.agent, - model: info.model, - issues: parsedInfo.error.issues, - }) - } - - parts.forEach((part, index) => { - const parsedPart = MessageV2.Part.safeParse(part) - if (parsedPart.success) return - log.error("invalid user part before save", { - sessionID: input.sessionID, - messageID: info.id, - partID: part.id, - partType: part.type, - index, - issues: parsedPart.error.issues, - part, - }) - }) - - await Session.updateMessage(info) - for (const part of parts) { - await Session.updatePart(part) - } - - return { - info, - parts, - } - } - async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) { const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages From eb05bda03b59b0e035e175a77a3b8368a98f4e8f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Mar 2026 21:35:24 -0400 Subject: [PATCH 15/66] remove abort from llm and processor services --- packages/opencode/src/session/compaction.ts | 2 - packages/opencode/src/session/llm.ts | 20 +- packages/opencode/src/session/processor.ts | 54 +-- packages/opencode/src/session/prompt.ts | 145 +++++-- .../opencode/test/session/compaction.test.ts | 2 +- .../test/session/processor-effect.test.ts | 86 ++-- .../test/session/prompt-effect.test.ts | 380 +++++++++++++----- 7 files changed, 445 insertions(+), 244 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 223e71639cc8..7e6b81317b78 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -235,7 +235,6 @@ When constructing the summary, try to stick to this template: assistantMessage: msg, sessionID: input.sessionID, model, - abort: input.abort, }) const cancel = Effect.fn("SessionCompaction.cancel")(function* () { if (!input.abort.aborted || msg.time.completed) return @@ -248,7 +247,6 @@ When constructing the summary, try to stick to this template: .process({ user: userMessage, agent, - abort: input.abort, sessionID: input.sessionID, tools: {}, system: [], diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 02b72f70a4d6..4d7d80b24107 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,6 +1,6 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Record, ServiceMap } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { mergeDeep, pipe } from "remeda" @@ -28,7 +28,6 @@ export namespace LLM { agent: Agent.Info permission?: Permission.Ruleset system: string[] - abort: AbortSignal messages: ModelMessage[] small?: boolean tools: Record @@ -36,6 +35,10 @@ export namespace LLM { toolChoice?: "auto" | "required" | "none" } + export type StreamRequest = StreamInput & { + abort: AbortSignal + } + export type Event = Awaited>["fullStream"] extends AsyncIterable ? T : never export interface Interface { @@ -50,7 +53,7 @@ export namespace LLM { return Service.of({ stream(input) { return Stream.unwrap( - Effect.promise(() => LLM.stream(input)).pipe( + Effect.promise((signal) => LLM.stream({ ...input, abort: signal })).pipe( Effect.map((result) => Stream.fromAsyncIterable(result.fullStream, (err) => err).pipe( Stream.mapEffect((event) => Effect.succeed(event)), @@ -65,7 +68,7 @@ export namespace LLM { export const defaultLayer = layer - export async function stream(input: StreamInput) { + export async function stream(input: StreamRequest) { const l = log .clone() .tag("providerID", input.model.providerID) @@ -314,17 +317,12 @@ export namespace LLM { }) } - async function resolveTools(input: Pick) { + function resolveTools(input: Pick) { const disabled = Permission.disabled( Object.keys(input.tools), Permission.merge(input.agent.permission, input.permission ?? []), ) - for (const tool of Object.keys(input.tools)) { - if (input.user.tools?.[tool] === false || disabled.has(tool)) { - delete input.tools[tool] - } - } - return input.tools + return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k)) } // Check if messages contain any tool-call content diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index d2459cd8ba5a..7ee23bf306bd 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -2,7 +2,6 @@ import { Cause, Effect, Exit, Layer, ServiceMap } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" -import { makeRuntime } from "@/effect/run-service" import { Config } from "@/config/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" @@ -35,17 +34,10 @@ export namespace SessionProcessor { readonly process: (streamInput: LLM.StreamInput) => Effect.Effect } - export interface Info { - readonly message: MessageV2.Assistant - readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined - readonly process: (streamInput: LLM.StreamInput) => Promise - } - type Input = { assistantMessage: MessageV2.Assistant sessionID: SessionID model: Provider.Model - abort: AbortSignal } export interface Interface { @@ -96,7 +88,6 @@ export namespace SessionProcessor { assistantMessage: input.assistantMessage, sessionID: input.sessionID, model: input.model, - abort: input.abort, toolcalls: {}, shouldBreak: false, snapshot: undefined, @@ -105,11 +96,12 @@ export namespace SessionProcessor { currentText: undefined, reasoningMap: {}, } + let aborted = false const parse = (e: unknown) => MessageV2.fromError(e, { providerID: input.model.providerID, - aborted: input.abort.aborted, + aborted, }) const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { @@ -440,16 +432,12 @@ export namespace SessionProcessor { const stream = llm.stream(streamInput) yield* stream.pipe( - Stream.tap((event) => - Effect.gen(function* () { - input.abort.throwIfAborted() - yield* handleEvent(event) - }), - ), + Stream.tap((event) => handleEvent(event)), Stream.takeUntil(() => ctx.needsCompaction), Stream.runDrain, ) }).pipe( + Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))), Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause)), @@ -468,17 +456,20 @@ export namespace SessionProcessor { ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) - ? halt(new DOMException("Aborted", "AbortError")) + ? Effect.gen(function* () { + aborted = true + yield* halt(new DOMException("Aborted", "AbortError")) + }) : halt(Cause.squash(cause)), ), Effect.ensuring(cleanup()), ) - if (input.abort.aborted && !ctx.assistantMessage.error) { + if (aborted && !ctx.assistantMessage.error) { yield* abort() } if (ctx.needsCompaction) return "compact" - if (ctx.blocked || ctx.assistantMessage.error || input.abort.aborted) return "stop" + if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop" return "continue" }) @@ -526,29 +517,4 @@ export namespace SessionProcessor { ), ), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function create(input: Input): Promise { - const hit = await runPromise((svc) => svc.create(input)) - return { - get message() { - return hit.message - }, - partFromToolCall(toolCallID: string) { - return hit.partFromToolCall(toolCallID) - }, - async process(streamInput: LLM.StreamInput) { - const exit = await Effect.runPromiseExit(hit.process(streamInput), { signal: input.abort }) - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && input.abort.aborted) { - await Effect.runPromise(hit.abort()) - return "stop" - } - throw Cause.squash(exit.cause) - } - return exit.value - }, - } - } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8d418c88754b..a54f8f747113 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -201,7 +201,8 @@ export namespace SessionPrompt { const text = yield* Effect.promise(async (signal) => { const mdl = ag.model ? await Provider.getModel(ag.model.providerID, ag.model.modelID) - : (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) + : ((await Provider.getSmallModel(input.providerID)) ?? + (await Provider.getModel(input.providerID, input.modelID))) const msgs = onlySubtasks ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] : await MessageV2.toModelMessages(context, mdl) @@ -236,7 +237,9 @@ export namespace SessionPrompt { const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" Bus.publish(Session.Event.Error, { sessionID, - error: new NamedError.Unknown({ message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}` }).toObject(), + error: new NamedError.Unknown({ + message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, + }).toObject(), }) } throw e @@ -288,7 +291,13 @@ export namespace SessionPrompt { const { clientName, uri } = part.source log.info("mcp resource", { clientName, uri, mime: part.mime }) const pieces: Draft[] = [ - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Reading MCP resource: ${part.filename} (${uri})` }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, ] try { const content = await MCP.readResource(clientName, uri) @@ -296,17 +305,35 @@ export namespace SessionPrompt { const items = Array.isArray(content.contents) ? content.contents : [content.contents] for (const c of items) { if ("text" in c && c.text) { - pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: c.text }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: c.text, + }) } else if ("blob" in c && c.blob) { const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `[Binary content: ${mime}]` }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mime}]`, + }) } } pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } catch (error: unknown) { log.error("failed to read MCP resource", { error, clientName, uri }) const message = error instanceof Error ? error.message : String(error) - pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Failed to read MCP resource ${part.filename}: ${message}` }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) } return pieces } @@ -315,8 +342,20 @@ export namespace SessionPrompt { case "data:": if (part.mime === "text/plain") { return [ - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}` }, - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: decodeDataUrl(part.url) }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: decodeDataUrl(part.url), + }, { ...part, messageID: info.id, sessionID: input.sessionID }, ] } @@ -353,7 +392,13 @@ export namespace SessionPrompt { } const args = { filePath: filepath, offset, limit } const pieces: Draft[] = [ - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify(args)}` }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, ] await ReadTool.init() .then(async (t) => { @@ -369,9 +414,23 @@ export namespace SessionPrompt { ask: async () => {}, } const result = await t.execute(args, ctx) - pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: result.output }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }) if (result.attachments?.length) { - pieces.push(...result.attachments.map((a) => ({ ...a, synthetic: true, filename: a.filename ?? part.filename, messageID: info.id, sessionID: input.sessionID }))) + pieces.push( + ...result.attachments.map((a) => ({ + ...a, + synthetic: true, + filename: a.filename ?? part.filename, + messageID: info.id, + sessionID: input.sessionID, + })), + ) } else { pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } @@ -379,8 +438,17 @@ export namespace SessionPrompt { .catch((error) => { log.error("failed to read file", { error }) const message = error instanceof Error ? error.message : error.toString() - Bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: new NamedError.Unknown({ message }).toObject() }) - pieces.push({ messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Read tool failed to read ${filepath} with the following error: ${message}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) }) return pieces } @@ -399,15 +467,33 @@ export namespace SessionPrompt { } const result = await ReadTool.init().then((t) => t.execute(args, ctx)) return [ - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: ${JSON.stringify(args)}` }, - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: result.output }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }, { ...part, messageID: info.id, sessionID: input.sessionID }, ] } await FileTime.read(input.sessionID, filepath) return [ - { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, text: `Called the Read tool with the following input: {"filePath":"${filepath}"}` }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + }, { id: part.id, messageID: info.id, @@ -433,7 +519,10 @@ export namespace SessionPrompt { sessionID: input.sessionID, type: "text", synthetic: true, - text: " Use the above message and context to generate a prompt and call the task tool with subagent: " + part.name + hint, + text: + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, }, ] } @@ -443,13 +532,17 @@ export namespace SessionPrompt { ).then((x) => x.flat().map(assign)), ) - yield* plugin.trigger("chat.message", { - sessionID: input.sessionID, - agent: input.agent, - model: input.model, - messageID: input.messageID, - variant: input.variant, - }, { message: info, parts }) + yield* plugin.trigger( + "chat.message", + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID: input.messageID, + variant: input.variant, + }, + { message: info, parts }, + ) const parsed = MessageV2.Info.safeParse(info) if (!parsed.success) { @@ -614,12 +707,10 @@ export namespace SessionPrompt { time: { created: Date.now() }, sessionID, }) - const ctrl = new AbortController() const handle = yield* processor.create({ assistantMessage: msg as MessageV2.Assistant, sessionID, model, - abort: ctrl.signal, }) const outcome: "break" | "continue" = yield* Effect.onExit( @@ -685,7 +776,6 @@ export namespace SessionPrompt { user: lastUser!, agent, permission: session.permission, - abort: ctrl.signal, sessionID, system, messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], @@ -727,7 +817,6 @@ export namespace SessionPrompt { }), (exit) => Effect.gen(function* () { - ctrl.abort() if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() InstructionPrompt.clear(handle.message.id) }), diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 9c8559c35a2c..479582351310 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -129,7 +129,7 @@ async function tool(sessionID: SessionID, messageID: MessageID, tool: string, ou } function fake( - input: Parameters<(typeof SessionProcessorModule.SessionProcessor)["create"]>[0], + input: Parameters[0], result: "continue" | "compact", ) { const msg = input.assistantMessage diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index cd9d97e15fdd..9efbaa159df5 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" import { APICallError } from "ai" -import { Effect, Layer, ServiceMap } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, ServiceMap } from "effect" import * as Stream from "effect/Stream" import path from "path" import type { Agent } from "../../src/agent/agent" @@ -120,21 +120,8 @@ function fail(err: E, ...items: LLM.Event[]) { return stream(...items).pipe(Stream.concat(Stream.fail(err))) } -function wait(abort: AbortSignal) { - return Effect.promise( - () => - new Promise((done) => { - abort.addEventListener("abort", () => done(), { once: true }) - }), - ) -} - -function hang(input: LLM.StreamInput, ...items: LLM.Event[]) { - return stream(...items).pipe( - Stream.concat( - Stream.unwrap(wait(input.abort).pipe(Effect.as(Stream.fail(new DOMException("Aborted", "AbortError"))))), - ), - ) +function hang(_input: LLM.StreamInput, ...items: LLM.Event[]) { + return stream(...items).pipe(Stream.concat(Stream.fromEffect(Effect.never))) } function model(context: number): Provider.Model { @@ -291,13 +278,11 @@ it.effect("session.processor effect tests capture llm input cleanly", () => { const chat = yield* session.create({}) const parent = yield* user(chat.id, "hi") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const input = { @@ -313,7 +298,6 @@ it.effect("session.processor effect tests capture llm input cleanly", () => { model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "hi" }], tools: {}, } satisfies LLM.StreamInput @@ -359,13 +343,11 @@ it.effect("session.processor effect tests stop after token overflow requests com const chat = yield* session.create({}) const parent = yield* user(chat.id, "compact") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(20) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const value = yield* handle.process({ @@ -381,7 +363,6 @@ it.effect("session.processor effect tests stop after token overflow requests com model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "compact" }], tools: {}, }) @@ -433,13 +414,11 @@ it.effect("session.processor effect tests reset reasoning state across retries", const chat = yield* session.create({}) const parent = yield* user(chat.id, "reason") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const value = yield* handle.process({ @@ -455,7 +434,6 @@ it.effect("session.processor effect tests reset reasoning state across retries", model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "reason" }], tools: {}, }) @@ -485,13 +463,11 @@ it.effect("session.processor effect tests do not retry unknown json errors", () const chat = yield* session.create({}) const parent = yield* user(chat.id, "json") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const value = yield* handle.process({ @@ -507,7 +483,6 @@ it.effect("session.processor effect tests do not retry unknown json errors", () model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "json" }], tools: {}, }) @@ -535,13 +510,11 @@ it.effect("session.processor effect tests retry recognized structured json error const chat = yield* session.create({}) const parent = yield* user(chat.id, "retry json") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const value = yield* handle.process({ @@ -557,7 +530,6 @@ it.effect("session.processor effect tests retry recognized structured json error model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "retry json" }], tools: {}, }) @@ -601,7 +573,6 @@ it.effect("session.processor effect tests publish retry status updates", () => { const chat = yield* session.create({}) const parent = yield* user(chat.id, "retry") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const states: number[] = [] const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { @@ -612,7 +583,6 @@ it.effect("session.processor effect tests publish retry status updates", () => { assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const value = yield* handle.process({ @@ -628,7 +598,6 @@ it.effect("session.processor effect tests publish retry status updates", () => { model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "retry" }], tools: {}, }) @@ -656,13 +625,11 @@ it.effect("session.processor effect tests compact on structured context overflow const chat = yield* session.create({}) const parent = yield* user(chat.id, "compact json") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) const value = yield* handle.process({ @@ -678,7 +645,6 @@ it.effect("session.processor effect tests compact on structured context overflow model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "compact json" }], tools: {}, }) @@ -710,17 +676,15 @@ it.effect("session.processor effect tests mark pending tools as aborted on clean const chat = yield* session.create({}) const parent = yield* user(chat.id, "tool abort") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) - const run = Effect.runPromise( - handle.process({ + const run = yield* handle + .process({ user: { id: parent.id, sessionID: chat.id, @@ -733,20 +697,25 @@ it.effect("session.processor effect tests mark pending tools as aborted on clean model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "tool abort" }], tools: {}, - }), - ) + }) + .pipe(Effect.forkChild) yield* Effect.promise(() => ready.promise) - abort.abort() + yield* Fiber.interrupt(run) - const value = yield* Effect.promise(() => run) + const exit = yield* Fiber.await(run) + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + yield* handle.abort() + } const parts = yield* Effect.promise(() => MessageV2.parts(msg.id)) const tool = parts.find((part): part is MessageV2.ToolPart => part.type === "tool") - expect(value).toBe("stop") + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } expect(yield* test.calls).toBe(1) expect(tool?.state.status).toBe("error") if (tool?.state.status === "error") { @@ -779,7 +748,6 @@ it.effect("session.processor effect tests record aborted errors and idle state", const chat = yield* session.create({}) const parent = yield* user(chat.id, "abort") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const abort = new AbortController() const mdl = model(100) const errs: string[] = [] const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => { @@ -792,11 +760,10 @@ it.effect("session.processor effect tests record aborted errors and idle state", assistantMessage: msg, sessionID: chat.id, model: mdl, - abort: abort.signal, }) - const run = Effect.runPromise( - handle.process({ + const run = yield* handle + .process({ user: { id: parent.id, sessionID: chat.id, @@ -809,22 +776,27 @@ it.effect("session.processor effect tests record aborted errors and idle state", model: mdl, agent: agent(), system: [], - abort: abort.signal, messages: [{ role: "user", content: "abort" }], tools: {}, - }), - ) + }) + .pipe(Effect.forkChild) yield* Effect.promise(() => ready.promise) - abort.abort() + yield* Fiber.interrupt(run) - const value = yield* Effect.promise(() => run) + const exit = yield* Fiber.await(run) + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + yield* handle.abort() + } yield* Effect.promise(() => seen.promise) const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id })) const state = yield* status.get(chat.id) off() - expect(value).toBe("stop") + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } expect(handle.message.error?.name).toBe("MessageAbortedError") expect(stored.info.role).toBe("assistant") if (stored.info.role === "assistant") { diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 928089eac710..56828bf86502 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -8,6 +8,9 @@ import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config/config" +import { FileTime } from "../../src/file/time" +import { LSP } from "../../src/lsp" +import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import type { Provider } from "../../src/provider/provider" @@ -98,21 +101,39 @@ function finish(): LLM.Event { return { type: "finish", finishReason: "stop", rawFinishReason: "stop", totalUsage: usage() } } -function wait(abort: AbortSignal) { - return Effect.promise( - () => - new Promise((done) => { - abort.addEventListener("abort", () => done(), { once: true }) - }), - ) +function finishToolCallsStep(): LLM.Event { + return { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: usage(), + } } -function hang(input: LLM.StreamInput, ...items: LLM.Event[]) { - return stream(...items).pipe( - Stream.concat( - Stream.unwrap(wait(input.abort).pipe(Effect.as(Stream.fail(new DOMException("Aborted", "AbortError"))))), - ), - ) +function finishToolCalls(): LLM.Event { + return { type: "finish", finishReason: "tool-calls", rawFinishReason: "tool_calls", totalUsage: usage() } +} + +function replyStop(text: string, id = "t") { + return [start(), textStart(id), textDelta(id, text), textEnd(id), finishStep(), finish()] as const +} + +function replyToolCalls(text: string, id = "t") { + return [start(), textStart(id), textDelta(id, text), textEnd(id), finishToolCallsStep(), finishToolCalls()] as const +} + +function toolInputStart(id: string, toolName: string): LLM.Event { + return { type: "tool-input-start", id, toolName } +} + +function toolCall(toolCallId: string, toolName: string, input: unknown): LLM.Event { + return { type: "tool-call", toolCallId, toolName, input } +} + +function hang(_input: LLM.StreamInput, ...items: LLM.Event[]) { + return stream(...items).pipe(Stream.concat(Stream.fromEffect(Effect.never))) } function defer() { @@ -123,6 +144,36 @@ function defer() { return { promise, resolve } } +function waitMs(ms: number) { + return Effect.promise(() => new Promise((done) => setTimeout(done, ms))) +} + +function toolPart(parts: MessageV2.Part[]) { + return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") +} + +type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } +type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } +type RunningToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateRunning } + +function completedTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("completed") + return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined +} + +function errorTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("error") + return part?.state.status === "error" ? (part as ErrorToolPart) : undefined +} + +function runningTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("running") + return part?.state.status === "running" ? (part as RunningToolPart) : undefined +} + const llm = Layer.unwrap( Effect.gen(function* () { const queue: Script[] = [] @@ -160,6 +211,59 @@ const llm = Layer.unwrap( }), ) +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const filetime = Layer.succeed( + FileTime.Service, + FileTime.Service.of({ + read: () => Effect.void, + get: () => Effect.succeed(undefined), + assert: () => Effect.void, + withLock: (_filepath, fn) => Effect.promise(fn), + }), +) + const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const deps = Layer.mergeAll( @@ -170,6 +274,9 @@ const deps = Layer.mergeAll( Permission.layer, Plugin.defaultLayer, Config.defaultLayer, + filetime, + lsp, + mcp, AppFileSystem.defaultLayer, status, llm, @@ -260,17 +367,36 @@ const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { fi return { user: msg, assistant } }) -// Priority 1: Loop lifecycle +const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.updatePart({ + id: PartID.ascending(), + messageID, + sessionID, + type: "subtask", + prompt: "look into the cache key path", + description: "inspect bug", + agent: "general", + model, + }) + }) + +const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { + const test = yield* TestLLM + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create(input ?? {}) + return { test, prompt, sessions, chat } +}) + +// Loop semantics it.effect("loop exits immediately when last assistant has stop finish", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) + const { test, prompt, chat } = yield* boot() yield* seed(chat.id, { finish: "stop" }) const result = yield* prompt.loop({ sessionID: chat.id }) @@ -286,13 +412,8 @@ it.effect("loop calls LLM and returns assistant message", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - yield* test.reply(start(), textStart(), textDelta("t", "world"), textEnd(), finishStep(), finish()) - - const chat = yield* sessions.create({}) + const { test, prompt, chat } = yield* boot() + yield* test.reply(...replyStop("world")) yield* user(chat.id, "hello") const result = yield* prompt.loop({ sessionID: chat.id }) @@ -309,16 +430,32 @@ it.effect("loop continues when finish is tool-calls", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service + const { test, prompt, chat } = yield* boot() + yield* test.reply(...replyToolCalls("first")) + yield* test.reply(...replyStop("second")) + yield* user(chat.id, "hello") + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(yield* test.calls).toBe(2) + expect(result.info.role).toBe("assistant") + }), + { git: true, config: cfg }, + ), +) - // First reply finishes with tool-calls, second with stop +it.effect("failed subtask preserves metadata on error tool state", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { test, prompt, chat } = yield* boot({ title: "Pinned" }) yield* test.reply( start(), - textStart(), - textDelta("t", "first"), - textEnd(), + toolInputStart("task-1", "task"), + toolCall("task-1", "task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }), { type: "finish-step", finishReason: "tool-calls", @@ -329,16 +466,41 @@ it.effect("loop continues when finish is tool-calls", () => }, { type: "finish", finishReason: "tool-calls", rawFinishReason: "tool_calls", totalUsage: usage() }, ) - yield* test.reply(start(), textStart(), textDelta("t", "second"), textEnd(), finishStep(), finish()) - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hello") + yield* test.reply(...replyStop("done")) + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) const result = yield* prompt.loop({ sessionID: chat.id }) - expect(yield* test.calls).toBe(2) expect(result.info.role).toBe("assistant") + expect(yield* test.calls).toBe(2) + + const msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(chat.id))) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(taskMsg?.info.role).toBe("assistant") + if (!taskMsg || taskMsg.info.role !== "assistant") return + + const tool = errorTool(taskMsg.parts) + if (!tool) return + + expect(tool.state.error).toContain("Tool execution failed") + expect(tool.state.metadata).toBeDefined() + expect(tool.state.metadata?.sessionId).toBeDefined() + expect(tool.state.metadata?.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("missing-model"), + }) }), - { git: true, config: cfg }, + { + git: true, + config: { + ...cfg, + agent: { + general: { + model: "test/missing-model", + }, + }, + }, + }, ), ) @@ -375,7 +537,7 @@ it.effect("loop sets status to busy then idle", () => ), ) -// Priority 2: Cancel safety +// Cancel semantics it.effect( "cancel interrupts loop and returns last assistant", @@ -383,11 +545,7 @@ it.effect( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) + const { test, prompt, chat } = yield* boot() yield* seed(chat.id) // Make LLM hang so the loop blocks @@ -398,7 +556,7 @@ it.effect( const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) // Give the loop time to start - yield* Effect.promise(() => new Promise((r) => setTimeout(r, 200))) + yield* waitMs(200) yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) @@ -419,17 +577,13 @@ it.effect( (dir) => Effect.gen(function* () { const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service + const { test, prompt, chat } = yield* boot() yield* test.push((input) => hang(input, start()).pipe( Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), ), ) - - const chat = yield* sessions.create({}) yield* user(chat.id, "hello") const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) @@ -457,24 +611,20 @@ it.effect( (dir) => Effect.gen(function* () { const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service + const { test, prompt, chat } = yield* boot() yield* test.push((input) => hang(input, start()).pipe( Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), ), ) - - const chat = yield* sessions.create({}) yield* user(chat.id, "hello") const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* Effect.promise(() => ready.promise) // Queue a second caller const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50))) + yield* waitMs(50) yield* prompt.cancel(chat.id) @@ -488,17 +638,13 @@ it.effect( 30_000, ) -// Priority 3: Deferred queue +// Queue semantics it.effect("concurrent loop callers get same result", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) + const { prompt, chat } = yield* boot() yield* seed(chat.id, { finish: "stop" }) const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { @@ -516,14 +662,10 @@ it.effect("concurrent loop callers all receive same error result", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service + const { test, prompt, chat } = yield* boot() // Push a stream that fails — the loop records the error on the assistant message yield* test.push(Stream.fail(new Error("boom"))) - - const chat = yield* sessions.create({}) yield* user(chat.id, "hello") const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { @@ -590,7 +732,7 @@ it.effect("assertNotBusy succeeds when idle", () => ), ) -// Priority 4: Shell basics +// Shell semantics it.effect( "shell rejects with BusyError when loop running", @@ -599,17 +741,13 @@ it.effect( (dir) => Effect.gen(function* () { const ready = defer() - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service + const { test, prompt, chat } = yield* boot() yield* test.push((input) => hang(input, start()).pipe( Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), ), ) - - const chat = yield* sessions.create({}) yield* user(chat.id, "hi") const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) @@ -626,27 +764,78 @@ it.effect( 30_000, ) +it.effect("shell captures stdout and stderr in completed tool output", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "printf out && printf err >&2", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("out") + expect(tool.state.output).toContain("err") + expect(tool.state.metadata.output).toContain("out") + expect(tool.state.metadata.output).toContain("err") + }), + { git: true, config: cfg }, + ), +) + it.effect( - "loop waits while shell runs and starts after shell exits", + "shell updates running metadata before process exit", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service + const { prompt, chat } = yield* boot() - yield* test.reply(start(), textStart(), textDelta("t", "after-shell"), textEnd(), finishStep(), finish()) + const fiber = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" }) + .pipe(Effect.forkChild) - const chat = yield* sessions.create({}) + yield* Effect.promise(async () => { + const start = Date.now() + while (Date.now() - start < 2000) { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant") + const tool = taskMsg ? runningTool(taskMsg.parts) : undefined + if (tool?.state.metadata?.output.includes("first")) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running shell metadata") + }) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.effect( + "loop waits while shell runs and starts after shell exits", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { test, prompt, chat } = yield* boot() + yield* test.reply(...replyStop("after-shell")) const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) .pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + yield* waitMs(50) const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + yield* waitMs(50) expect(yield* test.calls).toBe(0) @@ -671,22 +860,17 @@ it.effect( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const test = yield* TestLLM - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - yield* test.reply(start(), textStart(), textDelta("t", "done"), textEnd(), finishStep(), finish()) - - const chat = yield* sessions.create({}) + const { test, prompt, chat } = yield* boot() + yield* test.reply(...replyStop("done")) const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) .pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + yield* waitMs(50) const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + yield* waitMs(50) expect(yield* test.calls).toBe(0) @@ -712,15 +896,12 @@ it.effect( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) + const { prompt, chat } = yield* boot() const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) .pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + yield* waitMs(50) yield* prompt.cancel(chat.id) @@ -747,15 +928,12 @@ it.effect( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) + const { prompt, chat } = yield* boot() const a = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) .pipe(Effect.forkChild) - yield* Effect.promise(() => new Promise((done) => setTimeout(done, 50))) + yield* waitMs(50) const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) From cdac7a7dd144509157bc45f1bd9c309d5eef1ec2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 12:42:35 -0400 Subject: [PATCH 16/66] remove abort from compaction service --- packages/opencode/src/session/compaction.ts | 38 ++++++------------- packages/opencode/src/session/prompt.ts | 17 ++++----- .../opencode/test/session/compaction.test.ts | 7 ---- 3 files changed, 19 insertions(+), 43 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 7e6b81317b78..1729f475d2b3 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -45,7 +45,6 @@ export namespace SessionCompaction { parentID: MessageID messages: MessageV2.WithParts[] sessionID: SessionID - abort: AbortSignal auto: boolean overflow?: boolean }) => Effect.Effect<"continue" | "stop"> @@ -135,7 +134,6 @@ export namespace SessionCompaction { parentID: MessageID messages: MessageV2.WithParts[] sessionID: SessionID - abort: AbortSignal auto: boolean overflow?: boolean }) { @@ -236,13 +234,6 @@ When constructing the summary, try to stick to this template: sessionID: input.sessionID, model, }) - const cancel = Effect.fn("SessionCompaction.cancel")(function* () { - if (!input.abort.aborted || msg.time.completed) return - msg.error = msg.error ?? new MessageV2.AbortedError({ message: "Aborted" }).toObject() - msg.finish = msg.finish ?? "error" - msg.time.completed = Date.now() - yield* session.updateMessage(msg) - }) const result = yield* processor .process({ user: userMessage, @@ -259,7 +250,7 @@ When constructing the summary, try to stick to this template: ], model, }) - .pipe(Effect.ensuring(cancel())) + .pipe(Effect.onInterrupt(() => processor.abort())) if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ @@ -383,7 +374,7 @@ When constructing the summary, try to stick to this template: ), ) - const { runPromise, runPromiseExit } = makeRuntime(Service, defaultLayer) + const { runPromise } = makeRuntime(Service, defaultLayer) export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { return runPromise((svc) => svc.isOverflow(input)) @@ -393,21 +384,16 @@ When constructing the summary, try to stick to this template: return runPromise((svc) => svc.prune(input)) } - export async function process(input: { - parentID: MessageID - messages: MessageV2.WithParts[] - sessionID: SessionID - abort: AbortSignal - auto: boolean - overflow?: boolean - }) { - const exit = await runPromiseExit((svc) => svc.process(input), { signal: input.abort }) - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && input.abort.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - } + export const process = fn( + z.object({ + parentID: MessageID.zod, + messages: z.custom(), + sessionID: SessionID.zod, + auto: z.boolean(), + overflow: z.boolean().optional(), + }), + (input) => runPromise((svc) => svc.process(input)), + ) export const create = fn( z.object({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a54f8f747113..6916ddced690 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -657,16 +657,13 @@ export namespace SessionPrompt { } if (task?.type === "compaction") { - const result = yield* Effect.promise((signal) => - SessionCompaction.process({ - messages: msgs, - parentID: lastUser!.id, - abort: signal, - sessionID, - auto: task.auto, - overflow: task.overflow, - }), - ) + const result = yield* compaction.process({ + messages: msgs, + parentID: lastUser!.id, + sessionID, + auto: task.auto, + overflow: task.overflow, + }) if (result === "stop") break continue } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 479582351310..637cf8e67f6d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -540,7 +540,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: new AbortController().signal, auto: false, }), ), @@ -580,7 +579,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: new AbortController().signal, auto: false, }), ), @@ -621,7 +619,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: new AbortController().signal, auto: true, }), ), @@ -675,7 +672,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: new AbortController().signal, auto: true, overflow: true, }), @@ -717,7 +713,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: new AbortController().signal, auto: true, overflow: true, }), @@ -792,7 +787,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: abort.signal, auto: false, }), ), @@ -858,7 +852,6 @@ describe("session.compaction.process", () => { parentID: msg.id, messages: msgs, sessionID: session.id, - abort: abort.signal, auto: false, }), ), From b23e5b78a867a7749cf9d4bc0111fadc04a83013 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 12:21:10 -0400 Subject: [PATCH 17/66] make Session.updateMessage/updatePart generic, remove all MessageV2 casts Generic interface preserves the narrowed type through update calls, eliminating `as MessageV2.Assistant` / `as MessageV2.ToolPart` casts. Async facades keep Zod validation via parse + cast, with `.force()` bypass. Tighten test assertions for BusyError type and concurrent caller identity. --- packages/opencode/src/session/index.ts | 65 +++++++++++------- .../test/session/prompt-effect.test.ts | 68 ++++++++++++++++--- 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index eb01739c156f..e524217a4a57 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -350,14 +350,14 @@ export namespace Session { readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect - readonly updateMessage: (msg: MessageV2.Info) => Effect.Effect + readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly removePart: (input: { sessionID: SessionID messageID: MessageID partID: PartID }) => Effect.Effect - readonly updatePart: (part: MessageV2.Part) => Effect.Effect + readonly updatePart: (part: T) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID messageID: MessageID @@ -485,26 +485,23 @@ export namespace Session { } }) - const updateMessage = Effect.fn("Session.updateMessage")(function* (msg: MessageV2.Info) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.Updated, { - sessionID: msg.sessionID, - info: msg, - }), - ) - return msg - }) - - const updatePart = Effect.fn("Session.updatePart")(function* (part: MessageV2.Part) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }), - ) - return part - }) + const updateMessage = (msg: T): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })) + return msg + }).pipe(Effect.withSpan("Session.updateMessage")) + + const updatePart = (part: T): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sync(() => + SyncEvent.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }), + ) + return part + }).pipe(Effect.withSpan("Session.updatePart")) const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID @@ -867,7 +864,17 @@ export namespace Session { export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id))) export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id))) - export const updateMessage = fn(MessageV2.Info, (msg) => runPromise((svc) => svc.updateMessage(msg))) + export const updateMessage = Object.assign( + async function updateMessage(msg: T): Promise { + return runPromise((svc) => svc.updateMessage(MessageV2.Info.parse(msg) as T)) + }, + { + schema: MessageV2.Info, + force(msg: T): Promise { + return runPromise((svc) => svc.updateMessage(msg)) + }, + }, + ) export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) => runPromise((svc) => svc.removeMessage(input)), @@ -878,7 +885,17 @@ export namespace Session { (input) => runPromise((svc) => svc.removePart(input)), ) - export const updatePart = fn(MessageV2.Part, (part) => runPromise((svc) => svc.updatePart(part))) + export const updatePart = Object.assign( + async function updatePart(part: T): Promise { + return runPromise((svc) => svc.updatePart(MessageV2.Part.parse(part) as T)) + }, + { + schema: MessageV2.Part, + force(part: T): Promise { + return runPromise((svc) => svc.updatePart(part)) + }, + }, + ) export const updatePartDelta = fn( z.object({ diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 56828bf86502..30140a3324c7 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -386,7 +386,7 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { const test = yield* TestLLM const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service - const chat = yield* sessions.create(input ?? {}) + const chat = yield* sessions.create(input ?? { title: "Pinned" }) return { test, prompt, sessions, chat } }) @@ -438,6 +438,10 @@ it.effect("loop continues when finish is tool-calls", () => const result = yield* prompt.loop({ sessionID: chat.id }) expect(yield* test.calls).toBe(2) expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + expect(result.info.finish).toBe("stop") + } }), { git: true, config: cfg }, ), @@ -540,7 +544,7 @@ it.effect("loop sets status to busy then idle", () => // Cancel semantics it.effect( - "cancel interrupts loop and returns last assistant", + "cancel interrupts loop and resolves with an assistant message", () => provideTmpdirInstance( (dir) => @@ -629,9 +633,11 @@ it.effect( yield* prompt.cancel(chat.id) const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - // Both should resolve (success or interrupt, not error) - expect(Exit.isFailure(exitA) && !Cause.hasInterruptsOnly(exitA.cause)).toBe(false) - expect(Exit.isFailure(exitB) && !Cause.hasInterruptsOnly(exitB.cause)).toBe(false) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { + expect(exitA.value.info.id).toBe(exitB.value.info.id) + } }), { git: true, config: cfg }, ), @@ -678,6 +684,9 @@ it.effect("concurrent loop callers all receive same error result", () => if (a.info.role === "assistant") { expect(a.info.error).toBeDefined() } + if (b.info.role === "assistant") { + expect(b.info.error).toBeDefined() + } }), { git: true, config: cfg }, ), @@ -708,6 +717,9 @@ it.effect( const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } yield* prompt.cancel(chat.id) yield* Fiber.await(fiber) @@ -755,6 +767,9 @@ it.effect( const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } yield* prompt.cancel(chat.id) yield* Fiber.await(fiber) @@ -805,8 +820,8 @@ it.effect( while (Date.now() - start < 2000) { const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) const taskMsg = msgs.find((item) => item.info.role === "assistant") - const tool = taskMsg ? runningTool(taskMsg.parts) : undefined - if (tool?.state.metadata?.output.includes("first")) return + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return await new Promise((done) => setTimeout(done, 20)) } throw new Error("timed out waiting for running shell metadata") @@ -909,7 +924,10 @@ it.effect( expect(Exit.isSuccess(exit)).toBe(true) if (Exit.isSuccess(exit)) { expect(exit.value.info.role).toBe("assistant") - expect(exit.value.parts.some((part) => part.type === "tool")).toBe(true) + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") + } } const status = yield* SessionStatus.Service @@ -922,6 +940,37 @@ it.effect( 30_000, ) +it.effect( + "cancel interrupts loop queued behind shell", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* waitMs(50) + + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* waitMs(50) + + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(run) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } + + yield* Fiber.await(sh) + }), + { git: true, config: cfg }, + ), + 30_000, +) + it.effect( "shell rejects when another shell is already running", () => @@ -937,6 +986,9 @@ it.effect( const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } yield* prompt.cancel(chat.id) yield* Fiber.await(a) From 41b92cbcc02dfbe5d83e398bb60d54bcdbf5c15f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 13:19:37 -0400 Subject: [PATCH 18/66] restore prompt runtime after rebase drift --- packages/opencode/src/session/prompt.ts | 31 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6916ddced690..938f3af196e0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -73,6 +73,11 @@ export namespace SessionPrompt { queue: Deferred.Deferred[] } + interface ShellEntry { + fiber: Fiber.Fiber + abort: AbortController + } + export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect @@ -105,9 +110,15 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { const loops = new Map() - const shells = new Map>() + const shells = new Map() yield* Effect.addFinalizer(() => - Fiber.interruptAll([...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), ...shells.values()]), + Effect.gen(function* () { + for (const item of shells.values()) item.abort.abort() + yield* Fiber.interruptAll([ + ...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), + ...shells.values().map((x) => x.fiber), + ]) + }), ) return { loops, shells } }), @@ -133,8 +144,7 @@ export namespace SessionPrompt { s.loops.delete(sessionID) } if (shellEntry) { - yield* Fiber.interrupt(shellEntry) - s.shells.delete(sessionID) + shellEntry.abort.abort() } yield* status.set(sessionID, { type: "idle" }) }) @@ -826,7 +836,7 @@ export namespace SessionPrompt { return yield* lastAssistant(sessionID) }) - type State = { loops: Map; shells: Map> } + type State = { loops: Map; shells: Map } const awaitFiber = (fiber: Fiber.Fiber, fallback: Effect.Effect) => Effect.gen(function* () { @@ -890,15 +900,18 @@ export namespace SessionPrompt { throw new Session.BusyError(input.sessionID) } - const fiber = yield* Effect.promise((signal) => shellImpl(input, signal)).pipe( + yield* status.set(input.sessionID, { type: "busy" }) + const ctrl = new AbortController() + const fiber = yield* Effect.promise(() => shellImpl(input, ctrl.signal)).pipe( Effect.ensuring( Effect.gen(function* () { - s.shells.delete(input.sessionID) + const entry = s.shells.get(input.sessionID) + if (entry?.fiber === fiber) s.shells.delete(input.sessionID) // If callers queued a loop while the shell was running, start it const pending = s.loops.get(input.sessionID) if (pending && pending.queue.length > 0) { yield* startLoop(s, input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope)) - } else { + } else if (!s.loops.has(input.sessionID) && !s.shells.has(input.sessionID)) { yield* status.set(input.sessionID, { type: "idle" }) } }), @@ -906,7 +919,7 @@ export namespace SessionPrompt { Effect.forkChild, ) - s.shells.set(input.sessionID, fiber) + s.shells.set(input.sessionID, { fiber, abort: ctrl }) return yield* awaitFiber(fiber, lastAssistant(input.sessionID)) }) From 9c2a06d2c79cd2fbe169ba8053a60d217dc3fdd5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 13:22:47 -0400 Subject: [PATCH 19/66] effectify prompt reminder injection --- packages/opencode/src/session/prompt.ts | 279 ++++++++++++------------ 1 file changed, 137 insertions(+), 142 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 938f3af196e0..e0c3c1a4eab0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import fs from "fs/promises" import z from "zod" import { Filesystem } from "../util/filesystem" import { SessionID, MessageID, PartID } from "./schema" @@ -240,6 +239,142 @@ export namespace SessionPrompt { yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe(Effect.catchCause(() => Effect.void)) }) + const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: { + messages: MessageV2.WithParts[] + agent: Agent.Info + session: Session.Info + }) { + const userMessage = input.messages.findLast((msg) => msg.info.role === "user") + if (!userMessage) return input.messages + + if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) { + if (input.agent.name === "plan") { + userMessage.parts.push({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: PROMPT_PLAN, + synthetic: true, + }) + } + const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") + if (wasPlan && input.agent.name === "build") { + userMessage.parts.push({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: BUILD_SWITCH, + synthetic: true, + }) + } + return input.messages + } + + const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") + if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { + const plan = Session.plan(input.session) + if (!(yield* fsys.existsSafe(plan))) return input.messages + const part = yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: + BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`, + synthetic: true, + }) + userMessage.parts.push(part) + return input.messages + } + + if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages + + const plan = Session.plan(input.session) + const exists = yield* fsys.existsSafe(plan) + if (!exists) yield* fsys.ensureDir(path.dirname(plan)) + const part = yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: userMessage.info.sessionID, + type: "text", + text: ` +Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. + +## Plan File Info: +${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`} +You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. + +## Plan Workflow + +### Phase 1: Initial Understanding +Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type. + +1. Focus on understanding the user's request and the code associated with their request + +2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase. + - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change. + - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning. + - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1) + - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns + +3. After exploring the code, use the question tool to clarify ambiguities in the user request up front. + +### Phase 2: Design +Goal: Design an implementation approach. + +Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1. + +You can launch up to 1 agent(s) in parallel. + +**Guidelines:** +- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives +- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames) + +Examples of when to use multiple agents: +- The task touches multiple parts of the codebase +- It's a large refactor or architectural change +- There are many edge cases to consider +- You'd benefit from exploring different approaches + +Example perspectives by task type: +- New feature: simplicity vs performance vs maintainability +- Bug fix: root cause vs workaround vs prevention +- Refactoring: minimal change vs clean architecture + +In the agent prompt: +- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces +- Describe requirements and constraints +- Request a detailed implementation plan + +### Phase 3: Review +Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions. +1. Read the critical files identified by agents to deepen your understanding +2. Ensure that the plans align with the user's original request +3. Use question tool to clarify any remaining questions with the user + +### Phase 4: Final Plan +Goal: Write your final plan to the plan file (the only file you can edit). +- Include only your recommended approach, not all alternatives +- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively +- Include the paths of critical files to be modified +- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests) + +### Phase 5: Call plan_exit tool +At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning. +This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons. + +**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does. + +NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins. +`, + synthetic: true, + }) + userMessage.parts.push(part) + return input.messages + }) + const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) => Effect.promise(() => Provider.getModel(providerID, modelID).catch((e) => { @@ -697,7 +832,7 @@ export namespace SessionPrompt { } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps - msgs = yield* Effect.promise(() => insertReminders({ messages: msgs, agent, session })) + msgs = yield* insertReminders({ messages: msgs, agent, session }) const msg = yield* sessions.updateMessage({ id: MessageID.ascending(), @@ -1578,146 +1713,6 @@ export namespace SessionPrompt { }, }) } - async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) { - const userMessage = input.messages.findLast((msg) => msg.info.role === "user") - if (!userMessage) return input.messages - - // Original logic when experimental plan mode is disabled - if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) { - if (input.agent.name === "plan") { - userMessage.parts.push({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: PROMPT_PLAN, - synthetic: true, - }) - } - const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") - if (wasPlan && input.agent.name === "build") { - userMessage.parts.push({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: BUILD_SWITCH, - synthetic: true, - }) - } - return input.messages - } - - // New plan mode logic when flag is enabled - const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") - - // Switching from plan mode to build mode - if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { - const plan = Session.plan(input.session) - const exists = await Filesystem.exists(plan) - if (exists) { - const part = await Session.updatePart({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: - BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`, - synthetic: true, - }) - userMessage.parts.push(part) - } - return input.messages - } - - // Entering plan mode - if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") { - const plan = Session.plan(input.session) - const exists = await Filesystem.exists(plan) - if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true }) - const part = await Session.updatePart({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: ` -Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. - -## Plan File Info: -${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`} -You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. - -## Plan Workflow - -### Phase 1: Initial Understanding -Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type. - -1. Focus on understanding the user's request and the code associated with their request - -2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase. - - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change. - - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning. - - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1) - - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns - -3. After exploring the code, use the question tool to clarify ambiguities in the user request up front. - -### Phase 2: Design -Goal: Design an implementation approach. - -Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1. - -You can launch up to 1 agent(s) in parallel. - -**Guidelines:** -- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives -- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames) - -Examples of when to use multiple agents: -- The task touches multiple parts of the codebase -- It's a large refactor or architectural change -- There are many edge cases to consider -- You'd benefit from exploring different approaches - -Example perspectives by task type: -- New feature: simplicity vs performance vs maintainability -- Bug fix: root cause vs workaround vs prevention -- Refactoring: minimal change vs clean architecture - -In the agent prompt: -- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces -- Describe requirements and constraints -- Request a detailed implementation plan - -### Phase 3: Review -Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions. -1. Read the critical files identified by agents to deepen your understanding -2. Ensure that the plans align with the user's original request -3. Use question tool to clarify any remaining questions with the user - -### Phase 4: Final Plan -Goal: Write your final plan to the plan file (the only file you can edit). -- Include only your recommended approach, not all alternatives -- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively -- Include the paths of critical files to be modified -- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests) - -### Phase 5: Call plan_exit tool -At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning. -This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons. - -**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does. - -NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins. -`, - synthetic: true, - }) - userMessage.parts.push(part) - return input.messages - } - return input.messages - } - async function shellImpl(input: ShellInput, signal: AbortSignal): Promise { const session = await Session.get(input.sessionID) if (session.revert) { From eaf3454103c36f720033cf14fdfbaf16dbcc6106 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 13:25:48 -0400 Subject: [PATCH 20/66] effectify prompt tool resolution --- packages/opencode/src/session/prompt.ts | 381 +++++++++++------------- 1 file changed, 178 insertions(+), 203 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e0c3c1a4eab0..33ed8df8eed9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -100,6 +100,7 @@ export namespace SessionPrompt { const compaction = yield* SessionCompaction.Service const plugin = yield* Plugin.Service const commands = yield* Command.Service + const permission = yield* Permission.Service const fsys = yield* AppFileSystem.Service const mcp = yield* MCP.Service const lsp = yield* LSP.Service @@ -375,6 +376,173 @@ NOTE: At any point in time through this workflow you should feel free to ask the return input.messages }) + const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: { + agent: Agent.Info + model: Provider.Model + session: Session.Info + tools?: Record + processor: Pick + bypassAgentCheck: boolean + messages: MessageV2.WithParts[] + }) { + using _ = log.time("resolveTools") + const tools: Record = {} + + const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({ + sessionID: input.session.id, + abort: options.abortSignal!, + messageID: input.processor.message.id, + callID: options.toolCallId, + extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, + agent: input.agent.name, + messages: input.messages, + metadata: (val) => + Effect.runPromise( + Effect.gen(function* () { + const match = input.processor.partFromToolCall(options.toolCallId) + if (!match || match.state.status !== "running") return + yield* sessions.updatePart({ + ...match, + state: { + title: val.title, + metadata: val.metadata, + status: "running", + input: args, + time: { start: Date.now() }, + }, + }) + }), + ), + ask: (req) => + Effect.runPromise( + permission.ask({ + ...req, + sessionID: input.session.id, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), + }), + ), + }) + + for (const item of yield* Effect.promise(() => + ToolRegistry.tools( + { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, + input.agent, + ), + )) { + const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) + tools[item.id] = tool({ + id: item.id as any, + description: item.description, + inputSchema: jsonSchema(schema as any), + execute(args, options) { + return Effect.runPromise( + Effect.gen(function* () { + const ctx = context(args, options) + yield* plugin.trigger( + "tool.execute.before", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, + { args }, + ) + const result = yield* Effect.promise(() => item.execute(args, ctx)) + const output = { + ...result, + attachments: result.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + } + yield* plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + output, + ) + return output + }), + ) + }, + }) + } + + for (const [key, item] of Object.entries(yield* mcp.tools())) { + const execute = item.execute + if (!execute) continue + + const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) + const transformed = ProviderTransform.schema(input.model, schema) + item.inputSchema = jsonSchema(transformed) + item.execute = (args, opts) => + Effect.runPromise( + Effect.gen(function* () { + const ctx = context(args, opts) + yield* plugin.trigger( + "tool.execute.before", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })) + const result: Awaited>> = yield* Effect.promise(() => + execute(args, opts), + ) + yield* plugin.trigger( + "tool.execute.after", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + result, + ) + + const textParts: string[] = [] + const attachments: Omit[] = [] + for (const contentItem of result.content) { + if (contentItem.type === "text") textParts.push(contentItem.text) + else if (contentItem.type === "image") { + attachments.push({ + type: "file", + mime: contentItem.mimeType, + url: `data:${contentItem.mimeType};base64,${contentItem.data}`, + }) + } else if (contentItem.type === "resource") { + const { resource } = contentItem + if (resource.text) textParts.push(resource.text) + if (resource.blob) { + attachments.push({ + type: "file", + mime: resource.mimeType ?? "application/octet-stream", + url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, + filename: resource.uri, + }) + } + } + } + + const truncated = yield* Effect.promise(() => Truncate.output(textParts.join("\n\n"), {}, input.agent)) + const metadata = { + ...(result.metadata ?? {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + } + + return { + title: "", + metadata, + output: truncated.content, + attachments: attachments.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + content: result.content, + } + }), + ) + tools[key] = item + } + + return tools + }) + const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) => Effect.promise(() => Provider.getModel(providerID, modelID).catch((e) => { @@ -860,17 +1028,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - const tools = yield* Effect.promise(() => - resolveTools({ - agent, - session, - model, - tools: lastUser!.tools, - processor: handle, - bypassAgentCheck, - messages: msgs, - }), - ) + const tools = yield* resolveTools({ + agent, + session, + model, + tools: lastUser!.tools, + processor: handle, + bypassAgentCheck, + messages: msgs, + }) if (lastUser!.format?.type === "json_schema") { tools["StructuredOutput"] = createStructuredOutputTool({ @@ -1193,6 +1359,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Command.defaultLayer), + Layer.provide(Permission.layer), Layer.provide(MCP.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(FileTime.layer), @@ -1492,198 +1659,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } - /** @internal Exported for testing */ - export async function resolveTools(input: { - agent: Agent.Info - model: Provider.Model - session: Session.Info - tools?: Record - processor: Pick - bypassAgentCheck: boolean - messages: MessageV2.WithParts[] - }) { - using _ = log.time("resolveTools") - const tools: Record = {} - - const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({ - sessionID: input.session.id, - abort: options.abortSignal!, - messageID: input.processor.message.id, - callID: options.toolCallId, - extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, - agent: input.agent.name, - messages: input.messages, - metadata: async (val: { title?: string; metadata?: any }) => { - const match = input.processor.partFromToolCall(options.toolCallId) - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { - start: Date.now(), - }, - }, - }) - } - }, - async ask(req) { - await Permission.ask({ - ...req, - sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), - }) - }, - }) - - for (const item of await ToolRegistry.tools( - { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, - input.agent, - )) { - const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) - tools[item.id] = tool({ - id: item.id as any, - description: item.description, - inputSchema: jsonSchema(schema as any), - async execute(args, options) { - const ctx = context(args, options) - await Plugin.trigger( - "tool.execute.before", - { - tool: item.id, - sessionID: ctx.sessionID, - callID: ctx.callID, - }, - { - args, - }, - ) - const result = await item.execute(args, ctx) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - } - await Plugin.trigger( - "tool.execute.after", - { - tool: item.id, - sessionID: ctx.sessionID, - callID: ctx.callID, - args, - }, - output, - ) - return output - }, - }) - } - - for (const [key, item] of Object.entries(await MCP.tools())) { - const execute = item.execute - if (!execute) continue - - const schema = await asSchema(item.inputSchema).jsonSchema - const transformed = ProviderTransform.schema(input.model, schema) - item.inputSchema = jsonSchema(transformed) - // Wrap execute to add plugin hooks and format output - item.execute = async (args, opts) => { - const ctx = context(args, opts) - - await Plugin.trigger( - "tool.execute.before", - { - tool: key, - sessionID: ctx.sessionID, - callID: opts.toolCallId, - }, - { - args, - }, - ) - - await ctx.ask({ - permission: key, - metadata: {}, - patterns: ["*"], - always: ["*"], - }) - - const result = await execute(args, opts) - - await Plugin.trigger( - "tool.execute.after", - { - tool: key, - sessionID: ctx.sessionID, - callID: opts.toolCallId, - args, - }, - result, - ) - - const textParts: string[] = [] - const attachments: Omit[] = [] - - for (const contentItem of result.content) { - if (contentItem.type === "text") { - textParts.push(contentItem.text) - } else if (contentItem.type === "image") { - attachments.push({ - type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, - }) - } else if (contentItem.type === "resource") { - const { resource } = contentItem - if (resource.text) { - textParts.push(resource.text) - } - if (resource.blob) { - attachments.push({ - type: "file", - mime: resource.mimeType ?? "application/octet-stream", - url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, - filename: resource.uri, - }) - } - } - } - - const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent) - const metadata = { - ...(result.metadata ?? {}), - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - } - - return { - title: "", - metadata, - output: truncated.content, - attachments: attachments.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - content: result.content, // directly return content to preserve ordering when outputting to model - } - } - tools[key] = item - } - - return tools - } - /** @internal Exported for testing */ export function createStructuredOutputTool(input: { schema: Record From bbed140fd71163da0c2f0819b56956ff4b815781 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 13:34:35 -0400 Subject: [PATCH 21/66] effectify prompt subtask handling --- packages/opencode/src/session/prompt.ts | 444 +++++++++++++----------- 1 file changed, 234 insertions(+), 210 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 33ed8df8eed9..eaf1e4da0d53 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -21,13 +21,12 @@ import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" -import { defer } from "../util/defer" +import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { FileTime } from "../file/time" -import { NotFoundError } from "@/storage/db" import { Flag } from "../flag/flag" import { ulid } from "ulid" import { spawn } from "child_process" @@ -78,13 +77,13 @@ export namespace SessionPrompt { } export interface Interface { - readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect - readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly loop: (input: z.infer) => Effect.Effect - readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect - readonly resolvePromptParts: (template: string) => Effect.Effect + readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect + readonly loop: (input: z.infer) => Effect.Effect + readonly shell: (input: ShellInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect + readonly resolvePromptParts: (template: string) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/SessionPrompt") {} @@ -543,6 +542,182 @@ NOTE: At any point in time through this workflow you should feel free to ask the return tools }) + const handleSubtask: (input: { + task: MessageV2.SubtaskPart + model: Provider.Model + lastUser: MessageV2.User + sessionID: SessionID + session: Session.Info + msgs: MessageV2.WithParts[] + }) => Effect.Effect = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { + task: MessageV2.SubtaskPart + model: Provider.Model + lastUser: MessageV2.User + sessionID: SessionID + session: Session.Info + msgs: MessageV2.WithParts[] + }) { + const { task, model, lastUser, sessionID, session, msgs } = input + const taskTool: Awaited> = yield* Effect.promise(() => TaskTool.init()) + const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model + const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: lastUser.id, + sessionID, + mode: task.agent, + agent: task.agent, + variant: lastUser.variant, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: taskModel.id, + providerID: taskModel.providerID, + time: { created: Date.now() }, + }) + let part: MessageV2.ToolPart = yield* sessions.updatePart({ + id: PartID.ascending(), + 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, + command: task.command, + }, + time: { start: Date.now() }, + }, + }) + const taskArgs = { + prompt: task.prompt, + description: task.description, + subagent_type: task.agent, + command: task.command, + } + yield* plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs }) + + const taskAgent = yield* agents.get(task.agent) + if (!taskAgent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + throw error + } + + let executionError: Error | undefined + const result = yield* Effect.promise((signal) => + taskTool + .execute(taskArgs, { + agent: task.agent, + messageID: assistantMessage.id, + sessionID, + abort: signal, + callID: part.callID, + extra: { bypassAgentCheck: true }, + messages: msgs, + metadata(val: { title?: string; metadata?: Record }) { + return Effect.runPromise( + Effect.gen(function* () { + part = yield* sessions.updatePart({ + ...part, + type: "tool", + state: { ...part.state, ...val }, + } satisfies MessageV2.ToolPart) + }), + ) + }, + ask(req: any) { + return Effect.runPromise( + permission.ask({ + ...req, + sessionID, + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + }), + ) + }, + }) + .catch((error) => { + executionError = error instanceof Error ? error : new Error(String(error)) + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }), + ) + + const attachments = result?.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID, + messageID: assistantMessage.id, + })) + + yield* plugin.trigger( + "tool.execute.after", + { tool: "task", sessionID, callID: part.id, args: taskArgs }, + result, + ) + + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(assistantMessage) + + if (result && part.state.status === "running") { + yield* sessions.updatePart({ + ...part, + state: { + status: "completed", + input: part.state.input, + title: result.title, + metadata: result.metadata, + output: result.output, + attachments, + time: { ...part.state.time, end: Date.now() }, + }, + } satisfies MessageV2.ToolPart) + } + + if (!result) { + yield* sessions.updatePart({ + ...part, + state: { + status: "error", + error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", + time: { + start: part.state.status === "running" ? part.state.time.start : Date.now(), + end: Date.now(), + }, + metadata: part.state.status === "pending" ? undefined : part.state.metadata, + input: part.state.input, + }, + } satisfies MessageV2.ToolPart) + } + + if (!task.command) return + + const summaryUserMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + } + yield* sessions.updateMessage(summaryUserMsg) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: summaryUserMsg.id, + sessionID, + type: "text", + text: "Summarize the task tool output above and continue with your task.", + synthetic: true, + } satisfies MessageV2.TextPart) + }) + const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) => Effect.promise(() => Provider.getModel(providerID, modelID).catch((e) => { @@ -887,7 +1062,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }) - const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( + "SessionPrompt.prompt", + )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) yield* Effect.promise(() => SessionRevert.cleanup(session)) const message = yield* createUserMessage(input) @@ -915,7 +1092,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw new Error("Impossible") }) - const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) { + const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn( + "SessionPrompt.run", + )(function* (sessionID: SessionID) { let structured: unknown | undefined let step = 0 const session = yield* sessions.get(sessionID) @@ -963,9 +1142,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const task = tasks.pop() if (task?.type === "subtask") { - yield* Effect.promise((signal) => - handleSubtask({ task, model, lastUser: lastUser!, sessionID, session, msgs, signal }), - ) + yield* handleSubtask({ task, model, lastUser: lastUser!, sessionID, session, msgs }) continue } @@ -1147,55 +1324,60 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* Effect.failCause(exit.cause as Cause.Cause) }) - const startLoop = Effect.fnUntraced(function* (s: State, sessionID: SessionID) { - const fiber = yield* runLoop(sessionID).pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - const entry = s.loops.get(sessionID) - if (entry) { - // On interrupt, resolve queued callers with the last assistant message - const resolved = - Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - ? Exit.succeed(yield* lastAssistant(sessionID)) - : exit - for (const d of entry.queue) yield* Deferred.done(d, resolved) - } - s.loops.delete(sessionID) - yield* status.set(sessionID, { type: "idle" }) - }), - ), - Effect.forkChild, - ) - const entry = s.loops.get(sessionID) - if (entry) { - entry.fiber = fiber - } else { - s.loops.set(sessionID, { fiber, queue: [] }) - } - return yield* awaitFiber(fiber, lastAssistant(sessionID)) - }) + const startLoop: (s: State, sessionID: SessionID) => Effect.Effect = + Effect.fnUntraced(function* (s: State, sessionID: SessionID) { + const fiber = yield* runLoop(sessionID).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + const entry = s.loops.get(sessionID) + if (entry) { + // On interrupt, resolve queued callers with the last assistant message + const resolved = + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Exit.succeed(yield* lastAssistant(sessionID)) + : exit + for (const d of entry.queue) yield* Deferred.done(d, resolved) + } + s.loops.delete(sessionID) + yield* status.set(sessionID, { type: "idle" }) + }), + ), + Effect.forkChild, + ) + const entry = s.loops.get(sessionID) + if (entry) { + entry.fiber = fiber + } else { + s.loops.set(sessionID, { fiber, queue: [] }) + } + return yield* awaitFiber(fiber, lastAssistant(sessionID)) + }) - const loop = Effect.fn("SessionPrompt.loop")(function* (input: z.infer) { + const loop: (input: z.infer) => Effect.Effect = Effect.fn( + "SessionPrompt.loop", + )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const existing = s.loops.get(input.sessionID) if (existing) { const d = yield* Deferred.make() existing.queue.push(d) - return yield* Deferred.await(d).pipe(Effect.orDie) + return yield* Deferred.await(d) } // If a shell is running, queue — shell cleanup will start the loop if (s.shells.has(input.sessionID)) { const d = yield* Deferred.make() s.loops.set(input.sessionID, { queue: [d] }) - return yield* Deferred.await(d).pipe(Effect.orDie) + return yield* Deferred.await(d) } return yield* startLoop(s, input.sessionID) }) - const shell = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) { + const shell: (input: ShellInput) => Effect.Effect = Effect.fn( + "SessionPrompt.shell", + )(function* (input: ShellInput) { const s = yield* InstanceState.get(cache) if (s.loops.has(input.sessionID) || s.shells.has(input.sessionID)) { throw new Session.BusyError(input.sessionID) @@ -1373,9 +1555,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const { runPromise } = makeRuntime(Service, defaultLayer) - export async function assertNotBusy(sessionID: SessionID) { - return runPromise((svc) => svc.assertNotBusy(sessionID)) - } + export const assertNotBusy = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.assertNotBusy(sessionID))) export const PromptInput = z.object({ sessionID: SessionID.zod, @@ -1444,25 +1624,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type PromptInput = z.infer - export async function prompt(input: PromptInput) { - return runPromise((svc) => svc.prompt(input)) - } + export const prompt = fn(PromptInput, (input) => runPromise((svc) => svc.prompt(input))) - export async function resolvePromptParts(template: string) { - return runPromise((svc) => svc.resolvePromptParts(template)) - } + export const resolvePromptParts = fn(z.string(), (template) => runPromise((svc) => svc.resolvePromptParts(template))) - export async function cancel(sessionID: SessionID) { - return runPromise((svc) => svc.cancel(sessionID)) - } + export const cancel = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.cancel(sessionID))) export const LoopInput = z.object({ sessionID: SessionID.zod, }) - export async function loop(input: z.infer) { - return runPromise((svc) => svc.loop(input)) - } + export const loop = fn(LoopInput, (input) => runPromise((svc) => svc.loop(input))) export const ShellInput = z.object({ sessionID: SessionID.zod, @@ -1477,9 +1649,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type ShellInput = z.infer - export async function shell(input: ShellInput) { - return runPromise((svc) => svc.shell(input)) - } + export const shell = fn(ShellInput, (input) => runPromise((svc) => svc.shell(input))) export const CommandInput = z.object({ messageID: MessageID.zod.optional(), @@ -1504,9 +1674,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type CommandInput = z.infer - export async function command(input: CommandInput) { - return runPromise((svc) => svc.command(input)) - } + export const command = fn(CommandInput, (input) => runPromise((svc) => svc.command(input))) async function lastModelImpl(sessionID: SessionID) { for await (const item of MessageV2.stream(sessionID)) { @@ -1515,150 +1683,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return Provider.defaultModel() } - async function handleSubtask(input: { - task: MessageV2.SubtaskPart - model: Provider.Model - lastUser: MessageV2.User - sessionID: SessionID - session: Session.Info - msgs: MessageV2.WithParts[] - signal: AbortSignal - }) { - const { task, model, lastUser, sessionID, session, msgs, signal } = input - const taskTool = await TaskTool.init() - const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model - const assistantMessage = (await Session.updateMessage({ - id: MessageID.ascending(), - role: "assistant", - parentID: lastUser.id, - sessionID, - mode: task.agent, - agent: task.agent, - variant: lastUser.variant, - path: { cwd: Instance.directory, root: Instance.worktree }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: taskModel.id, - providerID: taskModel.providerID, - time: { created: Date.now() }, - })) as MessageV2.Assistant - let part = (await Session.updatePart({ - id: PartID.ascending(), - 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, command: task.command }, - time: { start: Date.now() }, - }, - })) as MessageV2.ToolPart - const taskArgs = { - prompt: task.prompt, - description: task.description, - subagent_type: task.agent, - command: task.command, - } - await Plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs }) - let executionError: Error | undefined - const taskAgent = await Agent.get(task.agent) - if (!taskAgent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) - throw error - } - const taskCtx: Tool.Context = { - agent: task.agent, - messageID: assistantMessage.id, - sessionID, - abort: signal, - callID: part.callID, - extra: { bypassAgentCheck: true }, - messages: msgs, - async metadata(val) { - part = (await Session.updatePart({ - ...part, - type: "tool", - state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart - }, - async ask(req) { - await Permission.ask({ - ...req, - sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }) - }, - } - const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { - executionError = error - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }) - const attachments = result?.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID, - messageID: assistantMessage.id, - })) - await Plugin.trigger("tool.execute.after", { tool: "task", sessionID, callID: part.id, args: taskArgs }, result) - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - await Session.updateMessage(assistantMessage) - if (result && part.state.status === "running") { - await Session.updatePart({ - ...part, - state: { - status: "completed", - input: part.state.input, - title: result.title, - metadata: result.metadata, - output: result.output, - attachments, - time: { ...part.state.time, end: Date.now() }, - }, - } satisfies MessageV2.ToolPart) - } - if (!result) { - await Session.updatePart({ - ...part, - state: { - status: "error", - error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", - time: { - start: part.state.status === "running" ? part.state.time.start : Date.now(), - end: Date.now(), - }, - metadata: part.state.status === "pending" ? undefined : part.state.metadata, - input: part.state.input, - }, - } satisfies MessageV2.ToolPart) - } - if (task.command) { - const summaryUserMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: lastUser.agent, - model: lastUser.model, - } - await Session.updateMessage(summaryUserMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: summaryUserMsg.id, - sessionID, - type: "text", - text: "Summarize the task tool output above and continue with your task.", - synthetic: true, - } satisfies MessageV2.TextPart) - } - } - /** @internal Exported for testing */ export function createStructuredOutputTool(input: { schema: Record From 8b04ddc6bf9cc2c331e7f3737eb5ef4cf0082a87 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 13:37:59 -0400 Subject: [PATCH 22/66] effectify prompt shell execution --- packages/opencode/src/session/prompt.ts | 411 +++++++++++------------- 1 file changed, 181 insertions(+), 230 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eaf1e4da0d53..6ce73aa3d481 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -718,6 +718,186 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) { + const session = yield* sessions.get(input.sessionID) + if (session.revert) { + yield* Effect.promise(() => SessionRevert.cleanup(session)) + } + const agent = yield* agents.get(input.agent) + if (!agent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + const model = input.model ?? agent.model ?? (yield* Effect.promise(() => lastModelImpl(input.sessionID))) + const userMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID: input.sessionID, + time: { created: Date.now() }, + role: "user", + agent: input.agent, + model: { providerID: model.providerID, modelID: model.modelID }, + } + yield* sessions.updateMessage(userMsg) + const userPart: MessageV2.Part = { + type: "text", + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + text: "The following tool was executed by the user", + synthetic: true, + } + yield* sessions.updatePart(userPart) + + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: userMsg.id, + mode: input.agent, + agent: input.agent, + 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, + } + yield* sessions.updateMessage(msg) + const part: MessageV2.ToolPart = { + type: "tool", + id: PartID.ascending(), + messageID: msg.id, + sessionID: input.sessionID, + tool: "bash", + callID: ulid(), + state: { + status: "running", + time: { start: Date.now() }, + input: { command: input.command }, + }, + } + yield* sessions.updatePart(part) + + const sh = Shell.preferred() + const shellName = ( + process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) + ).toLowerCase() + const invocations: Record = { + nu: { args: ["-c", input.command] }, + fish: { args: ["-c", input.command] }, + zsh: { + args: [ + "-c", + "-l", + ` + [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true + [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true + eval ${JSON.stringify(input.command)} + `, + ], + }, + bash: { + args: [ + "-c", + "-l", + ` + shopt -s expand_aliases + [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true + eval ${JSON.stringify(input.command)} + `, + ], + }, + cmd: { args: ["/c", input.command] }, + powershell: { args: ["-NoProfile", "-Command", input.command] }, + pwsh: { args: ["-NoProfile", "-Command", input.command] }, + "": { args: ["-c", `${input.command}`] }, + } + + const args = (invocations[shellName] ?? invocations[""]).args + const cwd = Instance.directory + const shellEnv = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: input.sessionID, callID: part.callID }, + { env: {} }, + ) + const proc = yield* Effect.sync(() => + spawn(sh, args, { + cwd, + detached: process.platform !== "win32", + windowsHide: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ...shellEnv.env, + TERM: "dumb", + }, + }), + ) + + let output = "" + const write = () => { + if (part.state.status !== "running") return + part.state.metadata = { output, description: "" } + void Effect.runFork(sessions.updatePart(part)) + } + + proc.stdout?.on("data", (chunk) => { + output += chunk.toString() + write() + }) + proc.stderr?.on("data", (chunk) => { + output += chunk.toString() + write() + }) + + let aborted = false + let exited = false + const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited })) + + if (signal.aborted) { + aborted = true + yield* kill + } + + const abortHandler = () => { + aborted = true + void Effect.runFork(kill) + } + + yield* Effect.promise(() => { + signal.addEventListener("abort", abortHandler, { once: true }) + return new Promise((resolve) => { + const close = () => { + exited = true + proc.off("close", close) + resolve() + } + proc.once("close", close) + }) + }).pipe(Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler)))) + + if (aborted) { + output += "\n\n" + ["", "User aborted the command", ""].join("\n") + } + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + if (part.state.status === "running") { + part.state = { + status: "completed", + time: { ...part.state.time, end: Date.now() }, + input: part.state.input, + title: "", + metadata: { output, description: "" }, + output, + } + yield* sessions.updatePart(part) + } + return { info: msg, parts: [part] } + }) + const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) => Effect.promise(() => Provider.getModel(providerID, modelID).catch((e) => { @@ -1385,7 +1565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* status.set(input.sessionID, { type: "busy" }) const ctrl = new AbortController() - const fiber = yield* Effect.promise(() => shellImpl(input, ctrl.signal)).pipe( + const fiber = yield* shellImpl(input, ctrl.signal).pipe( Effect.ensuring( Effect.gen(function* () { const entry = s.shells.get(input.sessionID) @@ -1712,235 +1892,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, }) } - async function shellImpl(input: ShellInput, signal: AbortSignal): Promise { - const session = await Session.get(input.sessionID) - if (session.revert) { - await SessionRevert.cleanup(session) - } - const agent = await Agent.get(input.agent) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } - const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID)) - const userMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - role: "user", - agent: input.agent, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - } - await Session.updateMessage(userMsg) - const userPart: MessageV2.Part = { - type: "text", - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - text: "The following tool was executed by the user", - synthetic: true, - } - await Session.updatePart(userPart) - - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - sessionID: input.sessionID, - parentID: userMsg.id, - mode: input.agent, - agent: input.agent, - 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(msg) - const part: MessageV2.Part = { - type: "tool", - id: PartID.ascending(), - messageID: msg.id, - sessionID: input.sessionID, - tool: "bash", - callID: ulid(), - state: { - status: "running", - time: { - start: Date.now(), - }, - input: { - command: input.command, - }, - }, - } - await Session.updatePart(part) - const sh = Shell.preferred() - const shellName = (process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)).toLowerCase() - - const invocations: Record = { - nu: { - args: ["-c", input.command], - }, - fish: { - args: ["-c", input.command], - }, - zsh: { - args: [ - "-c", - "-l", - ` - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} - `, - ], - }, - bash: { - args: [ - "-c", - "-l", - ` - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} - `, - ], - }, - // Windows cmd - cmd: { - args: ["/c", input.command], - }, - // Windows PowerShell - powershell: { - args: ["-NoProfile", "-Command", input.command], - }, - pwsh: { - args: ["-NoProfile", "-Command", input.command], - }, - // Fallback: any shell that doesn't match those above - // - No -l, for max compatibility - "": { - args: ["-c", `${input.command}`], - }, - } - - const matchingInvocation = invocations[shellName] ?? invocations[""] - const args = matchingInvocation?.args - - const cwd = Instance.directory - const shellEnv = await Plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ) - const proc = spawn(sh, args, { - cwd, - detached: process.platform !== "win32", - windowsHide: process.platform === "win32", - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - ...shellEnv.env, - TERM: "dumb", - }, - }) - - let output = "" - - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) - - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) - - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (signal.aborted) { - aborted = true - await kill() - } - - const abortHandler = () => { - aborted = true - void kill() - } - - signal.addEventListener("abort", abortHandler, { once: true }) - - await new Promise((resolve) => { - proc.on("close", () => { - exited = true - signal.removeEventListener("abort", abortHandler) - resolve() - }) - }) - - if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") - } - msg.time.completed = Date.now() - await Session.updateMessage(msg) - if (part.state.status === "running") { - part.state = { - status: "completed", - time: { - ...part.state.time, - end: Date.now(), - }, - input: part.state.input, - title: "", - metadata: { - output, - description: "", - }, - output, - } - await Session.updatePart(part) - } - return { info: msg, parts: [part] } - } - const bashRegex = /!`([^`]+)`/g // Match [Image N] as single token, quoted strings, or non-space sequences const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi From ec8f7afd7df289edee40b14d2e7fd3c97c9905f9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 20:27:02 -0400 Subject: [PATCH 23/66] make llm service cancellation interrupt-safe --- packages/opencode/src/session/llm.ts | 34 +++- packages/opencode/test/session/llm.test.ts | 220 ++++++++++++++++++++- 2 files changed, 244 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4d7d80b24107..d12087de73ff 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,6 +1,7 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { Effect, Layer, Record, ServiceMap } from "effect" +import { Cause, Effect, Layer, Record, ServiceMap } from "effect" +import * as Queue from "effect/Queue" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { mergeDeep, pipe } from "remeda" @@ -52,15 +53,32 @@ export namespace LLM { Effect.gen(function* () { return Service.of({ stream(input) { - return Stream.unwrap( - Effect.promise((signal) => LLM.stream({ ...input, abort: signal })).pipe( - Effect.map((result) => - Stream.fromAsyncIterable(result.fullStream, (err) => err).pipe( - Stream.mapEffect((event) => Effect.succeed(event)), - ), - ), + const stream: Stream.Stream = Stream.scoped( + Stream.unwrap( + Effect.gen(function* () { + const ctrl = yield* Effect.acquireRelease( + Effect.sync(() => new AbortController()), + (ctrl) => Effect.sync(() => ctrl.abort()), + ) + const queue = yield* Queue.unbounded() + + yield* Effect.promise(async () => { + const result = await LLM.stream({ ...input, abort: ctrl.signal }) + for await (const event of result.fullStream) { + if (!Queue.offerUnsafe(queue, event)) break + } + Queue.endUnsafe(queue) + }).pipe( + Effect.catchCause((cause) => Effect.sync(() => void Queue.failCauseUnsafe(queue, cause))), + Effect.onInterrupt(() => Effect.sync(() => ctrl.abort())), + Effect.forkScoped, + ) + + return Stream.fromQueue(queue) + }), ), ) + return stream }, }) }), diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 8de7d2723a9b..bb81aa681c9f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" import { tool, type ModelMessage } from "ai" +import { Cause, Exit, Stream } from "effect" import z from "zod" +import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" @@ -109,7 +111,11 @@ type Capture = { const state = { server: null as ReturnType | null, - queue: [] as Array<{ path: string; response: Response; resolve: (value: Capture) => void }>, + queue: [] as Array<{ + path: string + response: Response | ((req: Request, capture: Capture) => Response) + resolve: (value: Capture) => void + }>, } function deferred() { @@ -126,6 +132,58 @@ function waitRequest(pathname: string, response: Response) { return pending.promise } +function timeout(ms: number) { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms) + }) +} + +function waitStreamingRequest(pathname: string) { + const request = deferred() + const requestAborted = deferred() + const responseCanceled = deferred() + const encoder = new TextEncoder() + + state.queue.push({ + path: pathname, + resolve: request.resolve, + response(req: Request) { + req.signal.addEventListener("abort", () => requestAborted.resolve(), { once: true }) + + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + [ + `data: ${JSON.stringify({ + id: "chatcmpl-abort", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + })}`, + ].join("\n\n") + "\n\n", + ), + ) + }, + cancel() { + responseCanceled.resolve() + }, + }), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ) + }, + }) + + return { + request: request.promise, + requestAborted: requestAborted.promise, + responseCanceled: responseCanceled.promise, + } +} + beforeAll(() => { state.server = Bun.serve({ port: 0, @@ -143,7 +201,9 @@ beforeAll(() => { return new Response("not found", { status: 404 }) } - return next.response + return typeof next.response === "function" + ? next.response(req, { url, headers: req.headers, body }) + : next.response }, }) }) @@ -325,6 +385,162 @@ describe("session.llm.stream", () => { }) }) + test("raw stream abort signal cancels provider response body promptly", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + const pending = waitStreamingRequest("/chat/completions") + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-raw-abort") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("user-raw-abort"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + } satisfies MessageV2.User + + const ctrl = new AbortController() + const result = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: ctrl.signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + const iter = result.fullStream[Symbol.asyncIterator]() + await pending.request + await iter.next() + ctrl.abort() + + await Promise.race([pending.responseCanceled, timeout(500)]) + await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined) + await iter.return?.() + }, + }) + }) + + test("service stream cancellation cancels provider response body promptly", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + const pending = waitStreamingRequest("/chat/completions") + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-service-abort") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("user-service-abort"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + } satisfies MessageV2.User + + const ctrl = new AbortController() + const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer) + const run = runPromiseExit( + (svc) => + svc + .stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + .pipe(Stream.runDrain), + { signal: ctrl.signal }, + ) + + await pending.request + ctrl.abort() + + await Promise.race([pending.responseCanceled, timeout(500)]) + const exit = await run + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true) + } + await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined) + }, + }) + }) + test("keeps tools enabled by prompt permissions", async () => { const server = state.server if (!server) { From 1ff78fedd579a50130ff50a60bec2c9c1dd0552d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 20:42:16 -0400 Subject: [PATCH 24/66] scope instruction prompt cleanup to message creation --- packages/opencode/src/session/prompt.ts | 521 ++++++++++++------------ 1 file changed, 264 insertions(+), 257 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6ce73aa3d481..0fa0a8314daf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -236,7 +236,13 @@ export namespace SessionPrompt { .find((line) => line.length > 0) if (!cleaned) return const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe(Effect.catchCause(() => Effect.void)) + yield* sessions + .setTitle({ sessionID: input.session.id, title: t }) + .pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.error("failed to generate title", { error: Cause.squash(cause) })), + ), + ) }) const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: { @@ -944,302 +950,303 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, variant, } + return yield* Effect.gen(function* () { + type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never + const assign = (part: Draft): MessageV2.Part => ({ + ...part, + id: part.id ? PartID.make(part.id) : PartID.ascending(), + }) - type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ - ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), - }) - - const parts = yield* Effect.promise(() => - Promise.all( - input.parts.map(async (part): Promise[]> => { - if (part.type === "file") { - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - try { - const content = await MCP.readResource(clientName, uri) - if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) - const items = Array.isArray(content.contents) ? content.contents : [content.contents] - for (const c of items) { - if ("text" in c && c.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: c.text, - }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, - }) + const parts = yield* Effect.promise(() => + Promise.all( + input.parts.map(async (part): Promise[]> => { + if (part.type === "file") { + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, + ] + try { + const content = await MCP.readResource(clientName, uri) + if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + const items = Array.isArray(content.contents) ? content.contents : [content.contents] + for (const c of items) { + if ("text" in c && c.text) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: c.text, + }) + } else if ("blob" in c && c.blob) { + const mime = "mimeType" in c ? c.mimeType : part.mime + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mime}]`, + }) + } } + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } catch (error: unknown) { + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } catch (error: unknown) { - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) + return pieces } - return pieces - } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - break - case "file:": { - log.info("file", { mime: part.mime }) - const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - if (s?.isDirectory()) part.mime = "application/x-directory" - - if (part.mime === "text/plain") { - let offset: number | undefined - let limit: number | undefined - const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - if (start === end) { - const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) - for (const symbol of symbols) { - let r: LSP.Range | undefined - if ("range" in symbol) r = symbol.range - else if ("location" in symbol) r = symbol.location.range - if (r?.start?.line && r?.start?.line === start) { - start = r.start.line - end = r?.end?.line ?? start - break - } - } - } - offset = Math.max(start, 1) - if (end) limit = end - (offset - 1) - } - const args = { filePath: filepath, offset, limit } - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - await ReadTool.init() - .then(async (t) => { - const mdl = await Provider.getModel(info.model.providerID, info.model.modelID) - const ctx: Tool.Context = { + const url = new URL(part.url) + switch (url.protocol) { + case "data:": + if (part.mime === "text/plain") { + return [ + { + messageID: info.id, sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, + }, + { messageID: info.id, - extra: { bypassCwdCheck: true, model: mdl }, - messages: [], - metadata: async () => {}, - ask: async () => {}, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: decodeDataUrl(part.url), + }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] + } + break + case "file:": { + log.info("file", { mime: part.mime }) + const filepath = fileURLToPath(part.url) + const s = Filesystem.stat(filepath) + if (s?.isDirectory()) part.mime = "application/x-directory" + + if (part.mime === "text/plain") { + let offset: number | undefined + let limit: number | undefined + const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } + if (range.start != null) { + const filePathURI = part.url.split("?")[0] + let start = parseInt(range.start) + let end = range.end ? parseInt(range.end) : undefined + if (start === end) { + const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) + for (const symbol of symbols) { + let r: LSP.Range | undefined + if ("range" in symbol) r = symbol.range + else if ("location" in symbol) r = symbol.location.range + if (r?.start?.line && r?.start?.line === start) { + start = r.start.line + end = r?.end?.line ?? start + break + } + } } - const result = await t.execute(args, ctx) - pieces.push({ + offset = Math.max(start, 1) + if (end) limit = end - (offset - 1) + } + const args = { filePath: filepath, offset, limit } + const pieces: Draft[] = [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: result.output, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + ] + await ReadTool.init() + .then(async (t) => { + const mdl = await Provider.getModel(info.model.providerID, info.model.modelID) + const ctx: Tool.Context = { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true, model: mdl }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + } + const result = await t.execute(args, ctx) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }) + if (result.attachments?.length) { + pieces.push( + ...result.attachments.map((a) => ({ + ...a, + synthetic: true, + filename: a.filename ?? part.filename, + messageID: info.id, + sessionID: input.sessionID, + })), + ) + } else { + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((a) => ({ - ...a, - synthetic: true, - filename: a.filename ?? part.filename, - messageID: info.id, - sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } - }) - .catch((error) => { - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : error.toString() - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message }).toObject(), + .catch((error) => { + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : error.toString() + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) }) - pieces.push({ + return pieces + } + + if (part.mime === "application/x-directory") { + const args = { filePath: filepath } + const ctx: Tool.Context = { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + } + const result = await ReadTool.init().then((t) => t.execute(args, ctx)) + return [ + { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) - }) - return pieces - } - - if (part.mime === "application/x-directory") { - const args = { filePath: filepath } - const ctx: Tool.Context = { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] } - const result = await ReadTool.init().then((t) => t.execute(args, ctx)) + + await FileTime.read(input.sessionID, filepath) return [ { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, }, { + id: part.id, messageID: info.id, sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, + type: "file", + url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), + mime: part.mime, + filename: part.filename!, + source: part.source, }, - { ...part, messageID: info.id, sessionID: input.sessionID }, ] } - - await FileTime.read(input.sessionID, filepath) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, - }, - { - id: part.id, - messageID: info.id, - sessionID: input.sessionID, - type: "file", - url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), - mime: part.mime, - filename: part.filename!, - source: part.source, - }, - ] } } - } - if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { ...part, messageID: info.id, sessionID: input.sessionID }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } + if (part.type === "agent") { + const perm = Permission.evaluate("task", part.name, ag.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + return [ + { ...part, messageID: info.id, sessionID: input.sessionID }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, + }, + ] + } - return [{ ...part, messageID: info.id, sessionID: input.sessionID }] - }), - ).then((x) => x.flat().map(assign)), - ) + return [{ ...part, messageID: info.id, sessionID: input.sessionID }] + }), + ).then((x) => x.flat().map(assign)), + ) - yield* plugin.trigger( - "chat.message", - { - sessionID: input.sessionID, - agent: input.agent, - model: input.model, - messageID: input.messageID, - variant: input.variant, - }, - { message: info, parts }, - ) + yield* plugin.trigger( + "chat.message", + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID: input.messageID, + variant: input.variant, + }, + { message: info, parts }, + ) - const parsed = MessageV2.Info.safeParse(info) - if (!parsed.success) { - log.error("invalid user message before save", { - sessionID: input.sessionID, - messageID: info.id, - agent: info.agent, - model: info.model, - issues: parsed.error.issues, - }) - } - parts.forEach((part, index) => { - const p = MessageV2.Part.safeParse(part) - if (p.success) return - log.error("invalid user part before save", { - sessionID: input.sessionID, - messageID: info.id, - partID: part.id, - partType: part.type, - index, - issues: p.error.issues, - part, + const parsed = MessageV2.Info.safeParse(info) + if (!parsed.success) { + log.error("invalid user message before save", { + sessionID: input.sessionID, + messageID: info.id, + agent: info.agent, + model: info.model, + issues: parsed.error.issues, + }) + } + parts.forEach((part, index) => { + const p = MessageV2.Part.safeParse(part) + if (p.success) return + log.error("invalid user part before save", { + sessionID: input.sessionID, + messageID: info.id, + partID: part.id, + partType: part.type, + index, + issues: p.error.issues, + part, + }) }) - }) - yield* sessions.updateMessage(info) - for (const part of parts) yield* sessions.updatePart(part) + yield* sessions.updateMessage(info) + for (const part of parts) yield* sessions.updatePart(part) - return { info, parts } + return { info, parts } + }).pipe(Effect.ensuring(Effect.sync(() => InstructionPrompt.clear(info.id)))) }) const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( From 969e6335b1dfbb530c923c165433daa7cb36c0c3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 20:47:52 -0400 Subject: [PATCH 25/66] effectify prompt message part expansion --- packages/opencode/src/session/prompt.ts | 500 +++++++++++++----------- 1 file changed, 261 insertions(+), 239 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0fa0a8314daf..a7f8a3b3e189 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -72,7 +72,7 @@ export namespace SessionPrompt { } interface ShellEntry { - fiber: Fiber.Fiber + fiber?: Fiber.Fiber abort: AbortController } @@ -115,7 +115,7 @@ export namespace SessionPrompt { for (const item of shells.values()) item.abort.abort() yield* Fiber.interruptAll([ ...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), - ...shells.values().map((x) => x.fiber), + ...shells.values().flatMap((x) => (x.fiber ? [x.fiber] : [])), ]) }), ) @@ -144,8 +144,12 @@ export namespace SessionPrompt { } if (shellEntry) { shellEntry.abort.abort() + if (shellEntry.fiber) yield* Fiber.await(shellEntry.fiber) + else if (s.shells.get(sessionID) === shellEntry) s.shells.delete(sessionID) + } + if (!s.loops.has(sessionID) && !s.shells.has(sessionID)) { + yield* status.set(sessionID, { type: "idle" }) } - yield* status.set(sessionID, { type: "idle" }) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -957,253 +961,274 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const parts = yield* Effect.promise(() => - Promise.all( - input.parts.map(async (part): Promise[]> => { - if (part.type === "file") { - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - try { - const content = await MCP.readResource(clientName, uri) - if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) - const items = Array.isArray(content.contents) ? content.contents : [content.contents] - for (const c of items) { - if ("text" in c && c.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: c.text, - }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, - }) - } + const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[], unknown> = + Effect.fn("SessionPrompt.resolveUserPart")(function* (part: PromptInput["parts"][number]) { + if (part.type === "file") { + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, + ] + const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + const content = exit.value + if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + const items = Array.isArray(content.contents) ? content.contents : [content.contents] + for (const c of items) { + if ("text" in c && c.text) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: c.text, + }) + } else if ("blob" in c && c.blob) { + const mime = "mimeType" in c ? c.mimeType : part.mime + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mime}]`, + }) } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } catch (error: unknown) { - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) } - return pieces + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } else { + const error = Cause.squash(exit.cause) + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - break - case "file:": { - log.info("file", { mime: part.mime }) - const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - if (s?.isDirectory()) part.mime = "application/x-directory" - - if (part.mime === "text/plain") { - let offset: number | undefined - let limit: number | undefined - const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - if (start === end) { - const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) - for (const symbol of symbols) { - let r: LSP.Range | undefined - if ("range" in symbol) r = symbol.range - else if ("location" in symbol) r = symbol.location.range - if (r?.start?.line && r?.start?.line === start) { - start = r.start.line - end = r?.end?.line ?? start - break - } + return pieces + } + const url = new URL(part.url) + switch (url.protocol) { + case "data:": + if (part.mime === "text/plain") { + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: decodeDataUrl(part.url), + }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] + } + break + case "file:": { + log.info("file", { mime: part.mime }) + const filepath = fileURLToPath(part.url) + const s = Filesystem.stat(filepath) + if (s?.isDirectory()) part.mime = "application/x-directory" + + if (part.mime === "text/plain") { + let offset: number | undefined + let limit: number | undefined + const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } + if (range.start != null) { + const filePathURI = part.url.split("?")[0] + let start = parseInt(range.start) + let end = range.end ? parseInt(range.end) : undefined + if (start === end) { + const symbols = yield* lsp + .documentSymbol(filePathURI) + .pipe(Effect.catch(() => Effect.succeed([]))) + for (const symbol of symbols) { + let r: LSP.Range | undefined + if ("range" in symbol) r = symbol.range + else if ("location" in symbol) r = symbol.location.range + if (r?.start?.line && r?.start?.line === start) { + start = r.start.line + end = r?.end?.line ?? start + break } } - offset = Math.max(start, 1) - if (end) limit = end - (offset - 1) } - const args = { filePath: filepath, offset, limit } - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - await ReadTool.init() - .then(async (t) => { - const mdl = await Provider.getModel(info.model.providerID, info.model.modelID) - const ctx: Tool.Context = { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, model: mdl }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - } - const result = await t.execute(args, ctx) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((a) => ({ - ...a, - synthetic: true, - filename: a.filename ?? part.filename, - messageID: info.id, + offset = Math.max(start, 1) + if (end) limit = end - (offset - 1) + } + const args = { filePath: filepath, offset, limit } + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + ] + const read = yield* Effect.promise(() => ReadTool.init()).pipe( + Effect.flatMap((t) => + Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe( + Effect.flatMap((mdl) => + Effect.promise(() => + t.execute(args, { sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } - }) - .catch((error) => { - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : error.toString() - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message }).toObject(), - }) - pieces.push({ + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true, model: mdl }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + }), + ), + ), + ), + ), + Effect.exit, + ) + if (Exit.isSuccess(read)) { + const result = read.value + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }) + if (result.attachments?.length) { + pieces.push( + ...result.attachments.map((a) => ({ + ...a, + synthetic: true, + filename: a.filename ?? part.filename, messageID: info.id, sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) - }) - return pieces - } - - if (part.mime === "application/x-directory") { - const args = { filePath: filepath } - const ctx: Tool.Context = { + })), + ) + } else { + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } + } else { + const error = Cause.squash(read.cause) + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : String(error) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, + error: new NamedError.Unknown({ message }).toObject(), + }) + pieces.push({ messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - } - const result = await ReadTool.init().then((t) => t.execute(args, ctx)) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) } + return pieces + } - await FileTime.read(input.sessionID, filepath) + if (part.mime === "application/x-directory") { + const args = { filePath: filepath } + const result = yield* Effect.promise(() => ReadTool.init()).pipe( + Effect.flatMap((t) => + Effect.promise(() => + t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + }), + ), + ), + ) return [ { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, { - id: part.id, messageID: info.id, sessionID: input.sessionID, - type: "file", - url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), - mime: part.mime, - filename: part.filename!, - source: part.source, + type: "text", + synthetic: true, + text: result.output, }, + { ...part, messageID: info.id, sessionID: input.sessionID }, ] } + + yield* filetime.read(input.sessionID, filepath) + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + }, + { + id: part.id, + messageID: info.id, + sessionID: input.sessionID, + type: "file", + url: + `data:${part.mime};base64,` + + (yield* Effect.promise(() => Filesystem.readBytes(filepath))).toString("base64"), + mime: part.mime, + filename: part.filename!, + source: part.source, + }, + ] } } + } - if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { ...part, messageID: info.id, sessionID: input.sessionID }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } + if (part.type === "agent") { + const perm = Permission.evaluate("task", part.name, ag.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + return [ + { ...part, messageID: info.id, sessionID: input.sessionID }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, + }, + ] + } - return [{ ...part, messageID: info.id, sessionID: input.sessionID }] - }), - ).then((x) => x.flat().map(assign)), + return [{ ...part, messageID: info.id, sessionID: input.sessionID }] + }) + + const parts = yield* Effect.all(input.parts.map((part) => resolvePart(part))).pipe( + Effect.map((x) => x.flat().map(assign)), ) yield* plugin.trigger( @@ -1513,30 +1538,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the const startLoop: (s: State, sessionID: SessionID) => Effect.Effect = Effect.fnUntraced(function* (s: State, sessionID: SessionID) { + const entry = s.loops.get(sessionID) ?? { queue: [] } + s.loops.set(sessionID, entry) const fiber = yield* runLoop(sessionID).pipe( Effect.onExit((exit) => Effect.gen(function* () { - const entry = s.loops.get(sessionID) - if (entry) { - // On interrupt, resolve queued callers with the last assistant message - const resolved = - Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - ? Exit.succeed(yield* lastAssistant(sessionID)) - : exit - for (const d of entry.queue) yield* Deferred.done(d, resolved) + // On interrupt, resolve queued callers with the last assistant message + const resolved = + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Exit.succeed(yield* lastAssistant(sessionID)) + : exit + for (const d of entry.queue) yield* Deferred.done(d, resolved) + if (s.loops.get(sessionID) === entry) s.loops.delete(sessionID) + if (!s.loops.has(sessionID) && !s.shells.has(sessionID)) { + yield* status.set(sessionID, { type: "idle" }) } - s.loops.delete(sessionID) - yield* status.set(sessionID, { type: "idle" }) }), ), Effect.forkChild, ) - const entry = s.loops.get(sessionID) - if (entry) { - entry.fiber = fiber - } else { - s.loops.set(sessionID, { fiber, queue: [] }) - } + entry.fiber = fiber return yield* awaitFiber(fiber, lastAssistant(sessionID)) }) @@ -1572,11 +1593,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* status.set(input.sessionID, { type: "busy" }) const ctrl = new AbortController() + const entry: ShellEntry = { abort: ctrl } + s.shells.set(input.sessionID, entry) const fiber = yield* shellImpl(input, ctrl.signal).pipe( Effect.ensuring( Effect.gen(function* () { - const entry = s.shells.get(input.sessionID) - if (entry?.fiber === fiber) s.shells.delete(input.sessionID) + if (s.shells.get(input.sessionID) === entry) s.shells.delete(input.sessionID) // If callers queued a loop while the shell was running, start it const pending = s.loops.get(input.sessionID) if (pending && pending.queue.length > 0) { @@ -1589,7 +1611,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.forkChild, ) - s.shells.set(input.sessionID, { fiber, abort: ctrl }) + entry.fiber = fiber return yield* awaitFiber(fiber, lastAssistant(input.sessionID)) }) From bf5940d87154865d63f9e6eda4eb848fc78396d9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 21:05:45 -0400 Subject: [PATCH 26/66] tighten prompt cleanup assertions --- packages/opencode/src/session/prompt.ts | 563 +++++++++--------- .../test/session/processor-effect.test.ts | 2 - .../test/session/prompt-effect.test.ts | 15 +- 3 files changed, 290 insertions(+), 290 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a7f8a3b3e189..9a08e00f8249 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -954,325 +954,326 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, variant, } - return yield* Effect.gen(function* () { - type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ - ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), - }) - const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[], unknown> = - Effect.fn("SessionPrompt.resolveUserPart")(function* (part: PromptInput["parts"][number]) { - if (part.type === "file") { - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) - if (Exit.isSuccess(exit)) { - const content = exit.value - if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) - const items = Array.isArray(content.contents) ? content.contents : [content.contents] - for (const c of items) { - if ("text" in c && c.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: c.text, - }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, - }) - } + yield* Effect.addFinalizer(() => Effect.sync(() => InstructionPrompt.clear(info.id))) + + type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never + const assign = (part: Draft): MessageV2.Part => ({ + ...part, + id: part.id ? PartID.make(part.id) : PartID.ascending(), + }) + + const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[], unknown> = + Effect.fn("SessionPrompt.resolveUserPart")(function* (part: PromptInput["parts"][number]) { + if (part.type === "file") { + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, + ] + const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + const content = exit.value + if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + const items = Array.isArray(content.contents) ? content.contents : [content.contents] + for (const c of items) { + if ("text" in c && c.text) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: c.text, + }) + } else if ("blob" in c && c.blob) { + const mime = "mimeType" in c ? c.mimeType : part.mime + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mime}]`, + }) } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } else { - const error = Cause.squash(exit.cause) - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) } - return pieces + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } else { + const error = Cause.squash(exit.cause) + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - break - case "file:": { - log.info("file", { mime: part.mime }) - const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - if (s?.isDirectory()) part.mime = "application/x-directory" - - if (part.mime === "text/plain") { - let offset: number | undefined - let limit: number | undefined - const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - if (start === end) { - const symbols = yield* lsp - .documentSymbol(filePathURI) - .pipe(Effect.catch(() => Effect.succeed([]))) - for (const symbol of symbols) { - let r: LSP.Range | undefined - if ("range" in symbol) r = symbol.range - else if ("location" in symbol) r = symbol.location.range - if (r?.start?.line && r?.start?.line === start) { - start = r.start.line - end = r?.end?.line ?? start - break - } + return pieces + } + const url = new URL(part.url) + switch (url.protocol) { + case "data:": + if (part.mime === "text/plain") { + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: decodeDataUrl(part.url), + }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] + } + break + case "file:": { + log.info("file", { mime: part.mime }) + const filepath = fileURLToPath(part.url) + const s = Filesystem.stat(filepath) + if (s?.isDirectory()) part.mime = "application/x-directory" + + if (part.mime === "text/plain") { + let offset: number | undefined + let limit: number | undefined + const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } + if (range.start != null) { + const filePathURI = part.url.split("?")[0] + let start = parseInt(range.start) + let end = range.end ? parseInt(range.end) : undefined + if (start === end) { + const symbols = yield* lsp + .documentSymbol(filePathURI) + .pipe(Effect.catch(() => Effect.succeed([]))) + for (const symbol of symbols) { + let r: LSP.Range | undefined + if ("range" in symbol) r = symbol.range + else if ("location" in symbol) r = symbol.location.range + if (r?.start?.line && r?.start?.line === start) { + start = r.start.line + end = r?.end?.line ?? start + break } } - offset = Math.max(start, 1) - if (end) limit = end - (offset - 1) } - const args = { filePath: filepath, offset, limit } - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - const read = yield* Effect.promise(() => ReadTool.init()).pipe( - Effect.flatMap((t) => - Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe( - Effect.flatMap((mdl) => - Effect.promise(() => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, model: mdl }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ), + offset = Math.max(start, 1) + if (end) limit = end - (offset - 1) + } + const args = { filePath: filepath, offset, limit } + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + ] + const read = yield* Effect.promise(() => ReadTool.init()).pipe( + Effect.flatMap((t) => + Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe( + Effect.flatMap((mdl) => + Effect.promise(() => + t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true, model: mdl }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + }), ), ), ), - Effect.exit, - ) - if (Exit.isSuccess(read)) { - const result = read.value - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((a) => ({ - ...a, - synthetic: true, - filename: a.filename ?? part.filename, - messageID: info.id, - sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } + ), + Effect.exit, + ) + if (Exit.isSuccess(read)) { + const result = read.value + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }) + if (result.attachments?.length) { + pieces.push( + ...result.attachments.map((a) => ({ + ...a, + synthetic: true, + filename: a.filename ?? part.filename, + messageID: info.id, + sessionID: input.sessionID, + })), + ) } else { - const error = Cause.squash(read.cause) - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message }).toObject(), - }) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } - return pieces + } else { + const error = Cause.squash(read.cause) + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : String(error) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) } + return pieces + } - if (part.mime === "application/x-directory") { - const args = { filePath: filepath } - const result = yield* Effect.promise(() => ReadTool.init()).pipe( - Effect.flatMap((t) => - Effect.promise(() => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ), + if (part.mime === "application/x-directory") { + const args = { filePath: filepath } + const result = yield* Effect.promise(() => ReadTool.init()).pipe( + Effect.flatMap((t) => + Effect.promise(() => + t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + }), ), - ) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - - yield* filetime.read(input.sessionID, filepath) + ), + ) return [ { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, { - id: part.id, messageID: info.id, sessionID: input.sessionID, - type: "file", - url: - `data:${part.mime};base64,` + - (yield* Effect.promise(() => Filesystem.readBytes(filepath))).toString("base64"), - mime: part.mime, - filename: part.filename!, - source: part.source, + type: "text", + synthetic: true, + text: result.output, }, + { ...part, messageID: info.id, sessionID: input.sessionID }, ] } + + yield* filetime.read(input.sessionID, filepath) + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + }, + { + id: part.id, + messageID: info.id, + sessionID: input.sessionID, + type: "file", + url: + `data:${part.mime};base64,` + + (yield* Effect.promise(() => Filesystem.readBytes(filepath))).toString("base64"), + mime: part.mime, + filename: part.filename!, + source: part.source, + }, + ] } } + } - if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { ...part, messageID: info.id, sessionID: input.sessionID }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } + if (part.type === "agent") { + const perm = Permission.evaluate("task", part.name, ag.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + return [ + { ...part, messageID: info.id, sessionID: input.sessionID }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, + }, + ] + } - return [{ ...part, messageID: info.id, sessionID: input.sessionID }] - }) + return [{ ...part, messageID: info.id, sessionID: input.sessionID }] + }) - const parts = yield* Effect.all(input.parts.map((part) => resolvePart(part))).pipe( - Effect.map((x) => x.flat().map(assign)), - ) + const parts = yield* Effect.all(input.parts.map((part) => resolvePart(part))).pipe( + Effect.map((x) => x.flat().map(assign)), + ) - yield* plugin.trigger( - "chat.message", - { - sessionID: input.sessionID, - agent: input.agent, - model: input.model, - messageID: input.messageID, - variant: input.variant, - }, - { message: info, parts }, - ) + yield* plugin.trigger( + "chat.message", + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID: input.messageID, + variant: input.variant, + }, + { message: info, parts }, + ) - const parsed = MessageV2.Info.safeParse(info) - if (!parsed.success) { - log.error("invalid user message before save", { - sessionID: input.sessionID, - messageID: info.id, - agent: info.agent, - model: info.model, - issues: parsed.error.issues, - }) - } - parts.forEach((part, index) => { - const p = MessageV2.Part.safeParse(part) - if (p.success) return - log.error("invalid user part before save", { - sessionID: input.sessionID, - messageID: info.id, - partID: part.id, - partType: part.type, - index, - issues: p.error.issues, - part, - }) + const parsed = MessageV2.Info.safeParse(info) + if (!parsed.success) { + log.error("invalid user message before save", { + sessionID: input.sessionID, + messageID: info.id, + agent: info.agent, + model: info.model, + issues: parsed.error.issues, }) + } + parts.forEach((part, index) => { + const p = MessageV2.Part.safeParse(part) + if (p.success) return + log.error("invalid user part before save", { + sessionID: input.sessionID, + messageID: info.id, + partID: part.id, + partType: part.type, + index, + issues: p.error.issues, + part, + }) + }) - yield* sessions.updateMessage(info) - for (const part of parts) yield* sessions.updatePart(part) + yield* sessions.updateMessage(info) + for (const part of parts) yield* sessions.updatePart(part) - return { info, parts } - }).pipe(Effect.ensuring(Effect.sync(() => InstructionPrompt.clear(info.id)))) - }) + return { info, parts } + }, Effect.scoped) const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( "SessionPrompt.prompt", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 9efbaa159df5..8da2d239968f 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -10,7 +10,6 @@ import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { Instance } from "../../src/project/instance" import type { Provider } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" @@ -662,7 +661,6 @@ it.effect("session.processor effect tests mark pending tools as aborted on clean (dir) => Effect.gen(function* () { const ready = defer() - const seen = defer() const test = yield* TestLLM const processors = yield* SessionProcessor.Service const session = yield* Session.Service diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 30140a3324c7..ca429c8d4593 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1,8 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, ServiceMap } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, ServiceMap } from "effect" import * as Stream from "effect/Stream" -import path from "path" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" @@ -659,6 +658,7 @@ it.effect("concurrent loop callers get same result", () => expect(a.info.id).toBe(b.info.id) expect(a.info.role).toBe("assistant") + yield* prompt.assertNotBusy(chat.id) }), { git: true }, ), @@ -798,6 +798,7 @@ it.effect("shell captures stdout and stderr in completed tool output", () => expect(tool.state.output).toContain("err") expect(tool.state.metadata.output).toContain("out") expect(tool.state.metadata.output).toContain("err") + yield* prompt.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -920,6 +921,11 @@ it.effect( yield* prompt.cancel(chat.id) + const status = yield* SessionStatus.Service + expect((yield* status.get(chat.id)).type).toBe("idle") + const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(busy)).toBe(true) + const exit = yield* Fiber.await(sh) expect(Exit.isSuccess(exit)).toBe(true) if (Exit.isSuccess(exit)) { @@ -929,11 +935,6 @@ it.effect( expect(tool.state.output).toContain("User aborted the command") } } - - const status = yield* SessionStatus.Service - expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isSuccess(busy)).toBe(true) }), { git: true, config: cfg }, ), From 9e41d02bb70441a6ff7824a9e962ae3e88ca0d8d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 21:12:02 -0400 Subject: [PATCH 27/66] tighten prompt assistant message typing Make the assistant message explicitly typed at creation so the processor setup no longer relies on a cast. This keeps the effectified prompt path type-safe without changing behavior. --- packages/opencode/src/session/prompt.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9a08e00f8249..e8701c04bb10 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1392,7 +1392,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const isLastStep = step >= maxSteps msgs = yield* insertReminders({ messages: msgs, agent, session }) - const msg = yield* sessions.updateMessage({ + const msg: MessageV2.Assistant = { id: MessageID.ascending(), parentID: lastUser!.id, role: "assistant", @@ -1406,9 +1406,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, time: { created: Date.now() }, sessionID, - }) + } + yield* sessions.updateMessage(msg) const handle = yield* processor.create({ - assistantMessage: msg as MessageV2.Assistant, + assistantMessage: msg, sessionID, model, }) From 1849f09dc84da4b599653b9ffbb8eb489269b365 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 21:13:06 -0400 Subject: [PATCH 28/66] tighten processor part typing Constrain tool parts at construction so the processor no longer relies on local casts. Narrow process error logging with normal Error checks instead of any. --- packages/opencode/src/session/processor.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 7ee23bf306bd..e114932eaa8d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -147,7 +147,7 @@ export namespace SessionProcessor { return case "tool-input-start": - ctx.toolcalls[value.id] = (yield* session.updatePart({ + ctx.toolcalls[value.id] = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(), messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, @@ -155,7 +155,7 @@ export namespace SessionProcessor { tool: value.toolName, callID: value.id, state: { status: "pending", input: {}, raw: "" }, - })) as MessageV2.ToolPart + } satisfies MessageV2.ToolPart) return case "tool-input-delta": @@ -167,12 +167,12 @@ export namespace SessionProcessor { case "tool-call": { const match = ctx.toolcalls[value.toolCallId] if (!match) return - ctx.toolcalls[value.toolCallId] = (yield* session.updatePart({ + ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ ...match, tool: value.toolName, state: { status: "running", input: value.input, time: { start: Date.now() } }, metadata: value.providerMetadata, - })) as MessageV2.ToolPart + } satisfies MessageV2.ToolPart) const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id)) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) @@ -406,7 +406,7 @@ export namespace SessionProcessor { }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { - log.error("process", { error: e, stack: JSON.stringify((e as any)?.stack) }) + log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined }) const error = parse(e) if (MessageV2.ContextOverflowError.isInstance(error)) { ctx.needsCompaction = true From c4763657d6271ce713ebd75eb4f9c738902ed2ac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 21:47:07 -0400 Subject: [PATCH 29/66] fix session cleanup edge cases --- packages/opencode/src/session/compaction.ts | 24 +++++++++++++++------ packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/prompt.ts | 17 +++++++++++---- packages/opencode/src/session/summary.ts | 4 ++-- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 1729f475d2b3..e186a6c8ae99 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -15,7 +15,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/db" import { ModelID, ProviderID } from "@/provider/schema" -import { Cause, Effect, Exit, Layer, ServiceMap } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { makeRuntime } from "@/effect/run-service" import { isOverflow as overflow } from "./overflow" @@ -137,16 +137,25 @@ export namespace SessionCompaction { auto: boolean overflow?: boolean }) { - const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + const parent = input.messages.findLast((m) => m.info.id === input.parentID) + if (!parent || parent.info.role !== "user") { + throw new Error(`Compaction parent must be a user message: ${input.parentID}`) + } + const userMessage = parent.info let messages = input.messages - let replay: MessageV2.WithParts | undefined + let replay: + | { + info: MessageV2.User + parts: MessageV2.Part[] + } + | undefined if (input.overflow) { const idx = input.messages.findIndex((m) => m.info.id === input.parentID) for (let i = idx - 1; i >= 0; i--) { const msg = input.messages[i] if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { - replay = msg + replay = { info: msg.info, parts: msg.parts } messages = input.messages.slice(0, i) break } @@ -203,7 +212,7 @@ When constructing the summary, try to stick to this template: const msgs = structuredClone(messages) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true })) - const msg = (yield* session.updateMessage({ + const msg: MessageV2.Assistant = { id: MessageID.ascending(), role: "assistant", parentID: input.parentID, @@ -228,7 +237,8 @@ When constructing the summary, try to stick to this template: time: { created: Date.now(), }, - })) as MessageV2.Assistant + } + yield* session.updateMessage(msg) const processor = yield* processors.create({ assistantMessage: msg, sessionID: input.sessionID, @@ -265,7 +275,7 @@ When constructing the summary, try to stick to this template: if (result === "continue" && input.auto) { if (replay) { - const original = replay.info as MessageV2.User + const original = replay.info const replayMsg = yield* session.updateMessage({ id: MessageID.ascending(), role: "user", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e114932eaa8d..6e2684f8d4e2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Layer, ServiceMap } from "effect" +import { Cause, Effect, Layer, ServiceMap } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e8701c04bb10..9ce47a845dd2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -117,6 +117,10 @@ export namespace SessionPrompt { ...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), ...shells.values().flatMap((x) => (x.fiber ? [x.fiber] : [])), ]) + for (const entry of loops.values()) { + if (entry.fiber) continue + for (const d of entry.queue) yield* Deferred.interrupt(d) + } }), ) return { loops, shells } @@ -138,9 +142,14 @@ export namespace SessionPrompt { return } if (loopEntry) { - if (loopEntry.fiber) yield* Fiber.interrupt(loopEntry.fiber) - for (const d of loopEntry.queue) yield* Deferred.interrupt(d) - s.loops.delete(sessionID) + if (loopEntry.fiber) { + s.loops.delete(sessionID) + yield* Fiber.interrupt(loopEntry.fiber) + yield* Fiber.await(loopEntry.fiber) + } else { + for (const d of loopEntry.queue) yield* Deferred.interrupt(d) + s.loops.delete(sessionID) + } } if (shellEntry) { shellEntry.abort.abort() @@ -1535,7 +1544,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value if (Cause.hasInterruptsOnly(exit.cause)) return yield* fallback - return yield* Effect.failCause(exit.cause as Cause.Cause) + return yield* Effect.failCause(exit.cause) }) const startLoop: (s: State, sessionID: SessionID) => Effect.Effect = diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 898b93f3f98c..f6e1f0306302 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -110,8 +110,8 @@ export namespace SessionSummary { (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) const msgWithParts = messages.find((m) => m.info.id === input.messageID) - if (!msgWithParts) return - const userMsg = msgWithParts.info as MessageV2.User + if (!msgWithParts || msgWithParts.info.role !== "user") return + const userMsg = msgWithParts.info const diffs = await computeDiff({ messages }) userMsg.summary = { ...userMsg.summary, From 6389a0d72097060404ab1312b31b27d1df9cc5d9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 22:23:40 -0400 Subject: [PATCH 30/66] add single flight coordination primitive --- packages/opencode/src/effect/single-flight.ts | 116 ++++++++++++++++++ .../test/effect/single-flight.test.ts | 116 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 packages/opencode/src/effect/single-flight.ts create mode 100644 packages/opencode/test/effect/single-flight.test.ts diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts new file mode 100644 index 000000000000..7ac1d100f2ab --- /dev/null +++ b/packages/opencode/src/effect/single-flight.ts @@ -0,0 +1,116 @@ +import { Deferred, Effect, Exit, Fiber, SynchronizedRef } from "effect" + +const TypeId = Symbol.for("@opencode/SingleFlight") + +export interface SingleFlight { + readonly [TypeId]: typeof TypeId + readonly done: Deferred.Deferred + readonly state: SynchronizedRef.SynchronizedRef> +} + +export namespace SingleFlight { + export type State = + | { readonly _tag: "Empty" } + | { readonly _tag: "Pending"; readonly token: symbol } + | { readonly _tag: "Starting"; readonly token: symbol } + | { readonly _tag: "Running"; readonly token: symbol; readonly fiber: Fiber.Fiber } + | { readonly _tag: "Done" } + + export const make = (): Effect.Effect> => + Effect.gen(function* () { + return { + [TypeId]: TypeId, + done: yield* Deferred.make(), + state: yield* SynchronizedRef.make>({ _tag: "Empty" }), + } + }) + + export const wait = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) + + export const busy = (self: SingleFlight): Effect.Effect => + SynchronizedRef.get(self.state).pipe( + Effect.map((state) => state._tag === "Pending" || state._tag === "Starting" || state._tag === "Running"), + ) + + const complete = (self: SingleFlight) => + SynchronizedRef.update(self.state, (state): State => (state._tag === "Done" ? state : { _tag: "Done" })) + + const launch = (self: SingleFlight, token: symbol, effect: Effect.Effect): Effect.Effect => + Effect.gen(function* () { + const fiber = yield* effect.pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + yield* complete(self) + yield* Deferred.done(self.done, exit) + }), + ), + Effect.forkChild, + ) + + const next = yield* SynchronizedRef.modifyEffect(self.state, (state) => { + if (state._tag === "Starting" && state.token === token) { + return Effect.succeed<[Effect.Effect, State]>([Effect.void, { _tag: "Running", token, fiber }]) + } + + return Effect.succeed<[Effect.Effect, State]>([Fiber.interrupt(fiber).pipe(Effect.asVoid), state]) + }) + + return yield* next + }) + + export const run = (self: SingleFlight, effect: Effect.Effect): Effect.Effect => + SynchronizedRef.modifyEffect(self.state, (state) => { + if (state._tag !== "Empty") { + return Effect.succeed<[Effect.Effect, State]>([wait(self), state]) + } + + const token = Symbol() + return Effect.succeed<[Effect.Effect, State]>([ + launch(self, token, effect).pipe(Effect.flatMap(() => wait(self))), + { _tag: "Starting", token }, + ]) + }).pipe(Effect.flatten) + + export const pend = (self: SingleFlight): Effect.Effect => + SynchronizedRef.modifyEffect(self.state, (state) => { + if (state._tag !== "Empty") { + return Effect.succeed<[Effect.Effect, State]>([wait(self), state]) + } + + return Effect.succeed<[Effect.Effect, State]>([wait(self), { _tag: "Pending", token: Symbol() }]) + }).pipe(Effect.flatten) + + export const promote = (self: SingleFlight, effect: Effect.Effect): Effect.Effect => + SynchronizedRef.modifyEffect(self.state, (state) => { + if (state._tag !== "Pending") { + return Effect.succeed<[Effect.Effect, State]>([Effect.void, state]) + } + + return Effect.succeed<[Effect.Effect, State]>([ + launch(self, state.token, effect).pipe(Effect.ignore), + { _tag: "Starting", token: state.token }, + ]) + }).pipe(Effect.flatten) + + export const interrupt = (self: SingleFlight): Effect.Effect => + SynchronizedRef.modifyEffect(self.state, (state) => { + switch (state._tag) { + case "Empty": + case "Done": + return Effect.succeed<[Effect.Effect, State]>([Effect.void, state]) + case "Pending": + case "Starting": + return Effect.succeed<[Effect.Effect, State]>([ + Deferred.interrupt(self.done).pipe(Effect.asVoid), + { _tag: "Done" }, + ]) + case "Running": + return Effect.succeed<[Effect.Effect, State]>([ + Fiber.interrupt(state.fiber).pipe( + Effect.flatMap(() => Deferred.await(self.done).pipe(Effect.exit, Effect.asVoid)), + ), + { _tag: "Done" }, + ]) + } + }).pipe(Effect.flatten) +} diff --git a/packages/opencode/test/effect/single-flight.test.ts b/packages/opencode/test/effect/single-flight.test.ts new file mode 100644 index 000000000000..f448dee4746f --- /dev/null +++ b/packages/opencode/test/effect/single-flight.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Ref } from "effect" +import { SingleFlight } from "../../src/effect/single-flight" + +type Result = { value: string } + +describe("SingleFlight", () => { + test("run starts immediately and returns the result", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const flight = yield* SingleFlight.make() + const result = yield* SingleFlight.run(flight, Effect.succeed({ value: "ok" })) + expect(result.value).toBe("ok") + expect(yield* SingleFlight.busy(flight)).toBe(false) + }), + ), + ) + }) + + test("concurrent run callers share the same result", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const flight = yield* SingleFlight.make() + const calls = yield* Ref.make(0) + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + yield* Effect.sleep("10 millis") + return { value: "shared" } + }) + + const [a, b] = yield* Effect.all([SingleFlight.run(flight, work), SingleFlight.run(flight, work)], { + concurrency: "unbounded", + }) + + expect(a.value).toBe("shared") + expect(b.value).toBe("shared") + expect(yield* Ref.get(calls)).toBe(1) + }), + ), + ) + }) + + test("pend reserves the slot and promote starts it later", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const flight = yield* SingleFlight.make() + const started = yield* Ref.make(false) + const work = Effect.gen(function* () { + yield* Ref.set(started, true) + return { value: "later" } + }) + + const waiter = yield* SingleFlight.pend(flight).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(yield* SingleFlight.busy(flight)).toBe(true) + expect(yield* Ref.get(started)).toBe(false) + + yield* SingleFlight.promote(flight, work) + + const exit = yield* Fiber.await(waiter) + expect(yield* Ref.get(started)).toBe(true) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.value).toBe("later") + } + }), + ), + ) + }) + + test("interrupt fails pending waiters", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const flight = yield* SingleFlight.make() + const waiter = yield* SingleFlight.pend(flight).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* SingleFlight.interrupt(flight) + + const exit = yield* Fiber.await(waiter) + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* SingleFlight.busy(flight)).toBe(false) + }), + ), + ) + }) + + test("interrupt waits for running fiber cleanup", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const flight = yield* SingleFlight.make() + const cleanup = yield* Deferred.make() + const work = Effect.never.pipe( + Effect.ensuring(Deferred.succeed(cleanup, undefined).pipe(Effect.asVoid)), + Effect.as({ value: "never" as const }), + ) + + const waiter = yield* SingleFlight.run(flight, work).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* SingleFlight.interrupt(flight) + yield* Deferred.await(cleanup) + + const exit = yield* Fiber.await(waiter) + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* SingleFlight.busy(flight)).toBe(false) + }), + ), + ) + }) +}) From 09544dbd52ecaca669e48b0c45b90da10a7cc7e7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 22:30:28 -0400 Subject: [PATCH 31/66] simplify single flight api --- packages/opencode/src/effect/single-flight.ts | 72 +++++++------------ .../test/effect/single-flight.test.ts | 42 ++++++----- 2 files changed, 45 insertions(+), 69 deletions(-) diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts index 7ac1d100f2ab..b26b372f4b45 100644 --- a/packages/opencode/src/effect/single-flight.ts +++ b/packages/opencode/src/effect/single-flight.ts @@ -1,101 +1,79 @@ -import { Deferred, Effect, Exit, Fiber, SynchronizedRef } from "effect" +import { Deferred, Effect, Fiber, SynchronizedRef } from "effect" const TypeId = Symbol.for("@opencode/SingleFlight") export interface SingleFlight { readonly [TypeId]: typeof TypeId + readonly effect: Effect.Effect readonly done: Deferred.Deferred readonly state: SynchronizedRef.SynchronizedRef> } export namespace SingleFlight { export type State = - | { readonly _tag: "Empty" } | { readonly _tag: "Pending"; readonly token: symbol } | { readonly _tag: "Starting"; readonly token: symbol } | { readonly _tag: "Running"; readonly token: symbol; readonly fiber: Fiber.Fiber } | { readonly _tag: "Done" } - export const make = (): Effect.Effect> => + export const make = (effect: Effect.Effect, options?: { autoStart?: boolean }) => Effect.gen(function* () { - return { + const token = Symbol() + const self: SingleFlight = { [TypeId]: TypeId, + effect, done: yield* Deferred.make(), - state: yield* SynchronizedRef.make>({ _tag: "Empty" }), + state: yield* SynchronizedRef.make>({ _tag: "Pending", token }), } - }) - export const wait = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) + if (options?.autoStart !== false) { + yield* start(self) + } + + return self + }) - export const busy = (self: SingleFlight): Effect.Effect => - SynchronizedRef.get(self.state).pipe( - Effect.map((state) => state._tag === "Pending" || state._tag === "Starting" || state._tag === "Running"), - ) + export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) - const complete = (self: SingleFlight) => - SynchronizedRef.update(self.state, (state): State => (state._tag === "Done" ? state : { _tag: "Done" })) + const finish = (self: SingleFlight) => + SynchronizedRef.update(self.state, (): State => ({ _tag: "Done" })) - const launch = (self: SingleFlight, token: symbol, effect: Effect.Effect): Effect.Effect => + const launch = (self: SingleFlight, token: symbol): Effect.Effect => Effect.gen(function* () { - const fiber = yield* effect.pipe( + const fiber = yield* self.effect.pipe( Effect.onExit((exit) => Effect.gen(function* () { - yield* complete(self) + yield* finish(self) yield* Deferred.done(self.done, exit) }), ), Effect.forkChild, ) - const next = yield* SynchronizedRef.modifyEffect(self.state, (state) => { + yield* SynchronizedRef.modifyEffect(self.state, (state) => { if (state._tag === "Starting" && state.token === token) { return Effect.succeed<[Effect.Effect, State]>([Effect.void, { _tag: "Running", token, fiber }]) } return Effect.succeed<[Effect.Effect, State]>([Fiber.interrupt(fiber).pipe(Effect.asVoid), state]) - }) - - return yield* next + }).pipe(Effect.flatten) }) - export const run = (self: SingleFlight, effect: Effect.Effect): Effect.Effect => - SynchronizedRef.modifyEffect(self.state, (state) => { - if (state._tag !== "Empty") { - return Effect.succeed<[Effect.Effect, State]>([wait(self), state]) - } - - const token = Symbol() - return Effect.succeed<[Effect.Effect, State]>([ - launch(self, token, effect).pipe(Effect.flatMap(() => wait(self))), - { _tag: "Starting", token }, - ]) - }).pipe(Effect.flatten) - - export const pend = (self: SingleFlight): Effect.Effect => - SynchronizedRef.modifyEffect(self.state, (state) => { - if (state._tag !== "Empty") { - return Effect.succeed<[Effect.Effect, State]>([wait(self), state]) - } - - return Effect.succeed<[Effect.Effect, State]>([wait(self), { _tag: "Pending", token: Symbol() }]) - }).pipe(Effect.flatten) - - export const promote = (self: SingleFlight, effect: Effect.Effect): Effect.Effect => + export const start = (self: SingleFlight): Effect.Effect => SynchronizedRef.modifyEffect(self.state, (state) => { - if (state._tag !== "Pending") { + if (state._tag === "Running" || state._tag === "Starting" || state._tag === "Done") { return Effect.succeed<[Effect.Effect, State]>([Effect.void, state]) } return Effect.succeed<[Effect.Effect, State]>([ - launch(self, state.token, effect).pipe(Effect.ignore), + launch(self, state.token).pipe(Effect.ignore), { _tag: "Starting", token: state.token }, ]) }).pipe(Effect.flatten) - export const interrupt = (self: SingleFlight): Effect.Effect => + export const cancel = (self: SingleFlight): Effect.Effect => SynchronizedRef.modifyEffect(self.state, (state) => { switch (state._tag) { - case "Empty": case "Done": return Effect.succeed<[Effect.Effect, State]>([Effect.void, state]) case "Pending": diff --git a/packages/opencode/test/effect/single-flight.test.ts b/packages/opencode/test/effect/single-flight.test.ts index f448dee4746f..91f45bd4a46d 100644 --- a/packages/opencode/test/effect/single-flight.test.ts +++ b/packages/opencode/test/effect/single-flight.test.ts @@ -5,24 +5,22 @@ import { SingleFlight } from "../../src/effect/single-flight" type Result = { value: string } describe("SingleFlight", () => { - test("run starts immediately and returns the result", async () => { + test("autoStart true runs immediately and join returns the result", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const flight = yield* SingleFlight.make() - const result = yield* SingleFlight.run(flight, Effect.succeed({ value: "ok" })) + const flight = yield* SingleFlight.make(Effect.succeed({ value: "ok" })) + const result = yield* SingleFlight.join(flight) expect(result.value).toBe("ok") - expect(yield* SingleFlight.busy(flight)).toBe(false) }), ), ) }) - test("concurrent run callers share the same result", async () => { + test("concurrent joins share the same run", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const flight = yield* SingleFlight.make() const calls = yield* Ref.make(0) const work = Effect.gen(function* () { yield* Ref.update(calls, (n) => n + 1) @@ -30,7 +28,8 @@ describe("SingleFlight", () => { return { value: "shared" } }) - const [a, b] = yield* Effect.all([SingleFlight.run(flight, work), SingleFlight.run(flight, work)], { + const flight = yield* SingleFlight.make(work) + const [a, b] = yield* Effect.all([SingleFlight.join(flight), SingleFlight.join(flight)], { concurrency: "unbounded", }) @@ -42,23 +41,22 @@ describe("SingleFlight", () => { ) }) - test("pend reserves the slot and promote starts it later", async () => { + test("autoStart false does not begin until started", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const flight = yield* SingleFlight.make() const started = yield* Ref.make(false) const work = Effect.gen(function* () { yield* Ref.set(started, true) return { value: "later" } }) - const waiter = yield* SingleFlight.pend(flight).pipe(Effect.forkChild) + const flight = yield* SingleFlight.make(work, { autoStart: false }) + const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - expect(yield* SingleFlight.busy(flight)).toBe(true) expect(yield* Ref.get(started)).toBe(false) - yield* SingleFlight.promote(flight, work) + yield* SingleFlight.start(flight) const exit = yield* Fiber.await(waiter) expect(yield* Ref.get(started)).toBe(true) @@ -71,44 +69,44 @@ describe("SingleFlight", () => { ) }) - test("interrupt fails pending waiters", async () => { + test("cancel fails pending joins", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const flight = yield* SingleFlight.make() - const waiter = yield* SingleFlight.pend(flight).pipe(Effect.forkChild) + const flight = yield* SingleFlight.make(Effect.succeed({ value: "never" }), { + autoStart: false, + }) + const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - yield* SingleFlight.interrupt(flight) + yield* SingleFlight.cancel(flight) const exit = yield* Fiber.await(waiter) expect(Exit.isFailure(exit)).toBe(true) - expect(yield* SingleFlight.busy(flight)).toBe(false) }), ), ) }) - test("interrupt waits for running fiber cleanup", async () => { + test("cancel waits for running fiber cleanup", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const flight = yield* SingleFlight.make() const cleanup = yield* Deferred.make() const work = Effect.never.pipe( Effect.ensuring(Deferred.succeed(cleanup, undefined).pipe(Effect.asVoid)), Effect.as({ value: "never" as const }), ) - const waiter = yield* SingleFlight.run(flight, work).pipe(Effect.forkChild) + const flight = yield* SingleFlight.make(work) + const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - yield* SingleFlight.interrupt(flight) + yield* SingleFlight.cancel(flight) yield* Deferred.await(cleanup) const exit = yield* Fiber.await(waiter) expect(Exit.isFailure(exit)).toBe(true) - expect(yield* SingleFlight.busy(flight)).toBe(false) }), ), ) From 1e2d6c26dc47da007565fe24e8dfa27b834982c2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 23:08:09 -0400 Subject: [PATCH 32/66] simplify SingleFlight: 3 states, no onInterrupt, uninterruptible start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the Pending/Starting/token machinery — wrapping the fork in Effect.uninterruptible eliminates the race entirely. Drop onInterrupt (unused path). States are now Idle → Running → Done. --- packages/opencode/src/effect/single-flight.ts | 94 +++++++------------ 1 file changed, 35 insertions(+), 59 deletions(-) diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts index b26b372f4b45..a88d9679d53e 100644 --- a/packages/opencode/src/effect/single-flight.ts +++ b/packages/opencode/src/effect/single-flight.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Fiber, SynchronizedRef } from "effect" +import { Deferred, Effect, Fiber } from "effect" const TypeId = Symbol.for("@opencode/SingleFlight") @@ -6,89 +6,65 @@ export interface SingleFlight { readonly [TypeId]: typeof TypeId readonly effect: Effect.Effect readonly done: Deferred.Deferred - readonly state: SynchronizedRef.SynchronizedRef> + state: SingleFlight.State } export namespace SingleFlight { export type State = - | { readonly _tag: "Pending"; readonly token: symbol } - | { readonly _tag: "Starting"; readonly token: symbol } - | { readonly _tag: "Running"; readonly token: symbol; readonly fiber: Fiber.Fiber } + | { readonly _tag: "Idle" } + | { readonly _tag: "Running"; readonly fiber: Fiber.Fiber } | { readonly _tag: "Done" } - export const make = (effect: Effect.Effect, options?: { autoStart?: boolean }) => + export const make = ( + effect: Effect.Effect, + options?: { autoStart?: boolean }, + ) => Effect.gen(function* () { - const token = Symbol() const self: SingleFlight = { [TypeId]: TypeId, effect, done: yield* Deferred.make(), - state: yield* SynchronizedRef.make>({ _tag: "Pending", token }), + state: { _tag: "Idle" }, } - if (options?.autoStart !== false) { yield* start(self) } - return self }) export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) - const finish = (self: SingleFlight) => - SynchronizedRef.update(self.state, (): State => ({ _tag: "Done" })) - - const launch = (self: SingleFlight, token: symbol): Effect.Effect => - Effect.gen(function* () { - const fiber = yield* self.effect.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - yield* finish(self) - yield* Deferred.done(self.done, exit) - }), - ), - Effect.forkChild, - ) - - yield* SynchronizedRef.modifyEffect(self.state, (state) => { - if (state._tag === "Starting" && state.token === token) { - return Effect.succeed<[Effect.Effect, State]>([Effect.void, { _tag: "Running", token, fiber }]) - } - - return Effect.succeed<[Effect.Effect, State]>([Fiber.interrupt(fiber).pipe(Effect.asVoid), state]) - }).pipe(Effect.flatten) - }) - export const start = (self: SingleFlight): Effect.Effect => - SynchronizedRef.modifyEffect(self.state, (state) => { - if (state._tag === "Running" || state._tag === "Starting" || state._tag === "Done") { - return Effect.succeed<[Effect.Effect, State]>([Effect.void, state]) - } - - return Effect.succeed<[Effect.Effect, State]>([ - launch(self, state.token).pipe(Effect.ignore), - { _tag: "Starting", token: state.token }, - ]) - }).pipe(Effect.flatten) + Effect.uninterruptible( + Effect.gen(function* () { + if (self.state._tag !== "Idle") return + const fiber = yield* self.effect.pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + self.state = { _tag: "Done" } + yield* Deferred.done(self.done, exit) + }), + ), + Effect.forkChild, + ) + self.state = { _tag: "Running", fiber } + }), + ) export const cancel = (self: SingleFlight): Effect.Effect => - SynchronizedRef.modifyEffect(self.state, (state) => { + Effect.gen(function* () { + const state = self.state switch (state._tag) { case "Done": - return Effect.succeed<[Effect.Effect, State]>([Effect.void, state]) - case "Pending": - case "Starting": - return Effect.succeed<[Effect.Effect, State]>([ - Deferred.interrupt(self.done).pipe(Effect.asVoid), - { _tag: "Done" }, - ]) + return + case "Idle": + self.state = { _tag: "Done" } + yield* Deferred.interrupt(self.done).pipe(Effect.asVoid) + return case "Running": - return Effect.succeed<[Effect.Effect, State]>([ - Fiber.interrupt(state.fiber).pipe( - Effect.flatMap(() => Deferred.await(self.done).pipe(Effect.exit, Effect.asVoid)), - ), - { _tag: "Done" }, - ]) + self.state = { _tag: "Done" } + yield* Fiber.interrupt(state.fiber) + yield* Deferred.await(self.done).pipe(Effect.exit, Effect.asVoid) } - }).pipe(Effect.flatten) + }) } From 0608d0824fed154c5d09f3972a032ed5f29c485b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 29 Mar 2026 23:37:39 -0400 Subject: [PATCH 33/66] integrate SingleFlight into prompt coordination layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace LoopEntry/deferred-queue with SingleFlight for loop dedup. start() takes an explicit Scope for forkIn. Cancel uses Cancelled error (not interrupt) so all joiners wake up — Effect's Deferred.done with interrupt exits only notifies one waiter. Removed: LoopEntry, awaitFiber, startLoop, State type alias. Simplified: cancel, loop, shell, finalizer. --- packages/opencode/src/effect/single-flight.ts | 32 +++-- packages/opencode/src/session/prompt.ts | 133 +++++++----------- .../test/effect/single-flight.test.ts | 21 +-- .../test/session/prompt-effect.test.ts | 29 +--- 4 files changed, 87 insertions(+), 128 deletions(-) diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts index a88d9679d53e..594327391bda 100644 --- a/packages/opencode/src/effect/single-flight.ts +++ b/packages/opencode/src/effect/single-flight.ts @@ -1,11 +1,15 @@ -import { Deferred, Effect, Fiber } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" const TypeId = Symbol.for("@opencode/SingleFlight") +export class Cancelled { + readonly _tag = "SingleFlight.Cancelled" +} + export interface SingleFlight { readonly [TypeId]: typeof TypeId readonly effect: Effect.Effect - readonly done: Deferred.Deferred + readonly done: Deferred.Deferred state: SingleFlight.State } @@ -15,26 +19,20 @@ export namespace SingleFlight { | { readonly _tag: "Running"; readonly fiber: Fiber.Fiber } | { readonly _tag: "Done" } - export const make = ( - effect: Effect.Effect, - options?: { autoStart?: boolean }, - ) => + export const make = (effect: Effect.Effect) => Effect.gen(function* () { const self: SingleFlight = { [TypeId]: TypeId, effect, - done: yield* Deferred.make(), + done: yield* Deferred.make(), state: { _tag: "Idle" }, } - if (options?.autoStart !== false) { - yield* start(self) - } return self }) - export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) + export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) - export const start = (self: SingleFlight): Effect.Effect => + export const start = (self: SingleFlight, scope: Scope.Scope): Effect.Effect => Effect.uninterruptible( Effect.gen(function* () { if (self.state._tag !== "Idle") return @@ -42,10 +40,14 @@ export namespace SingleFlight { Effect.onExit((exit) => Effect.gen(function* () { self.state = { _tag: "Done" } - yield* Deferred.done(self.done, exit) + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + yield* Deferred.fail(self.done, new Cancelled()) + } else { + yield* Deferred.done(self.done, exit) + } }), ), - Effect.forkChild, + Effect.forkIn(scope), ) self.state = { _tag: "Running", fiber } }), @@ -59,7 +61,7 @@ export namespace SingleFlight { return case "Idle": self.state = { _tag: "Done" } - yield* Deferred.interrupt(self.done).pipe(Effect.asVoid) + yield* Deferred.fail(self.done, new Cancelled()).pipe(Effect.asVoid) return case "Running": self.state = { _tag: "Done" } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9ce47a845dd2..f254b54edab2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,6 +23,7 @@ import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" +import { SingleFlight } from "@/effect/single-flight" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -46,7 +47,7 @@ import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -66,13 +67,8 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) - interface LoopEntry { - fiber?: Fiber.Fiber - queue: Deferred.Deferred[] - } - interface ShellEntry { - fiber?: Fiber.Fiber + fiber: Fiber.Fiber abort: AbortController } @@ -108,19 +104,17 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { - const loops = new Map() + const loops = new Map>() const shells = new Map() yield* Effect.addFinalizer(() => Effect.gen(function* () { - for (const item of shells.values()) item.abort.abort() - yield* Fiber.interruptAll([ - ...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), - ...shells.values().flatMap((x) => (x.fiber ? [x.fiber] : [])), - ]) - for (const entry of loops.values()) { - if (entry.fiber) continue - for (const d of entry.queue) yield* Deferred.interrupt(d) - } + const flights = [...loops.values()] + const entries = [...shells.values()] + loops.clear() + shells.clear() + for (const item of entries) item.abort.abort() + yield* Fiber.interruptAll(entries.map((x) => x.fiber)) + yield* Effect.forEach(flights, (f) => SingleFlight.cancel(f), { concurrency: "unbounded" }) }), ) return { loops, shells } @@ -135,26 +129,19 @@ export namespace SessionPrompt { const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { log.info("cancel", { sessionID }) const s = yield* InstanceState.get(cache) - const loopEntry = s.loops.get(sessionID) - const shellEntry = s.shells.get(sessionID) - if (!loopEntry && !shellEntry) { + const flight = s.loops.get(sessionID) + const entry = s.shells.get(sessionID) + if (!flight && !entry) { yield* status.set(sessionID, { type: "idle" }) return } - if (loopEntry) { - if (loopEntry.fiber) { - s.loops.delete(sessionID) - yield* Fiber.interrupt(loopEntry.fiber) - yield* Fiber.await(loopEntry.fiber) - } else { - for (const d of loopEntry.queue) yield* Deferred.interrupt(d) - s.loops.delete(sessionID) - } + if (flight) { + s.loops.delete(sessionID) + yield* SingleFlight.cancel(flight) } - if (shellEntry) { - shellEntry.abort.abort() - if (shellEntry.fiber) yield* Fiber.await(shellEntry.fiber) - else if (s.shells.get(sessionID) === shellEntry) s.shells.delete(sessionID) + if (entry) { + entry.abort.abort() + yield* Fiber.await(entry.fiber) } if (!s.loops.has(sessionID) && !s.shells.has(sessionID)) { yield* status.set(sessionID, { type: "idle" }) @@ -1537,61 +1524,42 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* lastAssistant(sessionID) }) - type State = { loops: Map; shells: Map } - - const awaitFiber = (fiber: Fiber.Fiber, fallback: Effect.Effect) => - Effect.gen(function* () { - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause)) return yield* fallback - return yield* Effect.failCause(exit.cause) - }) - - const startLoop: (s: State, sessionID: SessionID) => Effect.Effect = - Effect.fnUntraced(function* (s: State, sessionID: SessionID) { - const entry = s.loops.get(sessionID) ?? { queue: [] } - s.loops.set(sessionID, entry) - const fiber = yield* runLoop(sessionID).pipe( - Effect.onExit((exit) => + const makeFlight = Effect.fnUntraced(function* ( + s: { loops: Map>; shells: Map }, + sessionID: SessionID, + ) { + let flight!: SingleFlight + flight = yield* SingleFlight.make( + runLoop(sessionID).pipe( + Effect.ensuring( Effect.gen(function* () { - // On interrupt, resolve queued callers with the last assistant message - const resolved = - Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - ? Exit.succeed(yield* lastAssistant(sessionID)) - : exit - for (const d of entry.queue) yield* Deferred.done(d, resolved) - if (s.loops.get(sessionID) === entry) s.loops.delete(sessionID) + if (s.loops.get(sessionID) === flight) s.loops.delete(sessionID) if (!s.loops.has(sessionID) && !s.shells.has(sessionID)) { yield* status.set(sessionID, { type: "idle" }) } }), ), - Effect.forkChild, - ) - entry.fiber = fiber - return yield* awaitFiber(fiber, lastAssistant(sessionID)) - }) + ), + ) + s.loops.set(sessionID, flight) + return flight + }) const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const existing = s.loops.get(input.sessionID) - if (existing) { - const d = yield* Deferred.make() - existing.queue.push(d) - return yield* Deferred.await(d) + return yield* SingleFlight.join(existing) } - // If a shell is running, queue — shell cleanup will start the loop - if (s.shells.has(input.sessionID)) { - const d = yield* Deferred.make() - s.loops.set(input.sessionID, { queue: [d] }) - return yield* Deferred.await(d) + const flight = yield* makeFlight(s, input.sessionID) + // If a shell is running, don't start yet — shell cleanup will start it + if (!s.shells.has(input.sessionID)) { + yield* SingleFlight.start(flight, scope) } - - return yield* startLoop(s, input.sessionID) + return yield* SingleFlight.join(flight) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( @@ -1604,26 +1572,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* status.set(input.sessionID, { type: "busy" }) const ctrl = new AbortController() - const entry: ShellEntry = { abort: ctrl } - s.shells.set(input.sessionID, entry) const fiber = yield* shellImpl(input, ctrl.signal).pipe( Effect.ensuring( Effect.gen(function* () { - if (s.shells.get(input.sessionID) === entry) s.shells.delete(input.sessionID) - // If callers queued a loop while the shell was running, start it + s.shells.delete(input.sessionID) const pending = s.loops.get(input.sessionID) - if (pending && pending.queue.length > 0) { - yield* startLoop(s, input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope)) - } else if (!s.loops.has(input.sessionID) && !s.shells.has(input.sessionID)) { + if (pending) { + yield* SingleFlight.start(pending, scope) + } else if (!s.loops.has(input.sessionID)) { yield* status.set(input.sessionID, { type: "idle" }) } }), ), - Effect.forkChild, + Effect.forkIn(scope), ) - - entry.fiber = fiber - return yield* awaitFiber(fiber, lastAssistant(input.sessionID)) + s.shells.set(input.sessionID, { fiber, abort: ctrl }) + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause)) return yield* lastAssistant(input.sessionID) + return yield* Effect.failCause(exit.cause) }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { diff --git a/packages/opencode/test/effect/single-flight.test.ts b/packages/opencode/test/effect/single-flight.test.ts index 91f45bd4a46d..2e90f4b00c3d 100644 --- a/packages/opencode/test/effect/single-flight.test.ts +++ b/packages/opencode/test/effect/single-flight.test.ts @@ -1,15 +1,17 @@ import { describe, expect, test } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Ref } from "effect" +import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { SingleFlight } from "../../src/effect/single-flight" type Result = { value: string } describe("SingleFlight", () => { - test("autoStart true runs immediately and join returns the result", async () => { + test("start + join returns the result", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { + const s = yield* Scope.Scope const flight = yield* SingleFlight.make(Effect.succeed({ value: "ok" })) + yield* SingleFlight.start(flight, s) const result = yield* SingleFlight.join(flight) expect(result.value).toBe("ok") }), @@ -21,6 +23,7 @@ describe("SingleFlight", () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { + const s = yield* Scope.Scope const calls = yield* Ref.make(0) const work = Effect.gen(function* () { yield* Ref.update(calls, (n) => n + 1) @@ -29,6 +32,7 @@ describe("SingleFlight", () => { }) const flight = yield* SingleFlight.make(work) + yield* SingleFlight.start(flight, s) const [a, b] = yield* Effect.all([SingleFlight.join(flight), SingleFlight.join(flight)], { concurrency: "unbounded", }) @@ -41,22 +45,23 @@ describe("SingleFlight", () => { ) }) - test("autoStart false does not begin until started", async () => { + test("idle flight does not begin until started", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { + const s = yield* Scope.Scope const started = yield* Ref.make(false) const work = Effect.gen(function* () { yield* Ref.set(started, true) return { value: "later" } }) - const flight = yield* SingleFlight.make(work, { autoStart: false }) + const flight = yield* SingleFlight.make(work) const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(yield* Ref.get(started)).toBe(false) - yield* SingleFlight.start(flight) + yield* SingleFlight.start(flight, s) const exit = yield* Fiber.await(waiter) expect(yield* Ref.get(started)).toBe(true) @@ -73,9 +78,7 @@ describe("SingleFlight", () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const flight = yield* SingleFlight.make(Effect.succeed({ value: "never" }), { - autoStart: false, - }) + const flight = yield* SingleFlight.make(Effect.succeed({ value: "never" })) const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") @@ -92,6 +95,7 @@ describe("SingleFlight", () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { + const s = yield* Scope.Scope const cleanup = yield* Deferred.make() const work = Effect.never.pipe( Effect.ensuring(Deferred.succeed(cleanup, undefined).pipe(Effect.asVoid)), @@ -99,6 +103,7 @@ describe("SingleFlight", () => { ) const flight = yield* SingleFlight.make(work) + yield* SingleFlight.start(flight, s) const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index ca429c8d4593..c9c474a69eb9 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -543,7 +543,7 @@ it.effect("loop sets status to busy then idle", () => // Cancel semantics it.effect( - "cancel interrupts loop and resolves with an assistant message", + "cancel interrupts loop joiners", () => provideTmpdirInstance( (dir) => @@ -563,10 +563,7 @@ it.effect( yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - } + expect(Exit.isFailure(exit)).toBe(true) }), { git: true, config: cfg }, ), @@ -574,7 +571,7 @@ it.effect( ) it.effect( - "cancel records MessageAbortedError on interrupted process", + "cancel interrupts loop after LLM stream starts", () => provideTmpdirInstance( (dir) => @@ -594,13 +591,7 @@ it.effect( yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - const info = exit.value.info - if (info.role === "assistant") { - expect(info.error?.name).toBe("MessageAbortedError") - } - } + expect(Exit.isFailure(exit)).toBe(true) }), { git: true, config: cfg }, ), @@ -608,7 +599,7 @@ it.effect( ) it.effect( - "cancel with queued callers resolves all cleanly", + "cancel with queued callers interrupts all", () => provideTmpdirInstance( (dir) => @@ -632,11 +623,8 @@ it.effect( yield* prompt.cancel(chat.id) const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(exitA)).toBe(true) - expect(Exit.isSuccess(exitB)).toBe(true) - if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { - expect(exitA.value.info.id).toBe(exitB.value.info.id) - } + expect(Exit.isFailure(exitA)).toBe(true) + expect(Exit.isFailure(exitB)).toBe(true) }), { git: true, config: cfg }, ), @@ -961,9 +949,6 @@ it.effect( const exit = yield* Fiber.await(run) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) - } yield* Fiber.await(sh) }), From 9bc52adb725af1dd6d40ecab47e94608bbb62ead Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 08:24:53 -0400 Subject: [PATCH 34/66] catch Cancelled in loop to return last assistant on cancel SingleFlight.Cancelled is a regular error (not interrupt) for multi-joiner safety. The loop layer catches it and falls back to lastAssistant, restoring the original cancel-resolves-cleanly behavior for the UI. --- packages/opencode/src/session/prompt.ts | 13 +++++---- .../test/session/prompt-effect.test.ts | 28 +++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f254b54edab2..1fed1ff50b0b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,7 +23,7 @@ import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" -import { SingleFlight } from "@/effect/single-flight" +import { Cancelled, SingleFlight } from "@/effect/single-flight" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -1545,21 +1545,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the return flight }) + const joinFlight = (flight: SingleFlight, sessionID: SessionID) => + SingleFlight.join(flight).pipe( + Effect.catch((e) => (e instanceof Cancelled ? lastAssistant(sessionID) : Effect.fail(e))), + ) + const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const existing = s.loops.get(input.sessionID) - if (existing) { - return yield* SingleFlight.join(existing) - } + if (existing) return yield* joinFlight(existing, input.sessionID) const flight = yield* makeFlight(s, input.sessionID) // If a shell is running, don't start yet — shell cleanup will start it if (!s.shells.has(input.sessionID)) { yield* SingleFlight.start(flight, scope) } - return yield* SingleFlight.join(flight) + return yield* joinFlight(flight, input.sessionID) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index c9c474a69eb9..e38bfc29825b 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -543,7 +543,7 @@ it.effect("loop sets status to busy then idle", () => // Cancel semantics it.effect( - "cancel interrupts loop joiners", + "cancel interrupts loop and resolves with an assistant message", () => provideTmpdirInstance( (dir) => @@ -563,7 +563,10 @@ it.effect( yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) - expect(Exit.isFailure(exit)).toBe(true) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + } }), { git: true, config: cfg }, ), @@ -571,7 +574,7 @@ it.effect( ) it.effect( - "cancel interrupts loop after LLM stream starts", + "cancel records MessageAbortedError on interrupted process", () => provideTmpdirInstance( (dir) => @@ -591,7 +594,13 @@ it.effect( yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(fiber) - expect(Exit.isFailure(exit)).toBe(true) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const info = exit.value.info + if (info.role === "assistant") { + expect(info.error?.name).toBe("MessageAbortedError") + } + } }), { git: true, config: cfg }, ), @@ -599,7 +608,7 @@ it.effect( ) it.effect( - "cancel with queued callers interrupts all", + "cancel with queued callers resolves all cleanly", () => provideTmpdirInstance( (dir) => @@ -623,8 +632,11 @@ it.effect( yield* prompt.cancel(chat.id) const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isFailure(exitA)).toBe(true) - expect(Exit.isFailure(exitB)).toBe(true) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { + expect(exitA.value.info.id).toBe(exitB.value.info.id) + } }), { git: true, config: cfg }, ), @@ -948,7 +960,7 @@ it.effect( yield* prompt.cancel(chat.id) const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit)).toBe(true) + expect(Exit.isSuccess(exit)).toBe(true) yield* Fiber.await(sh) }), From 18b31ea62a843e7cb36d2b91271b79cd2298c1c8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 08:27:46 -0400 Subject: [PATCH 35/66] use Schema.TaggedErrorClass for SingleFlight.Cancelled --- packages/opencode/src/effect/single-flight.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts index 594327391bda..2ee6cbddd1f0 100644 --- a/packages/opencode/src/effect/single-flight.ts +++ b/packages/opencode/src/effect/single-flight.ts @@ -1,10 +1,9 @@ +import { Schema } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" const TypeId = Symbol.for("@opencode/SingleFlight") -export class Cancelled { - readonly _tag = "SingleFlight.Cancelled" -} +export class Cancelled extends Schema.TaggedErrorClass()("SingleFlight.Cancelled", {}) {} export interface SingleFlight { readonly [TypeId]: typeof TypeId From 1fd1046f570fc19633913df53b5a5a94b621046c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 08:48:31 -0400 Subject: [PATCH 36/66] rename SingleFlight.join to SingleFlight.await --- packages/opencode/src/effect/single-flight.ts | 2 +- packages/opencode/src/session/prompt.ts | 8 ++++---- packages/opencode/test/effect/single-flight.test.ts | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts index 2ee6cbddd1f0..5da617582061 100644 --- a/packages/opencode/src/effect/single-flight.ts +++ b/packages/opencode/src/effect/single-flight.ts @@ -29,7 +29,7 @@ export namespace SingleFlight { return self }) - export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) + export const await = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) export const start = (self: SingleFlight, scope: Scope.Scope): Effect.Effect => Effect.uninterruptible( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1fed1ff50b0b..b52e3de0f2b0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1545,8 +1545,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the return flight }) - const joinFlight = (flight: SingleFlight, sessionID: SessionID) => - SingleFlight.join(flight).pipe( + const awaitFlight = (flight: SingleFlight, sessionID: SessionID) => + SingleFlight.await(flight).pipe( Effect.catch((e) => (e instanceof Cancelled ? lastAssistant(sessionID) : Effect.fail(e))), ) @@ -1555,14 +1555,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const existing = s.loops.get(input.sessionID) - if (existing) return yield* joinFlight(existing, input.sessionID) + if (existing) return yield* awaitFlight(existing, input.sessionID) const flight = yield* makeFlight(s, input.sessionID) // If a shell is running, don't start yet — shell cleanup will start it if (!s.shells.has(input.sessionID)) { yield* SingleFlight.start(flight, scope) } - return yield* joinFlight(flight, input.sessionID) + return yield* awaitFlight(flight, input.sessionID) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( diff --git a/packages/opencode/test/effect/single-flight.test.ts b/packages/opencode/test/effect/single-flight.test.ts index 2e90f4b00c3d..bebc3a7e80d7 100644 --- a/packages/opencode/test/effect/single-flight.test.ts +++ b/packages/opencode/test/effect/single-flight.test.ts @@ -12,7 +12,7 @@ describe("SingleFlight", () => { const s = yield* Scope.Scope const flight = yield* SingleFlight.make(Effect.succeed({ value: "ok" })) yield* SingleFlight.start(flight, s) - const result = yield* SingleFlight.join(flight) + const result = yield* SingleFlight.await(flight) expect(result.value).toBe("ok") }), ), @@ -33,7 +33,7 @@ describe("SingleFlight", () => { const flight = yield* SingleFlight.make(work) yield* SingleFlight.start(flight, s) - const [a, b] = yield* Effect.all([SingleFlight.join(flight), SingleFlight.join(flight)], { + const [a, b] = yield* Effect.all([SingleFlight.await(flight), SingleFlight.await(flight)], { concurrency: "unbounded", }) @@ -57,7 +57,7 @@ describe("SingleFlight", () => { }) const flight = yield* SingleFlight.make(work) - const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) + const waiter = yield* SingleFlight.await(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(yield* Ref.get(started)).toBe(false) @@ -79,7 +79,7 @@ describe("SingleFlight", () => { Effect.scoped( Effect.gen(function* () { const flight = yield* SingleFlight.make(Effect.succeed({ value: "never" })) - const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) + const waiter = yield* SingleFlight.await(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") yield* SingleFlight.cancel(flight) @@ -104,7 +104,7 @@ describe("SingleFlight", () => { const flight = yield* SingleFlight.make(work) yield* SingleFlight.start(flight, s) - const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) + const waiter = yield* SingleFlight.await(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") yield* SingleFlight.cancel(flight) From 86a9b57a1a91d7cbbecde6e798153466b7695fa0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 08:55:44 -0400 Subject: [PATCH 37/66] inline makeFlight into loop, revert shell to forkChild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove makeFlight helper — loop now creates the SingleFlight directly, with cleanup on the await side. Eliminates the let flight! forward-reference hack. Shell reverts to forkChild since forkIn(scope) broke scheduling for the shell process fiber. --- packages/opencode/src/effect/single-flight.ts | 2 +- packages/opencode/src/session/prompt.ts | 40 +++++++------------ .../test/effect/single-flight.test.ts | 10 ++--- 3 files changed, 20 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts index 5da617582061..2ee6cbddd1f0 100644 --- a/packages/opencode/src/effect/single-flight.ts +++ b/packages/opencode/src/effect/single-flight.ts @@ -29,7 +29,7 @@ export namespace SingleFlight { return self }) - export const await = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) + export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) export const start = (self: SingleFlight, scope: Scope.Scope): Effect.Effect => Effect.uninterruptible( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b52e3de0f2b0..659d6fc61640 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1524,29 +1524,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* lastAssistant(sessionID) }) - const makeFlight = Effect.fnUntraced(function* ( - s: { loops: Map>; shells: Map }, - sessionID: SessionID, - ) { - let flight!: SingleFlight - flight = yield* SingleFlight.make( - runLoop(sessionID).pipe( - Effect.ensuring( - Effect.gen(function* () { - if (s.loops.get(sessionID) === flight) s.loops.delete(sessionID) - if (!s.loops.has(sessionID) && !s.shells.has(sessionID)) { - yield* status.set(sessionID, { type: "idle" }) - } - }), - ), - ), - ) - s.loops.set(sessionID, flight) - return flight - }) - const awaitFlight = (flight: SingleFlight, sessionID: SessionID) => - SingleFlight.await(flight).pipe( + SingleFlight.join(flight).pipe( Effect.catch((e) => (e instanceof Cancelled ? lastAssistant(sessionID) : Effect.fail(e))), ) @@ -1557,12 +1536,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the const existing = s.loops.get(input.sessionID) if (existing) return yield* awaitFlight(existing, input.sessionID) - const flight = yield* makeFlight(s, input.sessionID) - // If a shell is running, don't start yet — shell cleanup will start it + const flight = yield* SingleFlight.make(runLoop(input.sessionID)) + s.loops.set(input.sessionID, flight) if (!s.shells.has(input.sessionID)) { yield* SingleFlight.start(flight, scope) } - return yield* awaitFlight(flight, input.sessionID) + return yield* awaitFlight(flight, input.sessionID).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (s.loops.get(input.sessionID) === flight) s.loops.delete(input.sessionID) + if (!s.loops.has(input.sessionID) && !s.shells.has(input.sessionID)) { + yield* status.set(input.sessionID, { type: "idle" }) + } + }), + ), + ) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( @@ -1587,7 +1575,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } }), ), - Effect.forkIn(scope), + Effect.forkChild, ) s.shells.set(input.sessionID, { fiber, abort: ctrl }) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/effect/single-flight.test.ts b/packages/opencode/test/effect/single-flight.test.ts index bebc3a7e80d7..2e90f4b00c3d 100644 --- a/packages/opencode/test/effect/single-flight.test.ts +++ b/packages/opencode/test/effect/single-flight.test.ts @@ -12,7 +12,7 @@ describe("SingleFlight", () => { const s = yield* Scope.Scope const flight = yield* SingleFlight.make(Effect.succeed({ value: "ok" })) yield* SingleFlight.start(flight, s) - const result = yield* SingleFlight.await(flight) + const result = yield* SingleFlight.join(flight) expect(result.value).toBe("ok") }), ), @@ -33,7 +33,7 @@ describe("SingleFlight", () => { const flight = yield* SingleFlight.make(work) yield* SingleFlight.start(flight, s) - const [a, b] = yield* Effect.all([SingleFlight.await(flight), SingleFlight.await(flight)], { + const [a, b] = yield* Effect.all([SingleFlight.join(flight), SingleFlight.join(flight)], { concurrency: "unbounded", }) @@ -57,7 +57,7 @@ describe("SingleFlight", () => { }) const flight = yield* SingleFlight.make(work) - const waiter = yield* SingleFlight.await(flight).pipe(Effect.forkChild) + const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(yield* Ref.get(started)).toBe(false) @@ -79,7 +79,7 @@ describe("SingleFlight", () => { Effect.scoped( Effect.gen(function* () { const flight = yield* SingleFlight.make(Effect.succeed({ value: "never" })) - const waiter = yield* SingleFlight.await(flight).pipe(Effect.forkChild) + const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") yield* SingleFlight.cancel(flight) @@ -104,7 +104,7 @@ describe("SingleFlight", () => { const flight = yield* SingleFlight.make(work) yield* SingleFlight.start(flight, s) - const waiter = yield* SingleFlight.await(flight).pipe(Effect.forkChild) + const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") yield* SingleFlight.cancel(flight) From dd26552ce7e8f2de7da0798f908cde506dea7770 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 10:22:40 -0400 Subject: [PATCH 38/66] replace SingleFlight with per-session runner state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-map (loops + shells) + SingleFlight coordination with a single per-session runner using a 4-state machine: idle → running → idle, idle → shell → idle, shell → shell_then_run → running. One state object per session instead of overlapping maps. The runner handles dedup (multiple callers share one Deferred), shell→loop handoff (queued run starts after shell exits), and cancel (set idle first to prevent ensuring from starting queued work). Removes: SingleFlight import, ShellEntry, awaitFlight, makeFlight. --- packages/opencode/src/session/prompt.ts | 235 ++++++++++++++++-------- 1 file changed, 158 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 659d6fc61640..e272407dc8b7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,7 +23,7 @@ import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" -import { Cancelled, SingleFlight } from "@/effect/single-flight" +import { Schema } from "effect" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -47,7 +47,7 @@ import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -67,11 +67,27 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) - interface ShellEntry { - fiber: Fiber.Fiber + class RunCancelled extends Schema.TaggedErrorClass()("RunCancelled", {}) {} + + interface ShellHandle { + fiber: Fiber.Fiber abort: AbortController } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type RunnerState = + | { type: "idle" } + | { type: "running"; done: any; fiber: any } + | { type: "shell"; shell: ShellHandle } + | { type: "shell_then_run"; shell: ShellHandle; done: any; work: any } + + interface Runner { + state: RunnerState + ensureRunning: (work: Effect.Effect) => Effect.Effect + startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + cancel: Effect.Effect + } + export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect @@ -104,48 +120,157 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { - const loops = new Map>() - const shells = new Map() + const runners = new Map() yield* Effect.addFinalizer(() => Effect.gen(function* () { - const flights = [...loops.values()] - const entries = [...shells.values()] - loops.clear() - shells.clear() - for (const item of entries) item.abort.abort() - yield* Fiber.interruptAll(entries.map((x) => x.fiber)) - yield* Effect.forEach(flights, (f) => SingleFlight.cancel(f), { concurrency: "unbounded" }) + const entries = [...runners.values()] + runners.clear() + yield* Effect.forEach(entries, (r) => r.cancel, { concurrency: "unbounded" }) }), ) - return { loops, shells } + return { runners } }), ) + const getRunner = ( + runners: Map, + sessionID: SessionID, + ) => { + const existing = runners.get(sessionID) + if (existing) return existing + const runner = { + state: { type: "idle" } as RunnerState, + } as Runner + + const cleanup = () => { + if (runner.state.type === "idle") runners.delete(sessionID) + } + + const startRun = (work: Effect.Effect, done: Deferred.Deferred) => + Effect.gen(function* () { + const fiber = yield* work.pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + if (runner.state.type === "running") runner.state = { type: "idle" } + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + yield* Deferred.fail(done, new RunCancelled()) + } else { + yield* Deferred.done(done, exit as any) + } + cleanup() + if (!runners.has(sessionID)) { + yield* status.set(sessionID, { type: "idle" }) + } + }), + ), + Effect.forkIn(scope), + ) + runner.state = { type: "running", done, fiber } + }) + + const ensureRunning = (work: Effect.Effect) => + Effect.gen(function* () { + switch (runner.state.type) { + case "running": + return yield* Deferred.await(runner.state.done) + case "shell_then_run": + return yield* Deferred.await(runner.state.done) + case "shell": { + const done = yield* Deferred.make() + runner.state = { type: "shell_then_run", shell: runner.state.shell, done, work } + return yield* Deferred.await(done) + } + case "idle": { + const done = yield* Deferred.make() + yield* startRun(work, done) + return yield* Deferred.await(done) + } + } + }).pipe(Effect.catch((e) => (e instanceof RunCancelled ? lastAssistant(sessionID) : Effect.fail(e)))) + + const startShell = (work: (signal: AbortSignal) => Effect.Effect) => + Effect.gen(function* () { + if (runner.state.type !== "idle") throw new Session.BusyError(sessionID) + yield* status.set(sessionID, { type: "busy" }) + const ctrl = new AbortController() + const fiber = yield* work(ctrl.signal).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (runner.state.type === "shell_then_run") { + const { done, work: pending } = runner.state + yield* startRun(pending, done) + } else { + runner.state = { type: "idle" } + cleanup() + if (!runners.has(sessionID)) { + yield* status.set(sessionID, { type: "idle" }) + } + } + }), + ), + Effect.forkChild, + ) + runner.state = { type: "shell", shell: { fiber, abort: ctrl } } + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause)) return yield* lastAssistant(sessionID) + return yield* Effect.failCause(exit.cause) + }) + + runner.cancel = Effect.gen(function* () { + const st = runner.state + switch (st.type) { + case "idle": + return + case "running": { + runner.state = { type: "idle" } + yield* Fiber.interrupt(st.fiber) + yield* Deferred.await(st.done).pipe(Effect.exit, Effect.asVoid) + cleanup() + yield* status.set(sessionID, { type: "idle" }) + return + } + case "shell": { + runner.state = { type: "idle" } + st.shell.abort.abort() + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + cleanup() + yield* status.set(sessionID, { type: "idle" }) + return + } + case "shell_then_run": { + runner.state = { type: "idle" } + yield* Deferred.fail(st.done, new RunCancelled()).pipe(Effect.asVoid) + st.shell.abort.abort() + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + cleanup() + yield* status.set(sessionID, { type: "idle" }) + return + } + } + }) + + runner.ensureRunning = ensureRunning as Runner["ensureRunning"] + runner.startShell = startShell as Runner["startShell"] + runners.set(sessionID, runner) + return runner + } + const assertNotBusy = Effect.fn("SessionPrompt.assertNotBusy")(function* (sessionID: SessionID) { const s = yield* InstanceState.get(cache) - if (s.loops.has(sessionID) || s.shells.has(sessionID)) throw new Session.BusyError(sessionID) + const runner = s.runners.get(sessionID) + if (runner && runner.state.type !== "idle") throw new Session.BusyError(sessionID) }) const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { log.info("cancel", { sessionID }) const s = yield* InstanceState.get(cache) - const flight = s.loops.get(sessionID) - const entry = s.shells.get(sessionID) - if (!flight && !entry) { + const runner = s.runners.get(sessionID) + if (!runner || runner.state.type === "idle") { yield* status.set(sessionID, { type: "idle" }) return } - if (flight) { - s.loops.delete(sessionID) - yield* SingleFlight.cancel(flight) - } - if (entry) { - entry.abort.abort() - yield* Fiber.await(entry.fiber) - } - if (!s.loops.has(sessionID) && !s.shells.has(sessionID)) { - yield* status.set(sessionID, { type: "idle" }) - } + yield* runner.cancel }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -1524,64 +1649,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* lastAssistant(sessionID) }) - const awaitFlight = (flight: SingleFlight, sessionID: SessionID) => - SingleFlight.join(flight).pipe( - Effect.catch((e) => (e instanceof Cancelled ? lastAssistant(sessionID) : Effect.fail(e))), - ) - const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) - const existing = s.loops.get(input.sessionID) - if (existing) return yield* awaitFlight(existing, input.sessionID) - - const flight = yield* SingleFlight.make(runLoop(input.sessionID)) - s.loops.set(input.sessionID, flight) - if (!s.shells.has(input.sessionID)) { - yield* SingleFlight.start(flight, scope) - } - return yield* awaitFlight(flight, input.sessionID).pipe( - Effect.ensuring( - Effect.gen(function* () { - if (s.loops.get(input.sessionID) === flight) s.loops.delete(input.sessionID) - if (!s.loops.has(input.sessionID) && !s.shells.has(input.sessionID)) { - yield* status.set(input.sessionID, { type: "idle" }) - } - }), - ), - ) + const runner = getRunner(s.runners, input.sessionID) + return yield* runner.ensureRunning(runLoop(input.sessionID)) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", )(function* (input: ShellInput) { const s = yield* InstanceState.get(cache) - if (s.loops.has(input.sessionID) || s.shells.has(input.sessionID)) { - throw new Session.BusyError(input.sessionID) - } - - yield* status.set(input.sessionID, { type: "busy" }) - const ctrl = new AbortController() - const fiber = yield* shellImpl(input, ctrl.signal).pipe( - Effect.ensuring( - Effect.gen(function* () { - s.shells.delete(input.sessionID) - const pending = s.loops.get(input.sessionID) - if (pending) { - yield* SingleFlight.start(pending, scope) - } else if (!s.loops.has(input.sessionID)) { - yield* status.set(input.sessionID, { type: "idle" }) - } - }), - ), - Effect.forkChild, - ) - s.shells.set(input.sessionID, { fiber, abort: ctrl }) - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause)) return yield* lastAssistant(input.sessionID) - return yield* Effect.failCause(exit.cause) + const runner = getRunner(s.runners, input.sessionID) + return yield* runner.startShell((signal) => shellImpl(input, signal)) }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { From 8208783abd2492fc9dfbdcec25e2642a9c34b5c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 10:28:47 -0400 Subject: [PATCH 39/66] add Runner: per-key actor with 4-state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic coordination primitive for serializing work per key. States: idle → running → idle, idle → shell → idle, shell → shell_then_run → running → idle. Handles dedup (concurrent callers share one Deferred), shell→run handoff, cancel, and lifecycle callbacks (onIdle, onBusy, onInterrupt). 22 tests covering all state transitions and edge cases. --- packages/opencode/src/effect/runner.ts | 143 ++++++ packages/opencode/test/effect/runner.test.ts | 483 +++++++++++++++++++ 2 files changed, 626 insertions(+) create mode 100644 packages/opencode/src/effect/runner.ts create mode 100644 packages/opencode/test/effect/runner.test.ts diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts new file mode 100644 index 000000000000..5a1e1148ad7e --- /dev/null +++ b/packages/opencode/src/effect/runner.ts @@ -0,0 +1,143 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect" + +export class Cancelled extends Schema.TaggedErrorClass()("Runner.Cancelled", {}) {} + +interface ShellHandle { + fiber: Fiber.Fiber + abort: AbortController +} + +type State = + | { type: "idle" } + | { type: "running"; done: Deferred.Deferred; fiber: Fiber.Fiber } + | { type: "shell"; shell: ShellHandle } + | { type: "shell_then_run"; shell: ShellHandle; done: Deferred.Deferred; work: Effect.Effect } + +export interface Runner { + readonly state: State + readonly busy: boolean + readonly ensureRunning: (work: Effect.Effect) => Effect.Effect + readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly cancel: Effect.Effect +} + +export const make = (scope: Scope.Scope, opts?: { + onIdle?: Effect.Effect + onBusy?: Effect.Effect + onInterrupt?: Effect.Effect +}): Runner => { + let state: State = { type: "idle" } + const idle = opts?.onIdle ?? Effect.void + const busy = opts?.onBusy ?? Effect.void + const onInterrupt = opts?.onInterrupt + + const startRun = (work: Effect.Effect, done: Deferred.Deferred) => + Effect.gen(function* () { + const fiber = yield* work.pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + if (state.type === "running") { + state = { type: "idle" } + yield* idle + } + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + yield* Deferred.fail(done, new Cancelled()) + } else { + yield* Deferred.done(done, exit as Exit.Exit) + } + }), + ), + Effect.forkIn(scope), + ) + state = { type: "running", done, fiber } + }) + + const ensureRunning = (work: Effect.Effect) => + Effect.gen(function* () { + switch (state.type) { + case "running": + return yield* Deferred.await(state.done) + case "shell_then_run": + return yield* Deferred.await(state.done) + case "shell": { + const done = yield* Deferred.make() + state = { type: "shell_then_run", shell: state.shell, done, work } + return yield* Deferred.await(done) + } + case "idle": { + const done = yield* Deferred.make() + yield* startRun(work, done) + return yield* Deferred.await(done) + } + } + }).pipe( + Effect.catch((e) => + e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e), + ), + ) + + const startShell = (work: (signal: AbortSignal) => Effect.Effect) => + Effect.gen(function* () { + if (state.type !== "idle") throw new Error("Runner is busy") + yield* busy + const ctrl = new AbortController() + const fiber = yield* work(ctrl.signal).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (state.type === "shell_then_run") { + const { done, work: pending } = state + yield* startRun(pending, done) + } else { + state = { type: "idle" } + if (state.type === "idle") yield* idle + } + }), + ), + Effect.forkChild, + ) + state = { type: "shell", shell: { fiber, abort: ctrl } } + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause) + return exit.value + }) + + const cancel = Effect.gen(function* () { + const st = state + switch (st.type) { + case "idle": + return + case "running": { + state = { type: "idle" } + yield* Fiber.interrupt(st.fiber) + yield* Deferred.await(st.done).pipe(Effect.exit, Effect.asVoid) + yield* idle + return + } + case "shell": { + state = { type: "idle" } + st.shell.abort.abort() + yield* Fiber.interrupt(st.shell.fiber).pipe(Effect.asVoid) + yield* idle + return + } + case "shell_then_run": { + state = { type: "idle" } + yield* Deferred.fail(st.done, new Cancelled()).pipe(Effect.asVoid) + st.shell.abort.abort() + yield* Fiber.interrupt(st.shell.fiber).pipe(Effect.asVoid) + yield* idle + return + } + } + }) + + return { + get state() { return state }, + get busy() { return state.type !== "idle" }, + ensureRunning, + startShell, + cancel, + } +} diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts new file mode 100644 index 000000000000..84422cb8fb16 --- /dev/null +++ b/packages/opencode/test/effect/runner.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" +import { Cancelled, make as makeRunner } from "../../src/effect/runner" + +describe("Runner", () => { + // --- ensureRunning semantics --- + + test("ensureRunning starts work and returns result", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const result = yield* runner.ensureRunning(Effect.succeed("hello")) + expect(result).toBe("hello") + expect(runner.state.type).toBe("idle") + expect(runner.busy).toBe(false) + }), + ), + ) + }) + + test("ensureRunning propagates work failures", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const exit = yield* runner.ensureRunning(Effect.fail("boom")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + expect(runner.state.type).toBe("idle") + }), + ), + ) + }) + + test("concurrent callers share the same run", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const calls = yield* Ref.make(0) + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + yield* Effect.sleep("10 millis") + return "shared" + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(work), runner.ensureRunning(work)], + { concurrency: "unbounded" }, + ) + + expect(a).toBe("shared") + expect(b).toBe("shared") + expect(yield* Ref.get(calls)).toBe(1) + }), + ), + ) + }) + + test("concurrent callers all receive same error", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const work = Effect.gen(function* () { + yield* Effect.sleep("10 millis") + return yield* Effect.fail("boom") + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(work).pipe(Effect.exit), runner.ensureRunning(work).pipe(Effect.exit)], + { concurrency: "unbounded" }, + ) + + expect(Exit.isFailure(a)).toBe(true) + expect(Exit.isFailure(b)).toBe(true) + }), + ), + ) + }) + + test("ensureRunning can be called again after previous run completes", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const a = yield* runner.ensureRunning(Effect.succeed("first")) + expect(a).toBe("first") + + const b = yield* runner.ensureRunning(Effect.succeed("second")) + expect(b).toBe("second") + }), + ), + ) + }) + + test("second ensureRunning ignores new work if already running", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const ran = yield* Ref.make([]) + + const first = Effect.gen(function* () { + yield* Ref.update(ran, (a) => [...a, "first"]) + yield* Effect.sleep("50 millis") + return "first-result" + }) + const second = Effect.gen(function* () { + yield* Ref.update(ran, (a) => [...a, "second"]) + return "second-result" + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(first), runner.ensureRunning(second)], + { concurrency: "unbounded" }, + ) + + // Both get the first run's result — second work is never started + expect(a).toBe("first-result") + expect(b).toBe("first-result") + expect(yield* Ref.get(ran)).toEqual(["first"]) + }), + ), + ) + }) + + // --- cancel semantics --- + + test("cancel interrupts running work", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(true) + expect(runner.state.type).toBe("running") + + yield* runner.cancel + expect(runner.busy).toBe(false) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + }), + ), + ) + }) + + test("cancel on idle is a no-op", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + yield* runner.cancel + expect(runner.busy).toBe(false) + }), + ), + ) + }) + + test("cancel with onInterrupt resolves callers gracefully", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("fallback") + }), + ), + ) + }) + + test("cancel with queued callers resolves all", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) + + const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA)) expect(exitA.value).toBe("fallback") + if (Exit.isSuccess(exitB)) expect(exitB.value).toBe("fallback") + }), + ), + ) + }) + + test("work can be started after cancel", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + yield* runner.cancel + yield* Fiber.await(fiber) + + // Should be able to start fresh + const result = yield* runner.ensureRunning(Effect.succeed("after-cancel")) + expect(result).toBe("after-cancel") + }), + ), + ) + }) + + // --- shell semantics --- + + test("shell runs exclusively", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) + expect(result).toBe("shell-done") + expect(runner.busy).toBe(false) + }), + ), + ) + }) + + test("shell rejects when run is active", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* runner.cancel + yield* Fiber.await(fiber) + }), + ), + ) + }) + + test("shell rejects when another shell is running", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + }), + ), + ) + }) + + // --- shell→run handoff --- + + test("ensureRunning queues behind shell then runs after", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.state.type).toBe("shell") + + const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.state.type).toBe("shell_then_run") + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("run-result") + expect(runner.state.type).toBe("idle") + }), + ), + ) + }) + + test("multiple ensureRunning callers share the queued run behind shell", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const calls = yield* Ref.make(0) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + return "run" + }) + const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + // Only one execution + expect(yield* Ref.get(calls)).toBe(1) + }), + ), + ) + }) + + test("cancel during shell_then_run cancels both", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + + const sh = yield* runner + .startShell((_signal) => Effect.never.pipe(Effect.as("x"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.state.type).toBe("shell_then_run") + + yield* runner.cancel + expect(runner.busy).toBe(false) + + yield* Fiber.await(sh) + const exit = yield* Fiber.await(run) + expect(Exit.isFailure(exit)).toBe(true) + }), + ), + ) + }) + + // --- lifecycle callbacks --- + + test("onIdle fires when returning to idle from running", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = makeRunner(s, { + onIdle: Ref.update(count, (n) => n + 1), + }) + yield* runner.ensureRunning(Effect.succeed("ok")) + expect(yield* Ref.get(count)).toBe(1) + }), + ), + ) + }) + + test("onIdle fires when cancel returns to idle", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = makeRunner(s, { + onIdle: Ref.update(count, (n) => n + 1), + }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + yield* runner.cancel + yield* Fiber.await(fiber) + // onIdle fires from both the fiber onExit and cancel — at least once + expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1) + }), + ), + ) + }) + + test("onBusy fires when shell starts", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = makeRunner(s, { + onBusy: Ref.update(count, (n) => n + 1), + }) + yield* runner.startShell((_signal) => Effect.succeed("done")) + expect(yield* Ref.get(count)).toBe(1) + }), + ), + ) + }) + + // --- busy flag --- + + test("busy is true during run", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const fiber = yield* runner + .ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(fiber) + expect(runner.busy).toBe(false) + }), + ), + ) + }) + + test("busy is true during shell", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const fiber = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(fiber) + expect(runner.busy).toBe(false) + }), + ), + ) + }) +}) From d267d745cd0abd4c45c519a0adf272e71b5fb293 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 10:35:10 -0400 Subject: [PATCH 40/66] extract Runner to standalone module, integrate into prompt.ts Move the per-session coordination state machine into src/effect/runner.ts as a generic, reusable primitive. Runner.make(scope, opts) creates a runner with: - ensureRunning(work): dedup concurrent callers via shared Deferred - startShell(work): exclusive shell execution with run handoff - cancel: clean state-first cancellation - Lifecycle callbacks: onIdle, onBusy, onInterrupt, busy (error) prompt.ts now creates runners lazily per session via getRunner(), passing session-specific callbacks for status updates and error handling. Removes all inline coordination code. 47 tests passing (22 runner + 20 prompt-effect + 5 concurrency). --- packages/opencode/src/effect/runner.ts | 13 +- packages/opencode/src/session/prompt.ts | 157 +--- packages/opencode/test/effect/runner.test.ts | 833 +++++++++---------- 3 files changed, 394 insertions(+), 609 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 5a1e1148ad7e..254cda79d5dd 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -25,6 +25,7 @@ export const make = (scope: Scope.Scope, opts?: { onIdle?: Effect.Effect onBusy?: Effect.Effect onInterrupt?: Effect.Effect + busy?: () => never }): Runner => { let state: State = { type: "idle" } const idle = opts?.onIdle ?? Effect.void @@ -78,7 +79,10 @@ export const make = (scope: Scope.Scope, opts?: { const startShell = (work: (signal: AbortSignal) => Effect.Effect) => Effect.gen(function* () { - if (state.type !== "idle") throw new Error("Runner is busy") + if (state.type !== "idle") { + if (opts?.busy) opts.busy() + throw new Error("Runner is busy") + } yield* busy const ctrl = new AbortController() const fiber = yield* work(ctrl.signal).pipe( @@ -99,8 +103,7 @@ export const make = (scope: Scope.Scope, opts?: { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt - if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause) - return exit.value + return yield* Effect.failCause(exit.cause) }) const cancel = Effect.gen(function* () { @@ -118,7 +121,7 @@ export const make = (scope: Scope.Scope, opts?: { case "shell": { state = { type: "idle" } st.shell.abort.abort() - yield* Fiber.interrupt(st.shell.fiber).pipe(Effect.asVoid) + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) yield* idle return } @@ -126,7 +129,7 @@ export const make = (scope: Scope.Scope, opts?: { state = { type: "idle" } yield* Deferred.fail(st.done, new Cancelled()).pipe(Effect.asVoid) st.shell.abort.abort() - yield* Fiber.interrupt(st.shell.fiber).pipe(Effect.asVoid) + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) yield* idle return } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e272407dc8b7..ebe7ff39928f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,7 +23,7 @@ import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" -import { Schema } from "effect" +import { Cancelled, make as makeRunner, type Runner } from "@/effect/runner" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -47,7 +47,7 @@ import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -67,27 +67,6 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) - class RunCancelled extends Schema.TaggedErrorClass()("RunCancelled", {}) {} - - interface ShellHandle { - fiber: Fiber.Fiber - abort: AbortController - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type RunnerState = - | { type: "idle" } - | { type: "running"; done: any; fiber: any } - | { type: "shell"; shell: ShellHandle } - | { type: "shell_then_run"; shell: ShellHandle; done: any; work: any } - - interface Runner { - state: RunnerState - ensureRunning: (work: Effect.Effect) => Effect.Effect - startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect - cancel: Effect.Effect - } - export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect @@ -120,7 +99,7 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { - const runners = new Map() + const runners = new Map>() yield* Effect.addFinalizer(() => Effect.gen(function* () { const entries = [...runners.values()] @@ -132,126 +111,18 @@ export namespace SessionPrompt { }), ) - const getRunner = ( - runners: Map, - sessionID: SessionID, - ) => { + const getRunner = (runners: Map>, sessionID: SessionID) => { const existing = runners.get(sessionID) if (existing) return existing - const runner = { - state: { type: "idle" } as RunnerState, - } as Runner - - const cleanup = () => { - if (runner.state.type === "idle") runners.delete(sessionID) - } - - const startRun = (work: Effect.Effect, done: Deferred.Deferred) => - Effect.gen(function* () { - const fiber = yield* work.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - if (runner.state.type === "running") runner.state = { type: "idle" } - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { - yield* Deferred.fail(done, new RunCancelled()) - } else { - yield* Deferred.done(done, exit as any) - } - cleanup() - if (!runners.has(sessionID)) { - yield* status.set(sessionID, { type: "idle" }) - } - }), - ), - Effect.forkIn(scope), - ) - runner.state = { type: "running", done, fiber } - }) - - const ensureRunning = (work: Effect.Effect) => - Effect.gen(function* () { - switch (runner.state.type) { - case "running": - return yield* Deferred.await(runner.state.done) - case "shell_then_run": - return yield* Deferred.await(runner.state.done) - case "shell": { - const done = yield* Deferred.make() - runner.state = { type: "shell_then_run", shell: runner.state.shell, done, work } - return yield* Deferred.await(done) - } - case "idle": { - const done = yield* Deferred.make() - yield* startRun(work, done) - return yield* Deferred.await(done) - } - } - }).pipe(Effect.catch((e) => (e instanceof RunCancelled ? lastAssistant(sessionID) : Effect.fail(e)))) - - const startShell = (work: (signal: AbortSignal) => Effect.Effect) => - Effect.gen(function* () { - if (runner.state.type !== "idle") throw new Session.BusyError(sessionID) - yield* status.set(sessionID, { type: "busy" }) - const ctrl = new AbortController() - const fiber = yield* work(ctrl.signal).pipe( - Effect.ensuring( - Effect.gen(function* () { - if (runner.state.type === "shell_then_run") { - const { done, work: pending } = runner.state - yield* startRun(pending, done) - } else { - runner.state = { type: "idle" } - cleanup() - if (!runners.has(sessionID)) { - yield* status.set(sessionID, { type: "idle" }) - } - } - }), - ), - Effect.forkChild, - ) - runner.state = { type: "shell", shell: { fiber, abort: ctrl } } - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause)) return yield* lastAssistant(sessionID) - return yield* Effect.failCause(exit.cause) - }) - - runner.cancel = Effect.gen(function* () { - const st = runner.state - switch (st.type) { - case "idle": - return - case "running": { - runner.state = { type: "idle" } - yield* Fiber.interrupt(st.fiber) - yield* Deferred.await(st.done).pipe(Effect.exit, Effect.asVoid) - cleanup() - yield* status.set(sessionID, { type: "idle" }) - return - } - case "shell": { - runner.state = { type: "idle" } - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) - cleanup() - yield* status.set(sessionID, { type: "idle" }) - return - } - case "shell_then_run": { - runner.state = { type: "idle" } - yield* Deferred.fail(st.done, new RunCancelled()).pipe(Effect.asVoid) - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) - cleanup() - yield* status.set(sessionID, { type: "idle" }) - return - } - } + const runner = makeRunner(scope, { + onIdle: Effect.gen(function* () { + runners.delete(sessionID) + yield* status.set(sessionID, { type: "idle" }) + }), + onBusy: status.set(sessionID, { type: "busy" }), + onInterrupt: lastAssistant(sessionID), + busy: () => { throw new Session.BusyError(sessionID) }, }) - - runner.ensureRunning = ensureRunning as Runner["ensureRunning"] - runner.startShell = startShell as Runner["startShell"] runners.set(sessionID, runner) return runner } @@ -259,14 +130,14 @@ export namespace SessionPrompt { const assertNotBusy = Effect.fn("SessionPrompt.assertNotBusy")(function* (sessionID: SessionID) { const s = yield* InstanceState.get(cache) const runner = s.runners.get(sessionID) - if (runner && runner.state.type !== "idle") throw new Session.BusyError(sessionID) + if (runner?.busy) throw new Session.BusyError(sessionID) }) const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { log.info("cancel", { sessionID }) const s = yield* InstanceState.get(cache) const runner = s.runners.get(sessionID) - if (!runner || runner.state.type === "idle") { + if (!runner || !runner.busy) { yield* status.set(sessionID, { type: "idle" }) return } diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 84422cb8fb16..089685e5b35c 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,483 +1,394 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { Cancelled, make as makeRunner } from "../../src/effect/runner" +import { it } from "../lib/effect" describe("Runner", () => { // --- ensureRunning semantics --- - test("ensureRunning starts work and returns result", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const result = yield* runner.ensureRunning(Effect.succeed("hello")) - expect(result).toBe("hello") - expect(runner.state.type).toBe("idle") - expect(runner.busy).toBe(false) - }), - ), - ) - }) - - test("ensureRunning propagates work failures", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const exit = yield* runner.ensureRunning(Effect.fail("boom")).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - expect(runner.state.type).toBe("idle") - }), - ), - ) - }) - - test("concurrent callers share the same run", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const calls = yield* Ref.make(0) - const work = Effect.gen(function* () { - yield* Ref.update(calls, (n) => n + 1) - yield* Effect.sleep("10 millis") - return "shared" - }) - - const [a, b] = yield* Effect.all( - [runner.ensureRunning(work), runner.ensureRunning(work)], - { concurrency: "unbounded" }, - ) - - expect(a).toBe("shared") - expect(b).toBe("shared") - expect(yield* Ref.get(calls)).toBe(1) - }), - ), - ) - }) - - test("concurrent callers all receive same error", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const work = Effect.gen(function* () { - yield* Effect.sleep("10 millis") - return yield* Effect.fail("boom") - }) - - const [a, b] = yield* Effect.all( - [runner.ensureRunning(work).pipe(Effect.exit), runner.ensureRunning(work).pipe(Effect.exit)], - { concurrency: "unbounded" }, - ) - - expect(Exit.isFailure(a)).toBe(true) - expect(Exit.isFailure(b)).toBe(true) - }), - ), - ) - }) - - test("ensureRunning can be called again after previous run completes", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const a = yield* runner.ensureRunning(Effect.succeed("first")) - expect(a).toBe("first") - - const b = yield* runner.ensureRunning(Effect.succeed("second")) - expect(b).toBe("second") - }), - ), - ) - }) - - test("second ensureRunning ignores new work if already running", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const ran = yield* Ref.make([]) - - const first = Effect.gen(function* () { - yield* Ref.update(ran, (a) => [...a, "first"]) - yield* Effect.sleep("50 millis") - return "first-result" - }) - const second = Effect.gen(function* () { - yield* Ref.update(ran, (a) => [...a, "second"]) - return "second-result" - }) - - const [a, b] = yield* Effect.all( - [runner.ensureRunning(first), runner.ensureRunning(second)], - { concurrency: "unbounded" }, - ) - - // Both get the first run's result — second work is never started - expect(a).toBe("first-result") - expect(b).toBe("first-result") - expect(yield* Ref.get(ran)).toEqual(["first"]) - }), - ), - ) - }) + it.effect("ensureRunning starts work and returns result", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const result = yield* runner.ensureRunning(Effect.succeed("hello")) + expect(result).toBe("hello") + expect(runner.state.type).toBe("idle") + expect(runner.busy).toBe(false) + }), + ) + + it.effect("ensureRunning propagates work failures", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const exit = yield* runner.ensureRunning(Effect.fail("boom")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + expect(runner.state.type).toBe("idle") + }), + ) + + it.effect("concurrent callers share the same run", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const calls = yield* Ref.make(0) + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + yield* Effect.sleep("10 millis") + return "shared" + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(work), runner.ensureRunning(work)], + { concurrency: "unbounded" }, + ) + + expect(a).toBe("shared") + expect(b).toBe("shared") + expect(yield* Ref.get(calls)).toBe(1) + }), + ) + + it.effect("concurrent callers all receive same error", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const work = Effect.gen(function* () { + yield* Effect.sleep("10 millis") + return yield* Effect.fail("boom") + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(work).pipe(Effect.exit), runner.ensureRunning(work).pipe(Effect.exit)], + { concurrency: "unbounded" }, + ) + + expect(Exit.isFailure(a)).toBe(true) + expect(Exit.isFailure(b)).toBe(true) + }), + ) + + it.effect("ensureRunning can be called again after previous run completes", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + expect(yield* runner.ensureRunning(Effect.succeed("first"))).toBe("first") + expect(yield* runner.ensureRunning(Effect.succeed("second"))).toBe("second") + }), + ) + + it.effect("second ensureRunning ignores new work if already running", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const ran = yield* Ref.make([]) + + const first = Effect.gen(function* () { + yield* Ref.update(ran, (a) => [...a, "first"]) + yield* Effect.sleep("50 millis") + return "first-result" + }) + const second = Effect.gen(function* () { + yield* Ref.update(ran, (a) => [...a, "second"]) + return "second-result" + }) + + const [a, b] = yield* Effect.all( + [runner.ensureRunning(first), runner.ensureRunning(second)], + { concurrency: "unbounded" }, + ) + + expect(a).toBe("first-result") + expect(b).toBe("first-result") + expect(yield* Ref.get(ran)).toEqual(["first"]) + }), + ) // --- cancel semantics --- - test("cancel interrupts running work", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(runner.busy).toBe(true) - expect(runner.state.type).toBe("running") - - yield* runner.cancel - expect(runner.busy).toBe(false) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isFailure(exit)).toBe(true) - }), - ), - ) - }) - - test("cancel on idle is a no-op", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - yield* runner.cancel - expect(runner.busy).toBe(false) - }), - ), - ) - }) - - test("cancel with onInterrupt resolves callers gracefully", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - yield* runner.cancel - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) expect(exit.value).toBe("fallback") - }), - ), - ) - }) - - test("cancel with queued callers resolves all", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) - - const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - yield* runner.cancel - - const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(exitA)).toBe(true) - expect(Exit.isSuccess(exitB)).toBe(true) - if (Exit.isSuccess(exitA)) expect(exitA.value).toBe("fallback") - if (Exit.isSuccess(exitB)) expect(exitB.value).toBe("fallback") - }), - ), - ) - }) - - test("work can be started after cancel", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - yield* runner.cancel - yield* Fiber.await(fiber) - - // Should be able to start fresh - const result = yield* runner.ensureRunning(Effect.succeed("after-cancel")) - expect(result).toBe("after-cancel") - }), - ), - ) - }) + it.effect("cancel interrupts running work", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(true) + expect(runner.state.type).toBe("running") + + yield* runner.cancel + expect(runner.busy).toBe(false) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.effect("cancel on idle is a no-op", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + yield* runner.cancel + expect(runner.busy).toBe(false) + }), + ) + + it.effect("cancel with onInterrupt resolves callers gracefully", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("fallback") + }), + ) + + it.effect("cancel with queued callers resolves all", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) + + const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + const b = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA)) expect(exitA.value).toBe("fallback") + if (Exit.isSuccess(exitB)) expect(exitB.value).toBe("fallback") + }), + ) + + it.effect("work can be started after cancel", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + yield* runner.cancel + yield* Fiber.await(fiber) + + const result = yield* runner.ensureRunning(Effect.succeed("after-cancel")) + expect(result).toBe("after-cancel") + }), + ) // --- shell semantics --- - test("shell runs exclusively", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) - expect(result).toBe("shell-done") - expect(runner.busy).toBe(false) - }), - ), - ) - }) - - test("shell rejects when run is active", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - - yield* runner.cancel - yield* Fiber.await(fiber) - }), - ), - ) - }) - - test("shell rejects when another shell is running", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const gate = yield* Deferred.make() - - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) - .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - - yield* Deferred.succeed(gate, undefined) - yield* Fiber.await(sh) - }), - ), - ) - }) + it.effect("shell runs exclusively", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) + expect(result).toBe("shell-done") + expect(runner.busy).toBe(false) + }), + ) + + it.effect("shell rejects when run is active", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* runner.cancel + yield* Fiber.await(fiber) + }), + ) + + it.effect("shell rejects when another shell is running", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + }), + ) // --- shell→run handoff --- - test("ensureRunning queues behind shell then runs after", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const gate = yield* Deferred.make() - - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) - .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(runner.state.type).toBe("shell") - - const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(runner.state.type).toBe("shell_then_run") - - yield* Deferred.succeed(gate, undefined) - yield* Fiber.await(sh) - - const exit = yield* Fiber.await(run) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) expect(exit.value).toBe("run-result") - expect(runner.state.type).toBe("idle") - }), - ), - ) - }) - - test("multiple ensureRunning callers share the queued run behind shell", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const calls = yield* Ref.make(0) - const gate = yield* Deferred.make() - - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) - .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - const work = Effect.gen(function* () { - yield* Ref.update(calls, (n) => n + 1) - return "run" - }) - const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild) - const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - yield* Deferred.succeed(gate, undefined) - yield* Fiber.await(sh) - - const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(exitA)).toBe(true) - expect(Exit.isSuccess(exitB)).toBe(true) - // Only one execution - expect(yield* Ref.get(calls)).toBe(1) - }), - ), - ) - }) - - test("cancel during shell_then_run cancels both", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - - const sh = yield* runner - .startShell((_signal) => Effect.never.pipe(Effect.as("x"))) - .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(runner.state.type).toBe("shell_then_run") - - yield* runner.cancel - expect(runner.busy).toBe(false) - - yield* Fiber.await(sh) - const exit = yield* Fiber.await(run) - expect(Exit.isFailure(exit)).toBe(true) - }), - ), - ) - }) + it.effect("ensureRunning queues behind shell then runs after", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.state.type).toBe("shell") + + const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.state.type).toBe("shell_then_run") + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("run-result") + expect(runner.state.type).toBe("idle") + }), + ) + + it.effect("multiple ensureRunning callers share the queued run behind shell", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const calls = yield* Ref.make(0) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const work = Effect.gen(function* () { + yield* Ref.update(calls, (n) => n + 1) + return "run" + }) + const a = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + const b = yield* runner.ensureRunning(work).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(sh) + + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + expect(yield* Ref.get(calls)).toBe(1) + }), + ) + + it.effect("cancel during shell_then_run cancels both", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((signal) => + Effect.promise(() => new Promise((resolve) => { + signal.addEventListener("abort", () => resolve("aborted"), { once: true }) + })), + ) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.state.type).toBe("shell_then_run") + + yield* runner.cancel + expect(runner.busy).toBe(false) + + yield* Fiber.await(sh) + const exit = yield* Fiber.await(run) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) // --- lifecycle callbacks --- - test("onIdle fires when returning to idle from running", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const count = yield* Ref.make(0) - const runner = makeRunner(s, { - onIdle: Ref.update(count, (n) => n + 1), - }) - yield* runner.ensureRunning(Effect.succeed("ok")) - expect(yield* Ref.get(count)).toBe(1) - }), - ), - ) - }) - - test("onIdle fires when cancel returns to idle", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const count = yield* Ref.make(0) - const runner = makeRunner(s, { - onIdle: Ref.update(count, (n) => n + 1), - }) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - yield* runner.cancel - yield* Fiber.await(fiber) - // onIdle fires from both the fiber onExit and cancel — at least once - expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1) - }), - ), - ) - }) - - test("onBusy fires when shell starts", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const count = yield* Ref.make(0) - const runner = makeRunner(s, { - onBusy: Ref.update(count, (n) => n + 1), - }) - yield* runner.startShell((_signal) => Effect.succeed("done")) - expect(yield* Ref.get(count)).toBe(1) - }), - ), - ) - }) + it.effect("onIdle fires when returning to idle from running", + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = makeRunner(s, { + onIdle: Ref.update(count, (n) => n + 1), + }) + yield* runner.ensureRunning(Effect.succeed("ok")) + expect(yield* Ref.get(count)).toBe(1) + }), + ) + + it.effect("onIdle fires on cancel", + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = makeRunner(s, { + onIdle: Ref.update(count, (n) => n + 1), + }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + yield* runner.cancel + yield* Fiber.await(fiber) + expect(yield* Ref.get(count)).toBeGreaterThanOrEqual(1) + }), + ) + + it.effect("onBusy fires when shell starts", + Effect.gen(function* () { + const s = yield* Scope.Scope + const count = yield* Ref.make(0) + const runner = makeRunner(s, { + onBusy: Ref.update(count, (n) => n + 1), + }) + yield* runner.startShell((_signal) => Effect.succeed("done")) + expect(yield* Ref.get(count)).toBe(1) + }), + ) // --- busy flag --- - test("busy is true during run", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const gate = yield* Deferred.make() - - const fiber = yield* runner - .ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(runner.busy).toBe(true) - - yield* Deferred.succeed(gate, undefined) - yield* Fiber.await(fiber) - expect(runner.busy).toBe(false) - }), - ), - ) - }) - - test("busy is true during shell", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const runner = makeRunner(s) - const gate = yield* Deferred.make() - - const fiber = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(runner.busy).toBe(true) - - yield* Deferred.succeed(gate, undefined) - yield* Fiber.await(fiber) - expect(runner.busy).toBe(false) - }), - ), - ) - }) + it.effect("busy is true during run", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const fiber = yield* runner + .ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(fiber) + expect(runner.busy).toBe(false) + }), + ) + + it.effect("busy is true during shell", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = makeRunner(s) + const gate = yield* Deferred.make() + + const fiber = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.await(fiber) + expect(runner.busy).toBe(false) + }), + ) }) From 2d50cd9e07fb127b2976546c510eae4a73d55570 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 10:37:24 -0400 Subject: [PATCH 41/66] remove old session-runner prototype test --- .../test/session/prompt-effect.test.ts | 169 ++++++++++-------- 1 file changed, 98 insertions(+), 71 deletions(-) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index e38bfc29825b..2bfc9636b6ef 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -23,6 +23,7 @@ import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -147,6 +148,24 @@ function waitMs(ms: number) { return Effect.promise(() => new Promise((done) => setTimeout(done, ms))) } +function withSh(fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env.SHELL + process.env.SHELL = "/bin/sh" + Shell.preferred.reset() + return prev + }), + () => fx(), + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.preferred.reset() + }), + ) +} + function toolPart(parts: MessageV2.Part[]) { return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") } @@ -807,31 +826,33 @@ it.effect("shell captures stdout and stderr in completed tool output", () => it.effect( "shell updates running metadata before process exit", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const fiber = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" }) - .pipe(Effect.forkChild) - - yield* Effect.promise(async () => { - const start = Date.now() - while (Date.now() - start < 2000) { - const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) - const taskMsg = msgs.find((item) => item.info.role === "assistant") - const tool = taskMsg ? toolPart(taskMsg.parts) : undefined - if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running shell metadata") - }) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - }), - { git: true, config: cfg }, + withSh(() => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const fiber = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const start = Date.now() + while (Date.now() - start < 5000) { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running shell metadata") + }) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { git: true, config: cfg }, + ), ), 30_000, ) @@ -909,34 +930,36 @@ it.effect( it.effect( "cancel interrupts shell and resolves cleanly", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* waitMs(50) - - yield* prompt.cancel(chat.id) - - const status = yield* SessionStatus.Service - expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isSuccess(busy)).toBe(true) - - const exit = yield* Fiber.await(sh) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - const tool = completedTool(exit.value.parts) - if (tool) { - expect(tool.state.output).toContain("User aborted the command") + withSh(() => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* waitMs(50) + + yield* prompt.cancel(chat.id) + + const status = yield* SessionStatus.Service + expect((yield* status.get(chat.id)).type).toBe("idle") + const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(busy)).toBe(true) + + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") + } } - } - }), - { git: true, config: cfg }, + }), + { git: true, config: cfg }, + ), ), 30_000, ) @@ -972,26 +995,30 @@ it.effect( it.effect( "shell rejects when another shell is already running", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const a = yield* prompt - .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* waitMs(50) - - const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } + withSh(() => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const a = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* waitMs(50) + + const exit = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "echo hi" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } - yield* prompt.cancel(chat.id) - yield* Fiber.await(a) - }), - { git: true, config: cfg }, + yield* prompt.cancel(chat.id) + yield* Fiber.await(a) + }), + { git: true, config: cfg }, + ), ), 30_000, ) From 90c4c4a3f4484e94d07d223da8b2f9078d809a9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 10:47:24 -0400 Subject: [PATCH 42/66] refine Runner API: namespace pattern, _tag states, generic E type - Move Runner interface outside namespace (avoid Runner.Runner) - Use _tag instead of type for state discrimination (Effect convention) - Add generic E parameter (default never) instead of hardcoded unknown - Rename tag string to RunnerCancelled (codebase convention, no dots) - Remove unnecessary type casts --- packages/opencode/src/effect/runner.ts | 260 ++++++++++--------- packages/opencode/src/session/prompt.ts | 8 +- packages/opencode/test/effect/runner.test.ts | 60 ++--- 3 files changed, 165 insertions(+), 163 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 254cda79d5dd..8929f5ac4862 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,146 +1,148 @@ import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect" -export class Cancelled extends Schema.TaggedErrorClass()("Runner.Cancelled", {}) {} - -interface ShellHandle { - fiber: Fiber.Fiber - abort: AbortController -} - -type State = - | { type: "idle" } - | { type: "running"; done: Deferred.Deferred; fiber: Fiber.Fiber } - | { type: "shell"; shell: ShellHandle } - | { type: "shell_then_run"; shell: ShellHandle; done: Deferred.Deferred; work: Effect.Effect } - -export interface Runner { - readonly state: State +export interface Runner { + readonly state: Runner.State readonly busy: boolean - readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly ensureRunning: (work: Effect.Effect) => Effect.Effect + readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } -export const make = (scope: Scope.Scope, opts?: { - onIdle?: Effect.Effect - onBusy?: Effect.Effect - onInterrupt?: Effect.Effect - busy?: () => never -}): Runner => { - let state: State = { type: "idle" } - const idle = opts?.onIdle ?? Effect.void - const busy = opts?.onBusy ?? Effect.void - const onInterrupt = opts?.onInterrupt +export namespace Runner { + export class Cancelled extends Schema.TaggedErrorClass()("RunnerCancelled", {}) {} - const startRun = (work: Effect.Effect, done: Deferred.Deferred) => - Effect.gen(function* () { - const fiber = yield* work.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - if (state.type === "running") { - state = { type: "idle" } - yield* idle - } - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { - yield* Deferred.fail(done, new Cancelled()) - } else { - yield* Deferred.done(done, exit as Exit.Exit) - } - }), - ), - Effect.forkIn(scope), - ) - state = { type: "running", done, fiber } - }) + interface ShellHandle { + fiber: Fiber.Fiber + abort: AbortController + } + + export type State = + | { readonly _tag: "Idle" } + | { readonly _tag: "Running"; readonly done: Deferred.Deferred; readonly fiber: Fiber.Fiber } + | { readonly _tag: "Shell"; readonly shell: ShellHandle } + | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle; readonly done: Deferred.Deferred; readonly work: Effect.Effect } + + export const make = (scope: Scope.Scope, opts?: { + onIdle?: Effect.Effect + onBusy?: Effect.Effect + onInterrupt?: Effect.Effect + busy?: () => never + }): Runner => { + let state: State = { _tag: "Idle" } + const idle = opts?.onIdle ?? Effect.void + const busy = opts?.onBusy ?? Effect.void + const onInterrupt = opts?.onInterrupt - const ensureRunning = (work: Effect.Effect) => - Effect.gen(function* () { - switch (state.type) { - case "running": - return yield* Deferred.await(state.done) - case "shell_then_run": - return yield* Deferred.await(state.done) - case "shell": { - const done = yield* Deferred.make() - state = { type: "shell_then_run", shell: state.shell, done, work } - return yield* Deferred.await(done) + const startRun = (work: Effect.Effect, done: Deferred.Deferred) => + Effect.gen(function* () { + const fiber = yield* work.pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + if (state._tag === "Running") { + state = { _tag: "Idle" } + yield* idle + } + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + yield* Deferred.fail(done, new Cancelled()) + } else { + yield* Deferred.done(done, exit) + } + }), + ), + Effect.forkIn(scope), + ) + state = { _tag: "Running", done, fiber } + }) + + const ensureRunning = (work: Effect.Effect): Effect.Effect => + Effect.gen(function* () { + switch (state._tag) { + case "Running": + return yield* Deferred.await(state.done) + case "ShellThenRun": + return yield* Deferred.await(state.done) + case "Shell": { + const done = yield* Deferred.make() + state = { _tag: "ShellThenRun", shell: state.shell, done, work } + return yield* Deferred.await(done) + } + case "Idle": { + const done = yield* Deferred.make() + yield* startRun(work, done) + return yield* Deferred.await(done) + } } - case "idle": { - const done = yield* Deferred.make() - yield* startRun(work, done) - return yield* Deferred.await(done) + }).pipe( + Effect.catch((e) => + e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e), + ), + ) as Effect.Effect + + const startShell = (work: (signal: AbortSignal) => Effect.Effect): Effect.Effect => + Effect.gen(function* () { + if (state._tag !== "Idle") { + if (opts?.busy) opts.busy() + throw new Error("Runner is busy") } - } - }).pipe( - Effect.catch((e) => - e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e), - ), - ) + yield* busy + const ctrl = new AbortController() + const fiber = yield* work(ctrl.signal).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (state._tag === "ShellThenRun") { + const { done, work: pending } = state + yield* startRun(pending, done) + } else { + state = { _tag: "Idle" } + yield* idle + } + }), + ), + Effect.forkChild, + ) + state = { _tag: "Shell", shell: { fiber, abort: ctrl } } + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + return yield* Effect.failCause(exit.cause) + }) as Effect.Effect - const startShell = (work: (signal: AbortSignal) => Effect.Effect) => - Effect.gen(function* () { - if (state.type !== "idle") { - if (opts?.busy) opts.busy() - throw new Error("Runner is busy") + const cancel = Effect.gen(function* () { + const st = state + switch (st._tag) { + case "Idle": + return + case "Running": { + state = { _tag: "Idle" } + yield* Fiber.interrupt(st.fiber) + yield* Deferred.await(st.done).pipe(Effect.exit, Effect.asVoid) + yield* idle + return + } + case "Shell": { + state = { _tag: "Idle" } + st.shell.abort.abort() + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + yield* idle + return + } + case "ShellThenRun": { + state = { _tag: "Idle" } + yield* Deferred.fail(st.done, new Cancelled()).pipe(Effect.asVoid) + st.shell.abort.abort() + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + yield* idle + return + } } - yield* busy - const ctrl = new AbortController() - const fiber = yield* work(ctrl.signal).pipe( - Effect.ensuring( - Effect.gen(function* () { - if (state.type === "shell_then_run") { - const { done, work: pending } = state - yield* startRun(pending, done) - } else { - state = { type: "idle" } - if (state.type === "idle") yield* idle - } - }), - ), - Effect.forkChild, - ) - state = { type: "shell", shell: { fiber, abort: ctrl } } - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt - return yield* Effect.failCause(exit.cause) }) - const cancel = Effect.gen(function* () { - const st = state - switch (st.type) { - case "idle": - return - case "running": { - state = { type: "idle" } - yield* Fiber.interrupt(st.fiber) - yield* Deferred.await(st.done).pipe(Effect.exit, Effect.asVoid) - yield* idle - return - } - case "shell": { - state = { type: "idle" } - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) - yield* idle - return - } - case "shell_then_run": { - state = { type: "idle" } - yield* Deferred.fail(st.done, new Cancelled()).pipe(Effect.asVoid) - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) - yield* idle - return - } + return { + get state() { return state }, + get busy() { return state._tag !== "Idle" }, + ensureRunning, + startShell, + cancel, } - }) - - return { - get state() { return state }, - get busy() { return state.type !== "idle" }, - ensureRunning, - startShell, - cancel, } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ebe7ff39928f..cbfc00ea6890 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -23,7 +23,7 @@ import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" -import { Cancelled, make as makeRunner, type Runner } from "@/effect/runner" +import { Runner } from "@/effect/runner" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" @@ -99,7 +99,7 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { - const runners = new Map>() + const runners = new Map>() yield* Effect.addFinalizer(() => Effect.gen(function* () { const entries = [...runners.values()] @@ -111,10 +111,10 @@ export namespace SessionPrompt { }), ) - const getRunner = (runners: Map>, sessionID: SessionID) => { + const getRunner = (runners: Map>, sessionID: SessionID) => { const existing = runners.get(sessionID) if (existing) return existing - const runner = makeRunner(scope, { + const runner = Runner.make(scope, { onIdle: Effect.gen(function* () { runners.delete(sessionID) yield* status.set(sessionID, { type: "idle" }) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 089685e5b35c..01265f796280 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" -import { Cancelled, make as makeRunner } from "../../src/effect/runner" +import { Runner } from "../../src/effect/runner" import { it } from "../lib/effect" describe("Runner", () => { @@ -9,10 +9,10 @@ describe("Runner", () => { it.effect("ensureRunning starts work and returns result", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const result = yield* runner.ensureRunning(Effect.succeed("hello")) expect(result).toBe("hello") - expect(runner.state.type).toBe("idle") + expect(runner.state._tag).toBe("Idle") expect(runner.busy).toBe(false) }), ) @@ -20,17 +20,17 @@ describe("Runner", () => { it.effect("ensureRunning propagates work failures", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const exit = yield* runner.ensureRunning(Effect.fail("boom")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) - expect(runner.state.type).toBe("idle") + expect(runner.state._tag).toBe("Idle") }), ) it.effect("concurrent callers share the same run", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const calls = yield* Ref.make(0) const work = Effect.gen(function* () { yield* Ref.update(calls, (n) => n + 1) @@ -52,7 +52,7 @@ describe("Runner", () => { it.effect("concurrent callers all receive same error", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const work = Effect.gen(function* () { yield* Effect.sleep("10 millis") return yield* Effect.fail("boom") @@ -71,7 +71,7 @@ describe("Runner", () => { it.effect("ensureRunning can be called again after previous run completes", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) expect(yield* runner.ensureRunning(Effect.succeed("first"))).toBe("first") expect(yield* runner.ensureRunning(Effect.succeed("second"))).toBe("second") }), @@ -80,7 +80,7 @@ describe("Runner", () => { it.effect("second ensureRunning ignores new work if already running", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const ran = yield* Ref.make([]) const first = Effect.gen(function* () { @@ -109,11 +109,11 @@ describe("Runner", () => { it.effect("cancel interrupts running work", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.busy).toBe(true) - expect(runner.state.type).toBe("running") + expect(runner.state._tag).toBe("Running") yield* runner.cancel expect(runner.busy).toBe(false) @@ -126,7 +126,7 @@ describe("Runner", () => { it.effect("cancel on idle is a no-op", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) yield* runner.cancel expect(runner.busy).toBe(false) }), @@ -135,7 +135,7 @@ describe("Runner", () => { it.effect("cancel with onInterrupt resolves callers gracefully", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) + const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") @@ -150,7 +150,7 @@ describe("Runner", () => { it.effect("cancel with queued callers resolves all", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s, { onInterrupt: Effect.succeed("fallback") }) + const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) const a = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") @@ -170,7 +170,7 @@ describe("Runner", () => { it.effect("work can be started after cancel", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") yield* runner.cancel @@ -186,7 +186,7 @@ describe("Runner", () => { it.effect("shell runs exclusively", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) expect(result).toBe("shell-done") expect(runner.busy).toBe(false) @@ -196,7 +196,7 @@ describe("Runner", () => { it.effect("shell rejects when run is active", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") @@ -211,7 +211,7 @@ describe("Runner", () => { it.effect("shell rejects when another shell is running", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const gate = yield* Deferred.make() const sh = yield* runner @@ -232,18 +232,18 @@ describe("Runner", () => { it.effect("ensureRunning queues behind shell then runs after", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const gate = yield* Deferred.make() const sh = yield* runner .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) .pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - expect(runner.state.type).toBe("shell") + expect(runner.state._tag).toBe("Shell") const run = yield* runner.ensureRunning(Effect.succeed("run-result")).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - expect(runner.state.type).toBe("shell_then_run") + expect(runner.state._tag).toBe("ShellThenRun") yield* Deferred.succeed(gate, undefined) yield* Fiber.await(sh) @@ -251,14 +251,14 @@ describe("Runner", () => { const exit = yield* Fiber.await(run) expect(Exit.isSuccess(exit)).toBe(true) if (Exit.isSuccess(exit)) expect(exit.value).toBe("run-result") - expect(runner.state.type).toBe("idle") + expect(runner.state._tag).toBe("Idle") }), ) it.effect("multiple ensureRunning callers share the queued run behind shell", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const calls = yield* Ref.make(0) const gate = yield* Deferred.make() @@ -288,7 +288,7 @@ describe("Runner", () => { it.effect("cancel during shell_then_run cancels both", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const gate = yield* Deferred.make() const sh = yield* runner @@ -302,7 +302,7 @@ describe("Runner", () => { const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - expect(runner.state.type).toBe("shell_then_run") + expect(runner.state._tag).toBe("ShellThenRun") yield* runner.cancel expect(runner.busy).toBe(false) @@ -319,7 +319,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const count = yield* Ref.make(0) - const runner = makeRunner(s, { + const runner = Runner.make(s, { onIdle: Ref.update(count, (n) => n + 1), }) yield* runner.ensureRunning(Effect.succeed("ok")) @@ -331,7 +331,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const count = yield* Ref.make(0) - const runner = makeRunner(s, { + const runner = Runner.make(s, { onIdle: Ref.update(count, (n) => n + 1), }) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) @@ -346,7 +346,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const count = yield* Ref.make(0) - const runner = makeRunner(s, { + const runner = Runner.make(s, { onBusy: Ref.update(count, (n) => n + 1), }) yield* runner.startShell((_signal) => Effect.succeed("done")) @@ -359,7 +359,7 @@ describe("Runner", () => { it.effect("busy is true during run", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const gate = yield* Deferred.make() const fiber = yield* runner @@ -377,7 +377,7 @@ describe("Runner", () => { it.effect("busy is true during shell", Effect.gen(function* () { const s = yield* Scope.Scope - const runner = makeRunner(s) + const runner = Runner.make(s) const gate = yield* Deferred.make() const fiber = yield* runner From 3d2e3878fd5e59221b615ee6ded7d9f990b48bf1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 10:52:39 -0400 Subject: [PATCH 43/66] make Runner error type honest, remove casts ensureRunning/startShell now return Effect instead of hiding Cancelled behind a cast. Callers handle Cancelled explicitly. Use Effect.catch instead of catchTag since onInterrupt widens the error channel. --- packages/opencode/src/effect/runner.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 8929f5ac4862..166ab1185892 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -3,8 +3,8 @@ import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect" export interface Runner { readonly state: Runner.State readonly busy: boolean - readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly ensureRunning: (work: Effect.Effect) => Effect.Effect + readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -54,7 +54,7 @@ export namespace Runner { state = { _tag: "Running", done, fiber } }) - const ensureRunning = (work: Effect.Effect): Effect.Effect => + const ensureRunning = (work: Effect.Effect) => Effect.gen(function* () { switch (state._tag) { case "Running": @@ -76,9 +76,9 @@ export namespace Runner { Effect.catch((e) => e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e), ), - ) as Effect.Effect + ) - const startShell = (work: (signal: AbortSignal) => Effect.Effect): Effect.Effect => + const startShell = (work: (signal: AbortSignal) => Effect.Effect) => Effect.gen(function* () { if (state._tag !== "Idle") { if (opts?.busy) opts.busy() @@ -105,7 +105,7 @@ export namespace Runner { if (Exit.isSuccess(exit)) return exit.value if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt return yield* Effect.failCause(exit.cause) - }) as Effect.Effect + }) const cancel = Effect.gen(function* () { const st = state From bc7adfe19685dfe30f6bf2cfef4b8d3f939bfd74 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 11:44:48 -0400 Subject: [PATCH 44/66] fix runner cancellation and session abort handling --- packages/opencode/src/effect/runner.ts | 254 +++++++++++------- packages/opencode/src/session/processor.ts | 112 ++++---- packages/opencode/src/session/prompt.ts | 17 +- packages/opencode/test/effect/runner.test.ts | 124 ++++++--- .../opencode/test/session/compaction.test.ts | 85 ++++++ .../test/session/processor-effect.test.ts | 64 +++++ 6 files changed, 463 insertions(+), 193 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 166ab1185892..b921b860569e 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State @@ -11,135 +11,195 @@ export interface Runner { export namespace Runner { export class Cancelled extends Schema.TaggedErrorClass()("RunnerCancelled", {}) {} + interface RunHandle { + id: number + done: Deferred.Deferred + fiber: Fiber.Fiber + } + interface ShellHandle { + id: number fiber: Fiber.Fiber abort: AbortController } + interface PendingHandle { + id: number + done: Deferred.Deferred + work: Effect.Effect + } + export type State = | { readonly _tag: "Idle" } - | { readonly _tag: "Running"; readonly done: Deferred.Deferred; readonly fiber: Fiber.Fiber } + | { readonly _tag: "Running"; readonly run: RunHandle } | { readonly _tag: "Shell"; readonly shell: ShellHandle } - | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle; readonly done: Deferred.Deferred; readonly work: Effect.Effect } - - export const make = (scope: Scope.Scope, opts?: { - onIdle?: Effect.Effect - onBusy?: Effect.Effect - onInterrupt?: Effect.Effect - busy?: () => never - }): Runner => { - let state: State = { _tag: "Idle" } + | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle; readonly run: PendingHandle } + + export const make = ( + scope: Scope.Scope, + opts?: { + onIdle?: Effect.Effect + onBusy?: Effect.Effect + onInterrupt?: Effect.Effect + busy?: () => never + }, + ): Runner => { + const ref = SynchronizedRef.makeUnsafe>({ _tag: "Idle" }) const idle = opts?.onIdle ?? Effect.void const busy = opts?.onBusy ?? Effect.void const onInterrupt = opts?.onInterrupt + let ids = 0 + + const state = () => SynchronizedRef.getUnsafe(ref) + const next = () => { + ids += 1 + return ids + } + + const complete = (done: Deferred.Deferred, exit: Exit.Exit) => + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid) + : Deferred.done(done, exit).pipe(Effect.asVoid) + + const idleIfCurrent = () => + SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) + + const finishRun = (id: number, done: Deferred.Deferred, exit: Exit.Exit) => + SynchronizedRef.modify( + ref, + (st) => + [ + Effect.gen(function* () { + if (st._tag === "Running" && st.run.id === id) yield* idle + yield* complete(done, exit) + }), + st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st, + ] as const, + ).pipe(Effect.flatten) const startRun = (work: Effect.Effect, done: Deferred.Deferred) => Effect.gen(function* () { + const id = next() const fiber = yield* work.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - if (state._tag === "Running") { - state = { _tag: "Idle" } - yield* idle - } - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { - yield* Deferred.fail(done, new Cancelled()) - } else { - yield* Deferred.done(done, exit) - } - }), - ), + Effect.onExit((exit) => finishRun(id, done, exit)), Effect.forkIn(scope), ) - state = { _tag: "Running", done, fiber } + return { id, done, fiber } satisfies RunHandle }) - const ensureRunning = (work: Effect.Effect) => - Effect.gen(function* () { - switch (state._tag) { - case "Running": - return yield* Deferred.await(state.done) - case "ShellThenRun": - return yield* Deferred.await(state.done) - case "Shell": { - const done = yield* Deferred.make() - state = { _tag: "ShellThenRun", shell: state.shell, done, work } - return yield* Deferred.await(done) + const finishShell = (id: number) => + SynchronizedRef.modifyEffect(ref, (st) => { + return Effect.gen(function* () { + if (st._tag === "Shell" && st.shell.id === id) { + return [idle, { _tag: "Idle" } as const] as const } - case "Idle": { - const done = yield* Deferred.make() - yield* startRun(work, done) - return yield* Deferred.await(done) + if (st._tag === "ShellThenRun" && st.shell.id === id) { + const run = yield* startRun(st.run.work, st.run.done) + return [Effect.void, { _tag: "Running", run } as const] as const + } + return [Effect.void, st] as const + }) + }).pipe(Effect.flatten) + + const ensureRunning = (work: Effect.Effect) => + SynchronizedRef.modifyEffect(ref, (st) => { + return Effect.gen(function* () { + switch (st._tag) { + case "Running": + return [Deferred.await(st.run.done), st] as const + case "ShellThenRun": + return [Deferred.await(st.run.done), st] as const + case "Shell": { + const run = { + id: next(), + done: yield* Deferred.make(), + work, + } satisfies PendingHandle + return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run } as const] as const + } + case "Idle": { + const done = yield* Deferred.make() + const run = yield* startRun(work, done) + return [Deferred.await(done), { _tag: "Running", run } as const] as const + } } - } + }) }).pipe( - Effect.catch((e) => - e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e), - ), + Effect.flatten, + Effect.catch((e) => (e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e))), ) const startShell = (work: (signal: AbortSignal) => Effect.Effect) => - Effect.gen(function* () { - if (state._tag !== "Idle") { - if (opts?.busy) opts.busy() - throw new Error("Runner is busy") - } - yield* busy - const ctrl = new AbortController() - const fiber = yield* work(ctrl.signal).pipe( - Effect.ensuring( + SynchronizedRef.modifyEffect(ref, (st) => { + return Effect.gen(function* () { + if (st._tag !== "Idle") { + return [ + Effect.sync(() => { + if (opts?.busy) opts.busy() + throw new Error("Runner is busy") + }), + st, + ] as const + } + yield* busy + const id = next() + const abort = new AbortController() + const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, fiber, abort } satisfies ShellHandle + return [ Effect.gen(function* () { - if (state._tag === "ShellThenRun") { - const { done, work: pending } = state - yield* startRun(pending, done) - } else { - state = { _tag: "Idle" } - yield* idle - } + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + return yield* Effect.failCause(exit.cause) }), - ), - Effect.forkChild, - ) - state = { _tag: "Shell", shell: { fiber, abort: ctrl } } - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt - return yield* Effect.failCause(exit.cause) - }) + { _tag: "Shell", shell } as const, + ] as const + }) + }).pipe(Effect.flatten) - const cancel = Effect.gen(function* () { - const st = state + const cancel = SynchronizedRef.modify(ref, (st) => { switch (st._tag) { case "Idle": - return - case "Running": { - state = { _tag: "Idle" } - yield* Fiber.interrupt(st.fiber) - yield* Deferred.await(st.done).pipe(Effect.exit, Effect.asVoid) - yield* idle - return - } - case "Shell": { - state = { _tag: "Idle" } - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) - yield* idle - return - } - case "ShellThenRun": { - state = { _tag: "Idle" } - yield* Deferred.fail(st.done, new Cancelled()).pipe(Effect.asVoid) - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) - yield* idle - return - } + return [Effect.void, st] as const + case "Running": + return [ + Effect.gen(function* () { + yield* Fiber.interrupt(st.run.fiber) + yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "Shell": + return [ + Effect.gen(function* () { + st.shell.abort.abort() + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "ShellThenRun": + return [ + Effect.gen(function* () { + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + st.shell.abort.abort() + yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const } - }) + }).pipe(Effect.flatten) return { - get state() { return state }, - get busy() { return state._tag !== "Idle" }, + get state() { + return state() + }, + get busy() { + return state()._tag !== "Idle" + }, ensureRunning, startShell, cancel, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6e2684f8d4e2..1b9318826594 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -147,6 +147,9 @@ export namespace SessionProcessor { return case "tool-input-start": + if (ctx.assistantMessage.summary) { + throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) + } ctx.toolcalls[value.id] = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -165,6 +168,9 @@ export namespace SessionProcessor { return case "tool-call": { + if (ctx.assistantMessage.summary) { + throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) + } const match = ctx.toolcalls[value.toolCallId] if (!match) return ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ @@ -421,58 +427,6 @@ export namespace SessionProcessor { yield* status.set(ctx.sessionID, { type: "idle" }) }) - const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { - log.info("process") - ctx.needsCompaction = false - ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true - - yield* Effect.gen(function* () { - ctx.currentText = undefined - ctx.reasoningMap = {} - const stream = llm.stream(streamInput) - - yield* stream.pipe( - Stream.tap((event) => handleEvent(event)), - Stream.takeUntil(() => ctx.needsCompaction), - Stream.runDrain, - ) - }).pipe( - Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))), - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => Effect.fail(Cause.squash(cause)), - ), - Effect.retry( - SessionRetry.policy({ - parse, - set: (info) => - status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - next: info.next, - }), - }), - ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.gen(function* () { - aborted = true - yield* halt(new DOMException("Aborted", "AbortError")) - }) - : halt(Cause.squash(cause)), - ), - Effect.ensuring(cleanup()), - ) - - if (aborted && !ctx.assistantMessage.error) { - yield* abort() - } - if (ctx.needsCompaction) return "compact" - if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop" - return "continue" - }) - const abort = Effect.fn("SessionProcessor.abort")(() => Effect.gen(function* () { if (!ctx.assistantMessage.error) { @@ -486,6 +440,60 @@ export namespace SessionProcessor { }), ) + const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { + log.info("process") + ctx.needsCompaction = false + ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true + + return yield* Effect.gen(function* () { + yield* Effect.gen(function* () { + ctx.currentText = undefined + ctx.reasoningMap = {} + const stream = llm.stream(streamInput) + + yield* stream.pipe( + Stream.tap((event) => handleEvent(event)), + Stream.takeUntil(() => ctx.needsCompaction), + Stream.runDrain, + ) + }).pipe( + Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => Effect.fail(Cause.squash(cause)), + ), + Effect.retry( + SessionRetry.policy({ + parse, + set: (info) => + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, + }), + }), + ), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.gen(function* () { + aborted = true + yield* halt(new DOMException("Aborted", "AbortError")) + }) + : halt(Cause.squash(cause)), + ), + Effect.ensuring(cleanup()), + ) + + if (aborted && !ctx.assistantMessage.error) { + yield* abort() + } + if (ctx.needsCompaction) return "compact" + if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop" + return "continue" + }).pipe(Effect.onInterrupt(() => abort().pipe(Effect.asVoid))) + }) + return { get message() { return ctx.assistantMessage diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index cbfc00ea6890..00524d16d02f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -121,7 +121,9 @@ export namespace SessionPrompt { }), onBusy: status.set(sessionID, { type: "busy" }), onInterrupt: lastAssistant(sessionID), - busy: () => { throw new Session.BusyError(sessionID) }, + busy: () => { + throw new Session.BusyError(sessionID) + }, }) runners.set(sessionID, runner) return runner @@ -859,18 +861,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the let exited = false const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited })) - if (signal.aborted) { - aborted = true - yield* kill - } - const abortHandler = () => { + if (aborted) return aborted = true void Effect.runFork(kill) } yield* Effect.promise(() => { signal.addEventListener("abort", abortHandler, { once: true }) + if (signal.aborted) abortHandler() return new Promise((resolve) => { const close = () => { exited = true @@ -1290,10 +1289,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the const lastAssistant = (sessionID: SessionID) => Effect.promise(async () => { + let latest: MessageV2.WithParts | undefined for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user") continue - return item + latest ??= item + if (item.info.role !== "user") return item } + if (latest) return latest throw new Error("Impossible") }) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 01265f796280..7ae0ac2f26f0 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -6,7 +6,8 @@ import { it } from "../lib/effect" describe("Runner", () => { // --- ensureRunning semantics --- - it.effect("ensureRunning starts work and returns result", + it.effect( + "ensureRunning starts work and returns result", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -17,7 +18,8 @@ describe("Runner", () => { }), ) - it.effect("ensureRunning propagates work failures", + it.effect( + "ensureRunning propagates work failures", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -27,7 +29,8 @@ describe("Runner", () => { }), ) - it.effect("concurrent callers share the same run", + it.effect( + "concurrent callers share the same run", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -38,10 +41,9 @@ describe("Runner", () => { return "shared" }) - const [a, b] = yield* Effect.all( - [runner.ensureRunning(work), runner.ensureRunning(work)], - { concurrency: "unbounded" }, - ) + const [a, b] = yield* Effect.all([runner.ensureRunning(work), runner.ensureRunning(work)], { + concurrency: "unbounded", + }) expect(a).toBe("shared") expect(b).toBe("shared") @@ -49,7 +51,8 @@ describe("Runner", () => { }), ) - it.effect("concurrent callers all receive same error", + it.effect( + "concurrent callers all receive same error", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -68,7 +71,8 @@ describe("Runner", () => { }), ) - it.effect("ensureRunning can be called again after previous run completes", + it.effect( + "ensureRunning can be called again after previous run completes", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -77,7 +81,8 @@ describe("Runner", () => { }), ) - it.effect("second ensureRunning ignores new work if already running", + it.effect( + "second ensureRunning ignores new work if already running", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -93,10 +98,9 @@ describe("Runner", () => { return "second-result" }) - const [a, b] = yield* Effect.all( - [runner.ensureRunning(first), runner.ensureRunning(second)], - { concurrency: "unbounded" }, - ) + const [a, b] = yield* Effect.all([runner.ensureRunning(first), runner.ensureRunning(second)], { + concurrency: "unbounded", + }) expect(a).toBe("first-result") expect(b).toBe("first-result") @@ -106,7 +110,8 @@ describe("Runner", () => { // --- cancel semantics --- - it.effect("cancel interrupts running work", + it.effect( + "cancel interrupts running work", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -123,7 +128,8 @@ describe("Runner", () => { }), ) - it.effect("cancel on idle is a no-op", + it.effect( + "cancel on idle is a no-op", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -132,7 +138,8 @@ describe("Runner", () => { }), ) - it.effect("cancel with onInterrupt resolves callers gracefully", + it.effect( + "cancel with onInterrupt resolves callers gracefully", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) @@ -147,7 +154,8 @@ describe("Runner", () => { }), ) - it.effect("cancel with queued callers resolves all", + it.effect( + "cancel with queued callers resolves all", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) @@ -167,7 +175,8 @@ describe("Runner", () => { }), ) - it.effect("work can be started after cancel", + it.effect( + "work can be started after cancel", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -183,7 +192,8 @@ describe("Runner", () => { // --- shell semantics --- - it.effect("shell runs exclusively", + it.effect( + "shell runs exclusively", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -193,7 +203,8 @@ describe("Runner", () => { }), ) - it.effect("shell rejects when run is active", + it.effect( + "shell rejects when run is active", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -208,7 +219,8 @@ describe("Runner", () => { }), ) - it.effect("shell rejects when another shell is running", + it.effect( + "shell rejects when another shell is running", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -227,9 +239,41 @@ describe("Runner", () => { }), ) + it.effect( + "shell rejects via busy callback and cancel still stops the first shell", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { + busy: () => { + throw new Error("busy") + }, + }) + + const sh = yield* runner + .startShell((signal) => + Effect.promise( + () => + new Promise((resolve) => { + signal.addEventListener("abort", () => resolve("aborted"), { once: true }) + }), + ), + ) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + yield* runner.cancel + const done = yield* Fiber.await(sh) + expect(Exit.isSuccess(done)).toBe(true) + }), + ) + // --- shell→run handoff --- - it.effect("ensureRunning queues behind shell then runs after", + it.effect( + "ensureRunning queues behind shell then runs after", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -255,7 +299,8 @@ describe("Runner", () => { }), ) - it.effect("multiple ensureRunning callers share the queued run behind shell", + it.effect( + "multiple ensureRunning callers share the queued run behind shell", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -285,7 +330,8 @@ describe("Runner", () => { }), ) - it.effect("cancel during shell_then_run cancels both", + it.effect( + "cancel during shell_then_run cancels both", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) @@ -293,9 +339,12 @@ describe("Runner", () => { const sh = yield* runner .startShell((signal) => - Effect.promise(() => new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - })), + Effect.promise( + () => + new Promise((resolve) => { + signal.addEventListener("abort", () => resolve("aborted"), { once: true }) + }), + ), ) .pipe(Effect.forkChild) yield* Effect.sleep("10 millis") @@ -315,7 +364,8 @@ describe("Runner", () => { // --- lifecycle callbacks --- - it.effect("onIdle fires when returning to idle from running", + it.effect( + "onIdle fires when returning to idle from running", Effect.gen(function* () { const s = yield* Scope.Scope const count = yield* Ref.make(0) @@ -327,7 +377,8 @@ describe("Runner", () => { }), ) - it.effect("onIdle fires on cancel", + it.effect( + "onIdle fires on cancel", Effect.gen(function* () { const s = yield* Scope.Scope const count = yield* Ref.make(0) @@ -342,7 +393,8 @@ describe("Runner", () => { }), ) - it.effect("onBusy fires when shell starts", + it.effect( + "onBusy fires when shell starts", Effect.gen(function* () { const s = yield* Scope.Scope const count = yield* Ref.make(0) @@ -356,15 +408,14 @@ describe("Runner", () => { // --- busy flag --- - it.effect("busy is true during run", + it.effect( + "busy is true during run", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) const gate = yield* Deferred.make() - const fiber = yield* runner - .ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) + const fiber = yield* runner.ensureRunning(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.busy).toBe(true) @@ -374,7 +425,8 @@ describe("Runner", () => { }), ) - it.effect("busy is true during shell", + it.effect( + "busy is true during shell", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 637cf8e67f6d..e40c2b5bf591 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -885,6 +885,91 @@ describe("session.compaction.process", () => { }, }) }) + + test("does not allow tool calls while generating the summary", async () => { + const stub = llm() + stub.push( + Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, + { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + ), + ) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 })) + + const session = await Session.create({}) + const msg = await user(session.id, "hello") + const rt = liveRuntime(stub.layer) + try { + const msgs = await Session.messages({ sessionID: session.id }) + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const summary = (await Session.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.summary, + ) + + expect(summary?.info.role).toBe("assistant") + expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) + } finally { + await rt.dispose() + } + }, + }) + }) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 8da2d239968f..0dfdef26f61e 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -806,3 +806,67 @@ it.effect("session.processor effect tests record aborted errors and idle state", { git: true }, ) }) + +it.effect("session.processor effect tests mark interruptions aborted without manual abort", () => { + return provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const processors = yield* SessionProcessor.Service + const session = yield* Session.Service + const status = yield* SessionStatus.Service + const test = yield* TestLLM + + yield* test.push((input) => + hang(input, start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + ), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "interrupt") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = model(100) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const run = yield* handle + .process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "interrupt" }], + tools: {}, + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(run) + + const exit = yield* Fiber.await(run) + const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id })) + const state = yield* status.get(chat.id) + + expect(Exit.isFailure(exit)).toBe(true) + expect(handle.message.error?.name).toBe("MessageAbortedError") + expect(stored.info.role).toBe("assistant") + if (stored.info.role === "assistant") { + expect(stored.info.error?.name).toBe("MessageAbortedError") + } + expect(state).toMatchObject({ type: "idle" }) + }), + { git: true }, + ) +}) From b0ef301b58ceedc22b8b273db95ca6567b5e16fd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 12:48:47 -0400 Subject: [PATCH 45/66] fix prompt follow-up regressions and cleanup --- packages/opencode/src/effect/runner.ts | 16 ++- packages/opencode/src/effect/single-flight.ts | 71 ----------- packages/opencode/src/session/compaction.ts | 1 + packages/opencode/src/session/processor.ts | 9 +- packages/opencode/src/session/prompt.ts | 60 ++++++--- packages/opencode/src/session/summary.ts | 1 - packages/opencode/test/effect/runner.test.ts | 79 +++++++++++- .../test/effect/single-flight.test.ts | 119 ------------------ .../test/session/prompt-effect.test.ts | 119 ++++++++++++++++-- 9 files changed, 243 insertions(+), 232 deletions(-) delete mode 100644 packages/opencode/src/effect/single-flight.ts delete mode 100644 packages/opencode/test/effect/single-flight.test.ts diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index b921b860569e..3aa0262b9094 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State @@ -101,6 +101,14 @@ export namespace Runner { }) }).pipe(Effect.flatten) + const stopShell = (shell: ShellHandle) => + Effect.gen(function* () { + shell.abort.abort() + const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis")) + if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber) + yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) + }) + const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect(ref, (st) => { return Effect.gen(function* () { @@ -174,8 +182,7 @@ export namespace Runner { case "Shell": return [ Effect.gen(function* () { - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + yield* stopShell(st.shell) yield* idleIfCurrent() }), { _tag: "Idle" } as const, @@ -184,8 +191,7 @@ export namespace Runner { return [ Effect.gen(function* () { yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - st.shell.abort.abort() - yield* Fiber.await(st.shell.fiber).pipe(Effect.exit, Effect.asVoid) + yield* stopShell(st.shell) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/effect/single-flight.ts b/packages/opencode/src/effect/single-flight.ts deleted file mode 100644 index 2ee6cbddd1f0..000000000000 --- a/packages/opencode/src/effect/single-flight.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Schema } from "effect" -import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" - -const TypeId = Symbol.for("@opencode/SingleFlight") - -export class Cancelled extends Schema.TaggedErrorClass()("SingleFlight.Cancelled", {}) {} - -export interface SingleFlight { - readonly [TypeId]: typeof TypeId - readonly effect: Effect.Effect - readonly done: Deferred.Deferred - state: SingleFlight.State -} - -export namespace SingleFlight { - export type State = - | { readonly _tag: "Idle" } - | { readonly _tag: "Running"; readonly fiber: Fiber.Fiber } - | { readonly _tag: "Done" } - - export const make = (effect: Effect.Effect) => - Effect.gen(function* () { - const self: SingleFlight = { - [TypeId]: TypeId, - effect, - done: yield* Deferred.make(), - state: { _tag: "Idle" }, - } - return self - }) - - export const join = (self: SingleFlight): Effect.Effect => Deferred.await(self.done) - - export const start = (self: SingleFlight, scope: Scope.Scope): Effect.Effect => - Effect.uninterruptible( - Effect.gen(function* () { - if (self.state._tag !== "Idle") return - const fiber = yield* self.effect.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - self.state = { _tag: "Done" } - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { - yield* Deferred.fail(self.done, new Cancelled()) - } else { - yield* Deferred.done(self.done, exit) - } - }), - ), - Effect.forkIn(scope), - ) - self.state = { _tag: "Running", fiber } - }), - ) - - export const cancel = (self: SingleFlight): Effect.Effect => - Effect.gen(function* () { - const state = self.state - switch (state._tag) { - case "Done": - return - case "Idle": - self.state = { _tag: "Done" } - yield* Deferred.fail(self.done, new Cancelled()).pipe(Effect.asVoid) - return - case "Running": - self.state = { _tag: "Done" } - yield* Fiber.interrupt(state.fiber) - yield* Deferred.await(self.done).pipe(Effect.exit, Effect.asVoid) - } - }) -} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index e186a6c8ae99..229dff0c46de 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -183,6 +183,7 @@ export namespace SessionCompaction { const defaultPrompt = `Provide a detailed prompt for continuing 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. The summary that you construct will be used so that another agent can read it and continue the work. +Do not call any tools. Respond only with the summary text. When constructing the summary, try to stick to this template: --- diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1b9318826594..b632a61a18e8 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -474,14 +474,7 @@ export namespace SessionProcessor { }), }), ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.gen(function* () { - aborted = true - yield* halt(new DOMException("Aborted", "AbortError")) - }) - : halt(Cause.squash(cause)), - ), + Effect.catch(halt), Effect.ensuring(cleanup()), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 00524d16d02f..610526e064ff 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -859,6 +859,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the let aborted = false let exited = false + let finished = false const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited })) const abortHandler = () => { @@ -867,7 +868,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the void Effect.runFork(kill) } - yield* Effect.promise(() => { + const finish = Effect.uninterruptible( + Effect.gen(function* () { + if (finished) return + finished = true + if (aborted) { + output += "\n\n" + ["", "User aborted the command", ""].join("\n") + } + if (!msg.time.completed) { + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + } + if (part.state.status === "running") { + part.state = { + status: "completed", + time: { ...part.state.time, end: Date.now() }, + input: part.state.input, + title: "", + metadata: { output, description: "" }, + output, + } + yield* sessions.updatePart(part) + } + }), + ) + + const exit = yield* Effect.promise(() => { signal.addEventListener("abort", abortHandler, { once: true }) if (signal.aborted) abortHandler() return new Promise((resolve) => { @@ -878,24 +904,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the } proc.once("close", close) }) - }).pipe(Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler)))) + }).pipe( + Effect.onInterrupt(() => Effect.sync(abortHandler)), + Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler))), + Effect.ensuring(finish), + Effect.exit, + ) - if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") - } - msg.time.completed = Date.now() - yield* sessions.updateMessage(msg) - if (part.state.status === "running") { - part.state = { - status: "completed", - time: { ...part.state.time, end: Date.now() }, - input: part.state.input, - title: "", - metadata: { output, description: "" }, - output, - } - yield* sessions.updatePart(part) + if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { + return yield* Effect.failCause(exit.cause) } + return { info: msg, parts: [part] } }) @@ -1220,9 +1239,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.all(input.parts.map((part) => resolvePart(part))).pipe( - Effect.map((x) => x.flat().map(assign)), - ) + const parts = yield* Effect.all( + input.parts.map((part) => resolvePart(part)), + { concurrency: "unbounded" }, + ).pipe(Effect.map((x) => x.flat().map(assign))) yield* plugin.trigger( "chat.message", diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f6e1f0306302..c65cb9d0e00a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -3,7 +3,6 @@ import z from "zod" import { Session } from "." import { MessageV2 } from "./message-v2" -import { Identifier } from "@/id/id" import { SessionID, MessageID } from "./schema" import { Snapshot } from "@/snapshot" diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 7ae0ac2f26f0..5d3488849c59 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { Runner } from "../../src/effect/runner" import { it } from "../lib/effect" @@ -190,6 +190,59 @@ describe("Runner", () => { }), ) + test("cancel does not deadlock when replacement work starts before interrupted run exits", async () => { + function defer() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } + } + + function fail(ms: number, msg: string) { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(msg)), ms) + }) + } + + const s = await Effect.runPromise(Scope.make()) + const hit = defer() + const hold = defer() + const done = defer() + try { + const runner = Runner.make(s) + const first = Effect.never.pipe( + Effect.onInterrupt(() => Effect.sync(() => hit.resolve())), + Effect.ensuring(Effect.promise(() => hold.promise)), + Effect.as("first"), + ) + + const a = Effect.runPromiseExit(runner.ensureRunning(first)) + await Bun.sleep(10) + + const stop = Effect.runPromise(runner.cancel) + await Promise.race([hit.promise, fail(250, "cancel did not interrupt running work")]) + + const b = Effect.runPromise(runner.ensureRunning(Effect.promise(() => done.promise).pipe(Effect.as("second")))) + expect(runner.busy).toBe(true) + + hold.resolve() + await Promise.race([stop, fail(250, "cancel deadlocked while replacement run was active")]) + + expect(runner.busy).toBe(true) + done.resolve() + expect(await b).toBe("second") + expect(runner.busy).toBe(false) + + const exit = await a + expect(Exit.isFailure(exit)).toBe(true) + } finally { + hold.resolve() + done.resolve() + await Promise.race([Effect.runPromise(Scope.close(s, Exit.void)), fail(1000, "runner scope did not close")]) + } + }) + // --- shell semantics --- it.effect( @@ -270,6 +323,30 @@ describe("Runner", () => { }), ) + it.effect( + "cancel interrupts shell that ignores abort signal", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s) + const gate = yield* Deferred.make() + + const sh = yield* runner + .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + const stop = yield* runner.cancel.pipe(Effect.forkChild) + const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) + expect(Exit.isSuccess(stopExit)).toBe(true) + expect(runner.busy).toBe(false) + + const shellExit = yield* Fiber.await(sh) + expect(Exit.isFailure(shellExit)).toBe(true) + + yield* Deferred.succeed(gate, undefined).pipe(Effect.ignore) + }), + ) + // --- shell→run handoff --- it.effect( diff --git a/packages/opencode/test/effect/single-flight.test.ts b/packages/opencode/test/effect/single-flight.test.ts deleted file mode 100644 index 2e90f4b00c3d..000000000000 --- a/packages/opencode/test/effect/single-flight.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" -import { SingleFlight } from "../../src/effect/single-flight" - -type Result = { value: string } - -describe("SingleFlight", () => { - test("start + join returns the result", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const flight = yield* SingleFlight.make(Effect.succeed({ value: "ok" })) - yield* SingleFlight.start(flight, s) - const result = yield* SingleFlight.join(flight) - expect(result.value).toBe("ok") - }), - ), - ) - }) - - test("concurrent joins share the same run", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const calls = yield* Ref.make(0) - const work = Effect.gen(function* () { - yield* Ref.update(calls, (n) => n + 1) - yield* Effect.sleep("10 millis") - return { value: "shared" } - }) - - const flight = yield* SingleFlight.make(work) - yield* SingleFlight.start(flight, s) - const [a, b] = yield* Effect.all([SingleFlight.join(flight), SingleFlight.join(flight)], { - concurrency: "unbounded", - }) - - expect(a.value).toBe("shared") - expect(b.value).toBe("shared") - expect(yield* Ref.get(calls)).toBe(1) - }), - ), - ) - }) - - test("idle flight does not begin until started", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const started = yield* Ref.make(false) - const work = Effect.gen(function* () { - yield* Ref.set(started, true) - return { value: "later" } - }) - - const flight = yield* SingleFlight.make(work) - const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - expect(yield* Ref.get(started)).toBe(false) - - yield* SingleFlight.start(flight, s) - - const exit = yield* Fiber.await(waiter) - expect(yield* Ref.get(started)).toBe(true) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.value).toBe("later") - } - }), - ), - ) - }) - - test("cancel fails pending joins", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const flight = yield* SingleFlight.make(Effect.succeed({ value: "never" })) - const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - yield* SingleFlight.cancel(flight) - - const exit = yield* Fiber.await(waiter) - expect(Exit.isFailure(exit)).toBe(true) - }), - ), - ) - }) - - test("cancel waits for running fiber cleanup", async () => { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const s = yield* Scope.Scope - const cleanup = yield* Deferred.make() - const work = Effect.never.pipe( - Effect.ensuring(Deferred.succeed(cleanup, undefined).pipe(Effect.asVoid)), - Effect.as({ value: "never" as const }), - ) - - const flight = yield* SingleFlight.make(work) - yield* SingleFlight.start(flight, s) - const waiter = yield* SingleFlight.join(flight).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") - - yield* SingleFlight.cancel(flight) - yield* Deferred.await(cleanup) - - const exit = yield* Fiber.await(waiter) - expect(Exit.isFailure(exit)).toBe(true) - }), - ), - ) - }) -}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 2bfc9636b6ef..4c3e5960dd54 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -172,7 +172,6 @@ function toolPart(parts: MessageV2.Part[]) { type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } -type RunningToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateRunning } function completedTool(parts: MessageV2.Part[]) { const part = toolPart(parts) @@ -186,12 +185,6 @@ function errorTool(parts: MessageV2.Part[]) { return part?.state.status === "error" ? (part as ErrorToolPart) : undefined } -function runningTool(parts: MessageV2.Part[]) { - const part = toolPart(parts) - expect(part?.state.status).toBe("running") - return part?.state.status === "running" ? (part as RunningToolPart) : undefined -} - const llm = Layer.unwrap( Effect.gen(function* () { const queue: Script[] = [] @@ -711,6 +704,86 @@ it.effect("concurrent loop callers all receive same error result", () => ), ) +it.effect( + "prompt submitted during an active run gets a follow-up assistant turn", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const ready = defer() + const gate = defer() + const { test, prompt, sessions, chat } = yield* boot() + + yield* test.push((_input) => + stream(start()).pipe( + Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)), + Stream.concat( + Stream.fromEffect(Effect.promise(() => gate.promise)).pipe( + Stream.flatMap(() => + stream(textStart("a"), textDelta("a", "first"), textEnd("a"), finishStep(), finish()), + ), + ), + ), + ), + ) + + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + + const id = MessageID.ascending() + const b = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const end = Date.now() + 5000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id })) + if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for second prompt to save") + }) + + yield* test.reply(...replyStop("second")) + gate.resolve() + + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + expect(yield* test.calls).toBe(2) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(2) + const last = assistants.at(-1) + if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") + expect(last.info.parentID).toBe(id) + expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + + const inputs = yield* test.inputs + expect(inputs).toHaveLength(2) + expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") + }), + { git: true, config: cfg }, + ), + 30_000, +) + it.effect( "assertNotBusy throws BusyError when loop running", () => @@ -964,6 +1037,38 @@ it.effect( 30_000, ) +it.effect( + "cancel persists aborted shell result when shell ignores TERM", + () => + withSh(() => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" }) + .pipe(Effect.forkChild) + yield* waitMs(50) + + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") + } + } + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) + it.effect( "cancel interrupts loop queued behind shell", () => From ba619e0fa46755017b0fda9d7e61f04488c8a748 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:00:11 -0400 Subject: [PATCH 46/66] test(server): isolate session server state --- .../opencode/test/server/session-list.test.ts | 30 ++++++++++++------- .../test/server/session-messages.test.ts | 23 +++++++++----- .../test/server/session-select.test.ts | 18 +++++++---- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 675a89011f96..933b5b5b5a97 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,26 +1,30 @@ -import { describe, expect, test } from "bun:test" -import path from "path" +import { afterEach, describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" -const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +afterEach(async () => { + await Instance.disposeAll() +}) + describe("Session.list", () => { test("filters by directory", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { const first = await Session.create({}) - const otherDir = path.join(projectRoot, "..", "__session_list_other") + await using other = await tmpdir({ git: true }) const second = await Instance.provide({ - directory: otherDir, + directory: other.path, fn: async () => Session.create({}), }) - const sessions = [...Session.list({ directory: projectRoot })] + const sessions = [...Session.list({ directory: tmp.path })] const ids = sessions.map((s) => s.id) expect(ids).toContain(first.id) @@ -30,8 +34,9 @@ describe("Session.list", () => { }) test("filters root sessions", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { const root = await Session.create({ title: "root-session" }) const child = await Session.create({ title: "child-session", parentID: root.id }) @@ -46,8 +51,9 @@ describe("Session.list", () => { }) test("filters by start time", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { const session = await Session.create({ title: "new-session" }) const futureStart = Date.now() + 86400000 @@ -59,8 +65,9 @@ describe("Session.list", () => { }) test("filters by search term", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { await Session.create({ title: "unique-search-term-abc" }) await Session.create({ title: "other-session-xyz" }) @@ -75,8 +82,9 @@ describe("Session.list", () => { }) test("respects limit parameter", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { await Session.create({ title: "session-1" }) await Session.create({ title: "session-2" }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 91e0fd92634c..d7e44cbecc36 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,15 +1,18 @@ -import { describe, expect, test } from "bun:test" -import path from "path" +import { afterEach, describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" -const root = path.join(__dirname, "../..") Log.init({ print: false }) +afterEach(async () => { + await Instance.disposeAll() +}) + async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) { const ids = [] as MessageID[] for (let i = 0; i < count; i++) { @@ -38,8 +41,9 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: root, + directory: tmp.path, fn: async () => { const session = await Session.create({}) const ids = await fill(session.id, 5) @@ -64,8 +68,9 @@ describe("session messages endpoint", () => { }) test("keeps full-history responses when limit is omitted", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: root, + directory: tmp.path, fn: async () => { const session = await Session.create({}) const ids = await fill(session.id, 3) @@ -82,8 +87,9 @@ describe("session messages endpoint", () => { }) test("rejects invalid cursors and missing sessions", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: root, + directory: tmp.path, fn: async () => { const session = await Session.create({}) const app = Server.Default() @@ -100,8 +106,9 @@ describe("session messages endpoint", () => { }) test("does not truncate large legacy limit requests", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: root, + directory: tmp.path, fn: async () => { const session = await Session.create({}) await fill(session.id, 520) @@ -120,7 +127,7 @@ describe("session messages endpoint", () => { describe("session.prompt_async error handling", () => { test("prompt_async route has error handler for detached prompt call", async () => { - const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text() + const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text() const start = src.indexOf('"/:sessionID/prompt_async"') const end = src.indexOf('"/:sessionID/command"', start) expect(start).toBeGreaterThan(-1) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index a336f8133c87..345b4314675b 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,17 +1,21 @@ -import { describe, expect, test } from "bun:test" -import path from "path" +import { afterEach, describe, expect, test } from "bun:test" import { Session } from "../../src/session" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" +import { tmpdir } from "../fixture/fixture" -const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +afterEach(async () => { + await Instance.disposeAll() +}) + describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { // #given const session = await Session.create({}) @@ -35,8 +39,9 @@ describe("tui.selectSession endpoint", () => { }) test("should return 404 when session does not exist", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { // #given const nonExistentSessionID = "ses_nonexistent123" @@ -56,8 +61,9 @@ describe("tui.selectSession endpoint", () => { }) test("should return 400 when session ID format is invalid", async () => { + await using tmp = await tmpdir({ git: true }) await Instance.provide({ - directory: projectRoot, + directory: tmp.path, fn: async () => { // #given const invalidSessionID = "invalid_session_id" From ee0b2e5bfc67bfd8612590e9214fe00b8a6afe2f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:03:21 -0400 Subject: [PATCH 47/66] test(session): clarify active-run prompt coverage --- packages/opencode/test/session/prompt-effect.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 4c3e5960dd54..424c956bf789 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -705,7 +705,7 @@ it.effect("concurrent loop callers all receive same error result", () => ) it.effect( - "prompt submitted during an active run gets a follow-up assistant turn", + "prompt submitted during an active run is included in the next LLM input", () => provideTmpdirInstance( (dir) => From 3a110c1b4a4f948ec3e1aeb5ad077531e0766c5f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:16:42 -0400 Subject: [PATCH 48/66] refactor(runner): simplify Match transition --- packages/opencode/src/effect/runner.ts | 33 +++++++++++++------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 3aa0262b9094..5788ad206bc8 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Match, Option, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State @@ -109,30 +109,31 @@ export namespace Runner { yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) }) + const join = (st: Extract, { _tag: "Running" } | { _tag: "ShellThenRun" }>) => + Effect.succeed([Deferred.await(st.run.done), st] as const) + const ensureRunning = (work: Effect.Effect) => - SynchronizedRef.modifyEffect(ref, (st) => { - return Effect.gen(function* () { - switch (st._tag) { - case "Running": - return [Deferred.await(st.run.done), st] as const - case "ShellThenRun": - return [Deferred.await(st.run.done), st] as const - case "Shell": { + SynchronizedRef.modifyEffect(ref, (st) => + Match.valueTags(st, { + Running: join, + ShellThenRun: join, + Shell: ({ shell }) => + Effect.gen(function* () { const run = { id: next(), done: yield* Deferred.make(), work, } satisfies PendingHandle - return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run } as const] as const - } - case "Idle": { + return [Deferred.await(run.done), { _tag: "ShellThenRun", shell, run } as const] as const + }), + Idle: () => + Effect.gen(function* () { const done = yield* Deferred.make() const run = yield* startRun(work, done) return [Deferred.await(done), { _tag: "Running", run } as const] as const - } - } - }) - }).pipe( + }), + }), + ).pipe( Effect.flatten, Effect.catch((e) => (e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e))), ) From c629529b59f02e6537043394e37f24c572e2607c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:26:27 -0400 Subject: [PATCH 49/66] revert(runner): restore switch-based state transition --- packages/opencode/src/effect/runner.ts | 33 +++++++++++++------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 5788ad206bc8..3aa0262b9094 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Match, Option, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State @@ -109,31 +109,30 @@ export namespace Runner { yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) }) - const join = (st: Extract, { _tag: "Running" } | { _tag: "ShellThenRun" }>) => - Effect.succeed([Deferred.await(st.run.done), st] as const) - const ensureRunning = (work: Effect.Effect) => - SynchronizedRef.modifyEffect(ref, (st) => - Match.valueTags(st, { - Running: join, - ShellThenRun: join, - Shell: ({ shell }) => - Effect.gen(function* () { + SynchronizedRef.modifyEffect(ref, (st) => { + return Effect.gen(function* () { + switch (st._tag) { + case "Running": + return [Deferred.await(st.run.done), st] as const + case "ShellThenRun": + return [Deferred.await(st.run.done), st] as const + case "Shell": { const run = { id: next(), done: yield* Deferred.make(), work, } satisfies PendingHandle - return [Deferred.await(run.done), { _tag: "ShellThenRun", shell, run } as const] as const - }), - Idle: () => - Effect.gen(function* () { + return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run } as const] as const + } + case "Idle": { const done = yield* Deferred.make() const run = yield* startRun(work, done) return [Deferred.await(done), { _tag: "Running", run } as const] as const - }), - }), - ).pipe( + } + } + }) + }).pipe( Effect.flatten, Effect.catch((e) => (e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e))), ) From ad7f1491d9db0db5aa7b1902dbc4a92d3d7b82e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:27:55 -0400 Subject: [PATCH 50/66] refactor(runner): simplify ensureRunning transition --- packages/opencode/src/effect/runner.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 3aa0262b9094..74ef3c3ab8f7 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -110,8 +110,9 @@ export namespace Runner { }) const ensureRunning = (work: Effect.Effect) => - SynchronizedRef.modifyEffect(ref, (st) => { - return Effect.gen(function* () { + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st: State) { switch (st._tag) { case "Running": return [Deferred.await(st.run.done), st] as const @@ -131,8 +132,8 @@ export namespace Runner { return [Deferred.await(done), { _tag: "Running", run } as const] as const } } - }) - }).pipe( + }), + ).pipe( Effect.flatten, Effect.catch((e) => (e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e))), ) From 776b9ae827a49de44e9877fdf4d6ba6aa019dd21 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:37:30 -0400 Subject: [PATCH 51/66] refactor(runner): trim redundant state assertions --- packages/opencode/src/effect/runner.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 74ef3c3ab8f7..712645b38199 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -91,11 +91,11 @@ export namespace Runner { SynchronizedRef.modifyEffect(ref, (st) => { return Effect.gen(function* () { if (st._tag === "Shell" && st.shell.id === id) { - return [idle, { _tag: "Idle" } as const] as const + return [idle, { _tag: "Idle" }] as const } if (st._tag === "ShellThenRun" && st.shell.id === id) { const run = yield* startRun(st.run.work, st.run.done) - return [Effect.void, { _tag: "Running", run } as const] as const + return [Effect.void, { _tag: "Running", run }] as const } return [Effect.void, st] as const }) @@ -112,7 +112,7 @@ export namespace Runner { const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( ref, - Effect.fnUntraced(function* (st: State) { + Effect.fnUntraced(function* (st) { switch (st._tag) { case "Running": return [Deferred.await(st.run.done), st] as const @@ -124,12 +124,12 @@ export namespace Runner { done: yield* Deferred.make(), work, } satisfies PendingHandle - return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run } as const] as const + return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const } case "Idle": { const done = yield* Deferred.make() const run = yield* startRun(work, done) - return [Deferred.await(done), { _tag: "Running", run } as const] as const + return [Deferred.await(done), { _tag: "Running", run }] as const } } }), @@ -162,7 +162,7 @@ export namespace Runner { if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt return yield* Effect.failCause(exit.cause) }), - { _tag: "Shell", shell } as const, + { _tag: "Shell", shell }, ] as const }) }).pipe(Effect.flatten) From c3aabbdbc4aa7e35c7b0a76c7e94268e4c541f71 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:40:45 -0400 Subject: [PATCH 52/66] refactor(session): drop unused message update helpers --- packages/opencode/src/session/index.ts | 28 ++++++-------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b4b9654b8cb4..c986040523d6 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -848,17 +848,9 @@ export namespace Session { export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id))) export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id))) - export const updateMessage = Object.assign( - async function updateMessage(msg: T): Promise { - return runPromise((svc) => svc.updateMessage(MessageV2.Info.parse(msg) as T)) - }, - { - schema: MessageV2.Info, - force(msg: T): Promise { - return runPromise((svc) => svc.updateMessage(msg)) - }, - }, - ) + export async function updateMessage(msg: T): Promise { + return runPromise((svc) => svc.updateMessage(MessageV2.Info.parse(msg) as T)) + } export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) => runPromise((svc) => svc.removeMessage(input)), @@ -869,17 +861,9 @@ export namespace Session { (input) => runPromise((svc) => svc.removePart(input)), ) - export const updatePart = Object.assign( - async function updatePart(part: T): Promise { - return runPromise((svc) => svc.updatePart(MessageV2.Part.parse(part) as T)) - }, - { - schema: MessageV2.Part, - force(part: T): Promise { - return runPromise((svc) => svc.updatePart(part)) - }, - }, - ) + export async function updatePart(part: T): Promise { + return runPromise((svc) => svc.updatePart(MessageV2.Part.parse(part) as T)) + } export const updatePartDelta = fn( z.object({ From c96d30667831adc725e44aeaba7914e74cdadfb6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 13:44:09 -0400 Subject: [PATCH 53/66] refactor(prompt): drop unused wrapper metadata --- packages/opencode/src/session/prompt.ts | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 610526e064ff..ede42bd36959 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -21,7 +21,6 @@ import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" -import { fn } from "../util/fn" import { ToolRegistry } from "../tool/registry" import { Runner } from "@/effect/runner" import { MCP } from "../mcp" @@ -1706,7 +1705,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const { runPromise } = makeRuntime(Service, defaultLayer) - export const assertNotBusy = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.assertNotBusy(sessionID))) + export async function assertNotBusy(sessionID: SessionID) { + return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID))) + } export const PromptInput = z.object({ sessionID: SessionID.zod, @@ -1775,17 +1776,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type PromptInput = z.infer - export const prompt = fn(PromptInput, (input) => runPromise((svc) => svc.prompt(input))) + export async function prompt(input: PromptInput) { + return runPromise((svc) => svc.prompt(PromptInput.parse(input))) + } - export const resolvePromptParts = fn(z.string(), (template) => runPromise((svc) => svc.resolvePromptParts(template))) + export async function resolvePromptParts(template: string) { + return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template))) + } - export const cancel = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.cancel(sessionID))) + export async function cancel(sessionID: SessionID) { + return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID))) + } export const LoopInput = z.object({ sessionID: SessionID.zod, }) - export const loop = fn(LoopInput, (input) => runPromise((svc) => svc.loop(input))) + export async function loop(input: z.infer) { + return runPromise((svc) => svc.loop(LoopInput.parse(input))) + } export const ShellInput = z.object({ sessionID: SessionID.zod, @@ -1800,7 +1809,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type ShellInput = z.infer - export const shell = fn(ShellInput, (input) => runPromise((svc) => svc.shell(input))) + export async function shell(input: ShellInput) { + return runPromise((svc) => svc.shell(ShellInput.parse(input))) + } export const CommandInput = z.object({ messageID: MessageID.zod.optional(), @@ -1825,7 +1836,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) export type CommandInput = z.infer - export const command = fn(CommandInput, (input) => runPromise((svc) => svc.command(input))) + export async function command(input: CommandInput) { + return runPromise((svc) => svc.command(CommandInput.parse(input))) + } async function lastModelImpl(sessionID: SessionID) { for await (const item of MessageV2.stream(sessionID)) { From 14d088f33016523a851848500d6e48b3b1c47658 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 14:35:26 -0400 Subject: [PATCH 54/66] refactor(prompt): narrow Effect error types from unknown to never MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace unknown error channels in SessionPrompt.Interface with precise types: assertNotBusy uses Session.BusyError, everything else is never. - Remove Runner → Runner - Absorb fsys.ensureDir Error via Effect.catch(Effect.die) - Absorb Runner.Cancelled via Effect.catch(Effect.die) at loop/shell boundary --- packages/opencode/src/session/prompt.ts | 953 ++++++++++++------------ 1 file changed, 478 insertions(+), 475 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ede42bd36959..8be326d8445a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -67,13 +67,13 @@ export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export interface Interface { - readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect - readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly loop: (input: z.infer) => Effect.Effect - readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect - readonly resolvePromptParts: (template: string) => Effect.Effect + readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect + readonly loop: (input: z.infer) => Effect.Effect + readonly shell: (input: ShellInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect + readonly resolvePromptParts: (template: string) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/SessionPrompt") {} @@ -98,7 +98,7 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { - const runners = new Map>() + const runners = new Map>() yield* Effect.addFinalizer(() => Effect.gen(function* () { const entries = [...runners.values()] @@ -110,10 +110,10 @@ export namespace SessionPrompt { }), ) - const getRunner = (runners: Map>, sessionID: SessionID) => { + const getRunner = (runners: Map>, sessionID: SessionID) => { const existing = runners.get(sessionID) if (existing) return existing - const runner = Runner.make(scope, { + const runner = Runner.make(scope, { onIdle: Effect.gen(function* () { runners.delete(sessionID) yield* status.set(sessionID, { type: "idle" }) @@ -128,7 +128,9 @@ export namespace SessionPrompt { return runner } - const assertNotBusy = Effect.fn("SessionPrompt.assertNotBusy")(function* (sessionID: SessionID) { + const assertNotBusy: (sessionID: SessionID) => Effect.Effect = Effect.fn( + "SessionPrompt.assertNotBusy", + )(function* (sessionID: SessionID) { const s = yield* InstanceState.get(cache) const runner = s.runners.get(sessionID) if (runner?.busy) throw new Session.BusyError(sessionID) @@ -296,7 +298,7 @@ export namespace SessionPrompt { const plan = Session.plan(input.session) const exists = yield* fsys.existsSafe(plan) - if (!exists) yield* fsys.ensureDir(path.dirname(plan)) + if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die)) const part = yield* sessions.updatePart({ id: PartID.ascending(), messageID: userMessage.info.id, @@ -552,7 +554,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: SessionID session: Session.Info msgs: MessageV2.WithParts[] - }) => Effect.Effect = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { + }) => Effect.Effect = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { task: MessageV2.SubtaskPart model: Provider.Model lastUser: MessageV2.User @@ -972,271 +974,272 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[], unknown> = - Effect.fn("SessionPrompt.resolveUserPart")(function* (part: PromptInput["parts"][number]) { - if (part.type === "file") { - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) - if (Exit.isSuccess(exit)) { - const content = exit.value - if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) - const items = Array.isArray(content.contents) ? content.contents : [content.contents] - for (const c of items) { - if ("text" in c && c.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: c.text, - }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, - }) - } + const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( + "SessionPrompt.resolveUserPart", + )(function* (part) { + if (part.type === "file") { + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, + ] + const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + const content = exit.value + if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + const items = Array.isArray(content.contents) ? content.contents : [content.contents] + for (const c of items) { + if ("text" in c && c.text) { + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: c.text, + }) + } else if ("blob" in c && c.blob) { + const mime = "mimeType" in c ? c.mimeType : part.mime + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mime}]`, + }) } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } else { - const error = Cause.squash(exit.cause) - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) } - return pieces + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + } else { + const error = Cause.squash(exit.cause) + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - break - case "file:": { - log.info("file", { mime: part.mime }) - const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - if (s?.isDirectory()) part.mime = "application/x-directory" - - if (part.mime === "text/plain") { - let offset: number | undefined - let limit: number | undefined - const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - if (start === end) { - const symbols = yield* lsp - .documentSymbol(filePathURI) - .pipe(Effect.catch(() => Effect.succeed([]))) - for (const symbol of symbols) { - let r: LSP.Range | undefined - if ("range" in symbol) r = symbol.range - else if ("location" in symbol) r = symbol.location.range - if (r?.start?.line && r?.start?.line === start) { - start = r.start.line - end = r?.end?.line ?? start - break - } + return pieces + } + const url = new URL(part.url) + switch (url.protocol) { + case "data:": + if (part.mime === "text/plain") { + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: decodeDataUrl(part.url), + }, + { ...part, messageID: info.id, sessionID: input.sessionID }, + ] + } + break + case "file:": { + log.info("file", { mime: part.mime }) + const filepath = fileURLToPath(part.url) + const s = Filesystem.stat(filepath) + if (s?.isDirectory()) part.mime = "application/x-directory" + + if (part.mime === "text/plain") { + let offset: number | undefined + let limit: number | undefined + const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } + if (range.start != null) { + const filePathURI = part.url.split("?")[0] + let start = parseInt(range.start) + let end = range.end ? parseInt(range.end) : undefined + if (start === end) { + const symbols = yield* lsp + .documentSymbol(filePathURI) + .pipe(Effect.catch(() => Effect.succeed([]))) + for (const symbol of symbols) { + let r: LSP.Range | undefined + if ("range" in symbol) r = symbol.range + else if ("location" in symbol) r = symbol.location.range + if (r?.start?.line && r?.start?.line === start) { + start = r.start.line + end = r?.end?.line ?? start + break } } - offset = Math.max(start, 1) - if (end) limit = end - (offset - 1) } - const args = { filePath: filepath, offset, limit } - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - const read = yield* Effect.promise(() => ReadTool.init()).pipe( - Effect.flatMap((t) => - Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe( - Effect.flatMap((mdl) => - Effect.promise(() => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, model: mdl }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ), + offset = Math.max(start, 1) + if (end) limit = end - (offset - 1) + } + const args = { filePath: filepath, offset, limit } + const pieces: Draft[] = [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + ] + const read = yield* Effect.promise(() => ReadTool.init()).pipe( + Effect.flatMap((t) => + Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe( + Effect.flatMap((mdl) => + Effect.promise(() => + t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true, model: mdl }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + }), ), ), ), - Effect.exit, - ) - if (Exit.isSuccess(read)) { - const result = read.value - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((a) => ({ - ...a, - synthetic: true, - filename: a.filename ?? part.filename, - messageID: info.id, - sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } + ), + Effect.exit, + ) + if (Exit.isSuccess(read)) { + const result = read.value + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }) + if (result.attachments?.length) { + pieces.push( + ...result.attachments.map((a) => ({ + ...a, + synthetic: true, + filename: a.filename ?? part.filename, + messageID: info.id, + sessionID: input.sessionID, + })), + ) } else { - const error = Cause.squash(read.cause) - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message }).toObject(), - }) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) + pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } - return pieces + } else { + const error = Cause.squash(read.cause) + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : String(error) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + pieces.push({ + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) } + return pieces + } - if (part.mime === "application/x-directory") { - const args = { filePath: filepath } - const result = yield* Effect.promise(() => ReadTool.init()).pipe( - Effect.flatMap((t) => - Effect.promise(() => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ), + if (part.mime === "application/x-directory") { + const args = { filePath: filepath } + const result = yield* Effect.promise(() => ReadTool.init()).pipe( + Effect.flatMap((t) => + Effect.promise(() => + t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true }, + messages: [], + metadata: async () => {}, + ask: async () => {}, + }), ), - ) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - - yield* filetime.read(input.sessionID, filepath) + ), + ) return [ { messageID: info.id, sessionID: input.sessionID, type: "text", synthetic: true, - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, { - id: part.id, messageID: info.id, sessionID: input.sessionID, - type: "file", - url: - `data:${part.mime};base64,` + - (yield* Effect.promise(() => Filesystem.readBytes(filepath))).toString("base64"), - mime: part.mime, - filename: part.filename!, - source: part.source, + type: "text", + synthetic: true, + text: result.output, }, + { ...part, messageID: info.id, sessionID: input.sessionID }, ] } + + yield* filetime.read(input.sessionID, filepath) + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + }, + { + id: part.id, + messageID: info.id, + sessionID: input.sessionID, + type: "file", + url: + `data:${part.mime};base64,` + + (yield* Effect.promise(() => Filesystem.readBytes(filepath))).toString("base64"), + mime: part.mime, + filename: part.filename!, + source: part.source, + }, + ] } } + } - if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { ...part, messageID: info.id, sessionID: input.sessionID }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } + if (part.type === "agent") { + const perm = Permission.evaluate("task", part.name, ag.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + return [ + { ...part, messageID: info.id, sessionID: input.sessionID }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, + }, + ] + } - return [{ ...part, messageID: info.id, sessionID: input.sessionID }] - }) + return [{ ...part, messageID: info.id, sessionID: input.sessionID }] + }) const parts = yield* Effect.all( input.parts.map((part) => resolvePart(part)), @@ -1285,26 +1288,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( - "SessionPrompt.prompt", - )(function* (input: PromptInput) { - const session = yield* sessions.get(input.sessionID) - yield* Effect.promise(() => SessionRevert.cleanup(session)) - const message = yield* createUserMessage(input) - yield* sessions.touch(input.sessionID) + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( + function* (input: PromptInput) { + const session = yield* sessions.get(input.sessionID) + yield* Effect.promise(() => SessionRevert.cleanup(session)) + const message = yield* createUserMessage(input) + yield* sessions.touch(input.sessionID) - const permissions: Permission.Ruleset = [] - for (const [t, enabled] of Object.entries(input.tools ?? {})) { - permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) - } - if (permissions.length > 0) { - session.permission = permissions - yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) - } + const permissions: Permission.Ruleset = [] + for (const [t, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) + } + if (permissions.length > 0) { + session.permission = permissions + yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) + } - if (input.noReply === true) return message - return yield* loop({ sessionID: input.sessionID }) - }) + if (input.noReply === true) return message + return yield* loop({ sessionID: input.sessionID }) + }, + ) const lastAssistant = (sessionID: SessionID) => Effect.promise(async () => { @@ -1317,244 +1320,244 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw new Error("Impossible") }) - const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn( - "SessionPrompt.run", - )(function* (sessionID: SessionID) { - let structured: unknown | undefined - let step = 0 - const session = yield* sessions.get(sessionID) - - while (true) { - yield* status.set(sessionID, { type: "busy" }) - log.info("loop", { step, sessionID }) - - let msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(sessionID))) - - let lastUser: MessageV2.User | undefined - let lastAssistant: MessageV2.Assistant | undefined - let lastFinished: MessageV2.Assistant | undefined - 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 - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info - if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info - if (lastUser && lastFinished) break - const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") - if (task && !lastFinished) tasks.push(...task) - } - - if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - if ( - lastAssistant?.finish && - !["tool-calls"].includes(lastAssistant.finish) && - lastUser.id < lastAssistant.id - ) { - log.info("exiting loop", { sessionID }) - break - } - - step++ - if (step === 1) - yield* title({ - session, - modelID: lastUser.model.modelID, - providerID: lastUser.model.providerID, - history: msgs, - }).pipe(Effect.ignore, Effect.forkIn(scope)) - - const model = yield* getModel(lastUser!.model.providerID, lastUser!.model.modelID, sessionID) - const task = tasks.pop() - - if (task?.type === "subtask") { - yield* handleSubtask({ task, model, lastUser: lastUser!, sessionID, session, msgs }) - continue - } + const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn("SessionPrompt.run")( + function* (sessionID: SessionID) { + let structured: unknown | undefined + let step = 0 + const session = yield* sessions.get(sessionID) + + while (true) { + yield* status.set(sessionID, { type: "busy" }) + log.info("loop", { step, sessionID }) + + let msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(sessionID))) + + let lastUser: MessageV2.User | undefined + let lastAssistant: MessageV2.Assistant | undefined + let lastFinished: MessageV2.Assistant | undefined + 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 + if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info + if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info + if (lastUser && lastFinished) break + const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") + if (task && !lastFinished) tasks.push(...task) + } - if (task?.type === "compaction") { - const result = yield* compaction.process({ - messages: msgs, - parentID: lastUser!.id, - sessionID, - auto: task.auto, - overflow: task.overflow, - }) - if (result === "stop") break - continue - } + if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + if ( + lastAssistant?.finish && + !["tool-calls"].includes(lastAssistant.finish) && + lastUser.id < lastAssistant.id + ) { + log.info("exiting loop", { sessionID }) + break + } - if ( - lastFinished && - lastFinished.summary !== true && - (yield* compaction.isOverflow({ tokens: lastFinished!.tokens, model })) - ) { - yield* compaction.create({ sessionID, agent: lastUser!.agent, model: lastUser!.model, auto: true }) - continue - } + step++ + if (step === 1) + yield* title({ + session, + modelID: lastUser.model.modelID, + providerID: lastUser.model.providerID, + history: msgs, + }).pipe(Effect.ignore, Effect.forkIn(scope)) - const agent = yield* agents.get(lastUser!.agent) - if (!agent) { - const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser!.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) - throw error - } - const maxSteps = agent.steps ?? Infinity - const isLastStep = step >= maxSteps - msgs = yield* insertReminders({ messages: msgs, agent, session }) - - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - parentID: lastUser!.id, - role: "assistant", - mode: agent.name, - agent: agent.name, - variant: lastUser!.variant, - path: { cwd: Instance.directory, root: Instance.worktree }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: model.id, - providerID: model.providerID, - time: { created: Date.now() }, - sessionID, - } - yield* sessions.updateMessage(msg) - const handle = yield* processor.create({ - assistantMessage: msg, - sessionID, - model, - }) + const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) + const task = tasks.pop() - const outcome: "break" | "continue" = yield* Effect.onExit( - Effect.gen(function* () { - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") - const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + if (task?.type === "subtask") { + yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) + continue + } - const tools = yield* resolveTools({ - agent, - session, - model, - tools: lastUser!.tools, - processor: handle, - bypassAgentCheck, + if (task?.type === "compaction") { + const result = yield* compaction.process({ messages: msgs, + parentID: lastUser.id, + sessionID, + auto: task.auto, + overflow: task.overflow, }) + if (result === "stop") break + continue + } - if (lastUser!.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser!.format.schema, - onSuccess(output) { - structured = output - }, + if ( + lastFinished && + lastFinished.summary !== true && + (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) + ) { + yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + continue + } + + const agent = yield* agents.get(lastUser.agent) + if (!agent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + throw error + } + const maxSteps = agent.steps ?? Infinity + const isLastStep = step >= maxSteps + msgs = yield* insertReminders({ messages: msgs, agent, session }) + + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + parentID: lastUser.id, + role: "assistant", + mode: agent.name, + agent: agent.name, + variant: lastUser.variant, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.id, + providerID: model.providerID, + time: { created: Date.now() }, + sessionID, + } + yield* sessions.updateMessage(msg) + const handle = yield* processor.create({ + assistantMessage: msg, + sessionID, + model, + }) + + const outcome: "break" | "continue" = yield* Effect.onExit( + Effect.gen(function* () { + const lastUserMsg = msgs.findLast((m) => m.info.role === "user") + const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + + const tools = yield* resolveTools({ + agent, + session, + model, + tools: lastUser.tools, + processor: handle, + bypassAgentCheck, + messages: msgs, }) - } - if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser!.id }) - - if (step > 1 && lastFinished) { - for (const m of msgs) { - if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue - for (const p of m.parts) { - if (p.type !== "text" || p.ignored || p.synthetic) continue - if (!p.text.trim()) continue - p.text = [ - "", - "The user sent the following message:", - p.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } + if (lastUser.format?.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: lastUser.format.schema, + onSuccess(output) { + structured = output + }, + }) } - } - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id }) + + if (step > 1 && lastFinished) { + for (const m of msgs) { + if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue + for (const p of m.parts) { + if (p.type !== "text" || p.ignored || p.synthetic) continue + if (!p.text.trim()) continue + p.text = [ + "", + "The user sent the following message:", + p.text, + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n") + } + } + } - const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() => - Promise.all([ - SystemPrompt.skills(agent), - SystemPrompt.environment(model), - InstructionPrompt.system(), - MessageV2.toModelMessages(msgs, model), - ]), - ) - const system = [...env, ...(skills ? [skills] : []), ...instructions] - const format = lastUser!.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - const result = yield* handle.process({ - user: lastUser!, - agent, - permission: session.permission, - sessionID, - system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], - tools, - model, - toolChoice: format.type === "json_schema" ? "required" : undefined, - }) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - if (structured !== undefined) { - handle.message.structured = structured - handle.message.finish = handle.message.finish ?? "stop" - yield* sessions.updateMessage(handle.message) - return "break" as const - } + const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() => + Promise.all([ + SystemPrompt.skills(agent), + SystemPrompt.environment(model), + InstructionPrompt.system(), + MessageV2.toModelMessages(msgs, model), + ]), + ) + const system = [...env, ...(skills ? [skills] : []), ...instructions] + const format = lastUser.format ?? { type: "text" as const } + if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + const result = yield* handle.process({ + user: lastUser, + agent, + permission: session.permission, + sessionID, + system, + messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + tools, + model, + toolChoice: format.type === "json_schema" ? "required" : undefined, + }) - const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) - if (finished && !handle.message.error) { - if (format.type === "json_schema") { - handle.message.error = new MessageV2.StructuredOutputError({ - message: "Model did not produce structured output", - retries: 0, - }).toObject() + if (structured !== undefined) { + handle.message.structured = structured + handle.message.finish = handle.message.finish ?? "stop" yield* sessions.updateMessage(handle.message) return "break" as const } - } - if (result === "stop") return "break" as const - if (result === "compact") { - yield* compaction.create({ - sessionID, - agent: lastUser!.agent, - model: lastUser!.model, - auto: true, - overflow: !handle.message.finish, - }) - } - return "continue" as const - }), - (exit) => - Effect.gen(function* () { - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() - InstructionPrompt.clear(handle.message.id) + const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) + if (finished && !handle.message.error) { + if (format.type === "json_schema") { + handle.message.error = new MessageV2.StructuredOutputError({ + message: "Model did not produce structured output", + retries: 0, + }).toObject() + yield* sessions.updateMessage(handle.message) + return "break" as const + } + } + + if (result === "stop") return "break" as const + if (result === "compact") { + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + overflow: !handle.message.finish, + }) + } + return "continue" as const }), - ) - if (outcome === "break") break - continue - } + (exit) => + Effect.gen(function* () { + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() + InstructionPrompt.clear(handle.message.id) + }), + ) + if (outcome === "break") break + continue + } - yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) - return yield* lastAssistant(sessionID) - }) + yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) + return yield* lastAssistant(sessionID) + }, + ) - const loop: (input: z.infer) => Effect.Effect = Effect.fn( + const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const runner = getRunner(s.runners, input.sessionID) - return yield* runner.ensureRunning(runLoop(input.sessionID)) + return yield* runner.ensureRunning(runLoop(input.sessionID)).pipe(Effect.catch(Effect.die)) }) - const shell: (input: ShellInput) => Effect.Effect = Effect.fn( - "SessionPrompt.shell", - )(function* (input: ShellInput) { - const s = yield* InstanceState.get(cache) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)) - }) + const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( + function* (input: ShellInput) { + const s = yield* InstanceState.get(cache) + const runner = getRunner(s.runners, input.sessionID) + return yield* runner.startShell((signal) => shellImpl(input, signal)).pipe(Effect.catch(Effect.die)) + }, + ) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { log.info("command", input) From f04ca441f34a36ac52c8a235c20da10b220064ca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 14:39:32 -0400 Subject: [PATCH 55/66] refactor(runner): absorb Cancelled internally, remove from public type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelled is an internal implementation detail of the Runner — callers can't meaningfully handle it. Absorb it inside ensureRunning via Effect.die when no onInterrupt is provided, and remove it from the Runner interface. Drop the now-unnecessary Effect.catch(Effect.die) wrappers in prompt.ts. --- packages/opencode/src/effect/runner.ts | 8 +++++--- packages/opencode/src/session/prompt.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 712645b38199..a6aee7f77c3d 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -3,8 +3,8 @@ import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, Synchroniz export interface Runner { readonly state: Runner.State readonly busy: boolean - readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly ensureRunning: (work: Effect.Effect) => Effect.Effect + readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -135,7 +135,9 @@ export namespace Runner { }), ).pipe( Effect.flatten, - Effect.catch((e) => (e instanceof Cancelled && onInterrupt ? onInterrupt : Effect.fail(e))), + Effect.catch((e): Effect.Effect => + e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E), + ), ) const startShell = (work: (signal: AbortSignal) => Effect.Effect) => diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8be326d8445a..1a907f5e2ea7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1548,14 +1548,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the )(function* (input: z.infer) { const s = yield* InstanceState.get(cache) const runner = getRunner(s.runners, input.sessionID) - return yield* runner.ensureRunning(runLoop(input.sessionID)).pipe(Effect.catch(Effect.die)) + return yield* runner.ensureRunning(runLoop(input.sessionID)) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { const s = yield* InstanceState.get(cache) const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)).pipe(Effect.catch(Effect.die)) + return yield* runner.startShell((signal) => shellImpl(input, signal)) }, ) From 827f5d0238d6c6362278dfb0f8cf45f084815e4a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 14:43:15 -0400 Subject: [PATCH 56/66] refactor(runner): collapse duplicate cases, use fnUntraced consistently Merge Running/ShellThenRun branches in ensureRunning since both just await the same deferred. Replace bare Effect.gen closures in modifyEffect callbacks with Effect.fnUntraced for consistency. --- packages/opencode/src/effect/runner.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index a6aee7f77c3d..fc1314071f02 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -88,18 +88,17 @@ export namespace Runner { }) const finishShell = (id: number) => - SynchronizedRef.modifyEffect(ref, (st) => { - return Effect.gen(function* () { - if (st._tag === "Shell" && st.shell.id === id) { - return [idle, { _tag: "Idle" }] as const - } + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { + if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const if (st._tag === "ShellThenRun" && st.shell.id === id) { const run = yield* startRun(st.run.work, st.run.done) return [Effect.void, { _tag: "Running", run }] as const } return [Effect.void, st] as const - }) - }).pipe(Effect.flatten) + }), + ).pipe(Effect.flatten) const stopShell = (shell: ShellHandle) => Effect.gen(function* () { @@ -115,7 +114,6 @@ export namespace Runner { Effect.fnUntraced(function* (st) { switch (st._tag) { case "Running": - return [Deferred.await(st.run.done), st] as const case "ShellThenRun": return [Deferred.await(st.run.done), st] as const case "Shell": { @@ -141,8 +139,9 @@ export namespace Runner { ) const startShell = (work: (signal: AbortSignal) => Effect.Effect) => - SynchronizedRef.modifyEffect(ref, (st) => { - return Effect.gen(function* () { + SynchronizedRef.modifyEffect( + ref, + Effect.fnUntraced(function* (st) { if (st._tag !== "Idle") { return [ Effect.sync(() => { @@ -166,8 +165,8 @@ export namespace Runner { }), { _tag: "Shell", shell }, ] as const - }) - }).pipe(Effect.flatten) + }), + ).pipe(Effect.flatten) const cancel = SynchronizedRef.modify(ref, (st) => { switch (st._tag) { From 7c4fc70cc51e60c3f2036cfbc55250ccf751858f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 14:46:25 -0400 Subject: [PATCH 57/66] refactor(prompt): use fnUntraced for Effect callback generators Replace Effect.gen(function* () { inside callbacks with Effect.fnUntraced(function* (param) { to reduce nesting. --- packages/opencode/src/session/prompt.ts | 58 ++++++++++++------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1a907f5e2ea7..09415461c1e8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -99,8 +99,8 @@ export namespace SessionPrompt { const cache = yield* InstanceState.make( Effect.fn("SessionPrompt.state")(function* () { const runners = new Map>() - yield* Effect.addFinalizer(() => - Effect.gen(function* () { + yield* Effect.addFinalizer( + Effect.fnUntraced(function* () { const entries = [...runners.values()] runners.clear() yield* Effect.forEach(entries, (r) => r.cancel, { concurrency: "unbounded" }) @@ -153,29 +153,28 @@ export namespace SessionPrompt { const seen = new Set() yield* Effect.forEach( files, - (match) => - Effect.gen(function* () { - const name = match[1] - if (seen.has(name)) return - seen.add(name) - const filepath = name.startsWith("~/") - ? path.join(os.homedir(), name.slice(2)) - : path.resolve(Instance.worktree, name) - - const info = yield* fsys.stat(filepath).pipe(Effect.option) - if (!info._tag || info._tag === "None") { - const found = yield* agents.get(name) - if (found) parts.push({ type: "agent", name: found.name }) - return - } - const stat = info.value - parts.push({ - type: "file", - url: pathToFileURL(filepath).href, - filename: name, - mime: stat.type === "Directory" ? "application/x-directory" : "text/plain", - }) - }), + Effect.fnUntraced(function* (match) { + const name = match[1] + if (seen.has(name)) return + seen.add(name) + const filepath = name.startsWith("~/") + ? path.join(os.homedir(), name.slice(2)) + : path.resolve(Instance.worktree, name) + + const info = yield* fsys.stat(filepath).pipe(Effect.option) + if (!info._tag || info._tag === "None") { + const found = yield* agents.get(name) + if (found) parts.push({ type: "agent", name: found.name }) + return + } + const stat = info.value + parts.push({ + type: "file", + url: pathToFileURL(filepath).href, + filename: name, + mime: stat.type === "Directory" ? "application/x-directory" : "text/plain", + }) + }), { concurrency: "unbounded" }, ) return parts @@ -1528,11 +1527,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the } return "continue" as const }), - (exit) => - Effect.gen(function* () { - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() - InstructionPrompt.clear(handle.message.id) - }), + Effect.fnUntraced(function* (exit) { + if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() + InstructionPrompt.clear(handle.message.id) + }), ) if (outcome === "break") break continue From 1497bd5ea0709f3f3135475209552d017e74a619 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 14:53:50 -0400 Subject: [PATCH 58/66] refactor(prompt): use ToolRegistry service directly instead of facade Yield ToolRegistry.Service in the layer instead of going through Effect.promise(() => ToolRegistry.tools(...)). Add layer type annotation to registry.ts to fix tsgo circular inference. --- packages/opencode/src/session/prompt.ts | 10 +++++----- packages/opencode/src/tool/registry.ts | 4 ++-- packages/opencode/test/session/prompt-effect.test.ts | 9 ++++++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 09415461c1e8..b45a6bb043f2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -94,6 +94,7 @@ export namespace SessionPrompt { const mcp = yield* MCP.Service const lsp = yield* LSP.Service const filetime = yield* FileTime.Service + const registry = yield* ToolRegistry.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -427,11 +428,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ), }) - for (const item of yield* Effect.promise(() => - ToolRegistry.tools( - { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, - input.agent, - ), + for (const item of yield* registry.tools( + { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, + input.agent, )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ @@ -1696,6 +1695,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Layer.provide(MCP.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(FileTime.layer), + Layer.provide(ToolRegistry.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index eeb7334806e2..549d756c7017 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -51,7 +51,7 @@ export namespace ToolRegistry { export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} - export const layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -174,7 +174,7 @@ export namespace ToolRegistry { }) return yield* Effect.forEach( filtered, - Effect.fnUntraced(function* (tool) { + Effect.fnUntraced(function* (tool: Tool.Info) { using _ = log.time(tool.id) const next = yield* Effect.promise(() => tool.init({ agent })) const output = { diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 424c956bf789..1d942539887f 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -25,6 +25,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" +import { ToolRegistry } from "../../src/tool/registry" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" @@ -292,9 +293,15 @@ const deps = Layer.mergeAll( status, llm, ).pipe(Layer.provideMerge(infra)) +const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps)) const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) -const env = SessionPrompt.layer.pipe(Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(deps)) +const env = SessionPrompt.layer.pipe( + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(deps), +) const it = testEffect(env) From 0f31215f6983695d4bf3dce6c6ae9bb2e1e78275 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 14:56:56 -0400 Subject: [PATCH 59/66] refactor(tool): extract Tool.Def type, use Option.isNone in prompt Pull the anonymous init return type into a named Tool.Def interface, replacing Awaited> casts across tool registry. Use Option.isNone instead of tag check in prompt file resolution. --- packages/opencode/src/session/prompt.ts | 4 ++-- packages/opencode/src/tool/registry.ts | 9 +++---- packages/opencode/src/tool/tool.ts | 32 +++++++++++++------------ 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b45a6bb043f2..12806ae619ea 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -46,7 +46,7 @@ import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Fiber, Layer, Scope, ServiceMap } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -163,7 +163,7 @@ export namespace SessionPrompt { : path.resolve(Instance.worktree, name) const info = yield* fsys.stat(filepath).pipe(Effect.option) - if (!info._tag || info._tag === "None") { + if (Option.isNone(info)) { const found = yield* agents.get(name) if (found) parts.push({ type: "agent", name: found.name }) return diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 549d756c7017..a8349e2c19bd 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -46,7 +46,7 @@ export namespace ToolRegistry { readonly tools: ( model: { providerID: ProviderID; modelID: ModelID }, agent?: Agent.Info, - ) => Effect.Effect<(Awaited> & { id: string })[]> + ) => Effect.Effect<(Tool.Def & { id: string })[]> } export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} @@ -184,10 +184,11 @@ export namespace ToolRegistry { yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) return { id: tool.id, - ...next, description: output.description, parameters: output.parameters, - } as Awaited> & { id: string } + execute: next.execute, + formatValidationError: next.formatValidationError, + } }), { concurrency: "unbounded" }, ) @@ -217,7 +218,7 @@ export namespace ToolRegistry { modelID: ModelID }, agent?: Agent.Info, - ): Promise<(Awaited> & { id: string })[]> { + ): Promise<(Tool.Def & { id: string })[]> { return runPromise((svc) => svc.tools(model, agent)) } } diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 6c3f4efaf6dc..98fa50f8c7ac 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -25,22 +25,24 @@ export namespace Tool { metadata(input: { title?: string; metadata?: M }): void ask(input: Omit): Promise } + export interface Def { + description: string + parameters: Parameters + execute( + args: z.infer, + ctx: Context, + ): Promise<{ + title: string + metadata: M + output: string + attachments?: Omit[] + }> + formatValidationError?(error: z.ZodError): string + } + export interface Info { id: string - init: (ctx?: InitContext) => Promise<{ - description: string - parameters: Parameters - execute( - args: z.infer, - ctx: Context, - ): Promise<{ - title: string - metadata: M - output: string - attachments?: Omit[] - }> - formatValidationError?(error: z.ZodError): string - }> + init: (ctx?: InitContext) => Promise> } export type InferParameters = T extends Info ? z.infer

: never @@ -48,7 +50,7 @@ export namespace Tool { export function define( id: string, - init: Info["init"] | Awaited["init"]>>, + init: Info["init"] | Def, ): Info { return { id, From 766a2506feaa11f4e4df558ece24c309896a7207 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 15:20:17 -0400 Subject: [PATCH 60/66] refactor(prompt): replace Filesystem util with effectified fsys service Use fsys.isDir instead of Filesystem.stat and fsys.readFile instead of Filesystem.readBytes. Remove unused Filesystem import. --- packages/opencode/src/session/prompt.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 12806ae619ea..d21f0d99519c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,7 +1,6 @@ import path from "path" import os from "os" import z from "zod" -import { Filesystem } from "../util/filesystem" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" @@ -545,14 +544,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return tools }) - const handleSubtask: (input: { - task: MessageV2.SubtaskPart - model: Provider.Model - lastUser: MessageV2.User - sessionID: SessionID - session: Session.Info - msgs: MessageV2.WithParts[] - }) => Effect.Effect = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { + const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { task: MessageV2.SubtaskPart model: Provider.Model lastUser: MessageV2.User @@ -561,7 +553,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the msgs: MessageV2.WithParts[] }) { const { task, model, lastUser, sessionID, session, msgs } = input - const taskTool: Awaited> = yield* Effect.promise(() => TaskTool.init()) + const taskTool = yield* Effect.promise(() => TaskTool.init()) const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), @@ -1054,8 +1046,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the case "file:": { log.info("file", { mime: part.mime }) const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - if (s?.isDirectory()) part.mime = "application/x-directory" + if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory" if (part.mime === "text/plain") { let offset: number | undefined @@ -1208,7 +1199,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the type: "file", url: `data:${part.mime};base64,` + - (yield* Effect.promise(() => Filesystem.readBytes(filepath))).toString("base64"), + Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), mime: part.mime, filename: part.filename!, source: part.source, From c0aae41210556077aab40966da442559c6223cfa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 15:23:04 -0400 Subject: [PATCH 61/66] fix(test): skip shell-spawning prompt tests on Windows Tests that spawn /bin/sh with Unix commands (printf, sleep, trap) fail on Windows. Use a unix() helper to skip them, matching the existing codebase pattern (e.g. worktree.test.ts). --- .../opencode/test/session/prompt-effect.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 1d942539887f..04656f55cb15 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -304,6 +304,7 @@ const env = SessionPrompt.layer.pipe( ) const it = testEffect(env) +const unix = process.platform !== "win32" ? it.effect : it.effect.skip // Config that registers a custom "test" provider with a "test-model" model // so Provider.getModel("test", "test-model") succeeds inside the loop. @@ -878,7 +879,7 @@ it.effect( 30_000, ) -it.effect("shell captures stdout and stderr in completed tool output", () => +unix("shell captures stdout and stderr in completed tool output", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -903,7 +904,7 @@ it.effect("shell captures stdout and stderr in completed tool output", () => ), ) -it.effect( +unix( "shell updates running metadata before process exit", () => withSh(() => @@ -937,7 +938,7 @@ it.effect( 30_000, ) -it.effect( +unix( "loop waits while shell runs and starts after shell exits", () => provideTmpdirInstance( @@ -971,7 +972,7 @@ it.effect( 30_000, ) -it.effect( +unix( "shell completion resumes queued loop callers", () => provideTmpdirInstance( @@ -1007,7 +1008,7 @@ it.effect( 30_000, ) -it.effect( +unix( "cancel interrupts shell and resolves cleanly", () => withSh(() => @@ -1044,7 +1045,7 @@ it.effect( 30_000, ) -it.effect( +unix( "cancel persists aborted shell result when shell ignores TERM", () => withSh(() => @@ -1076,7 +1077,7 @@ it.effect( 30_000, ) -it.effect( +unix( "cancel interrupts loop queued behind shell", () => provideTmpdirInstance( @@ -1104,7 +1105,7 @@ it.effect( 30_000, ) -it.effect( +unix( "shell rejects when another shell is already running", () => withSh(() => From 3d165a6726b1baa826b0f8ec5c60227f159000ac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 15:26:45 -0400 Subject: [PATCH 62/66] refactor(prompt): convert lastModelImpl to Effect, remove unused Fiber Replace async lastModelImpl with Effect-based lastModel, removing three Effect.promise wrappers. Use agents.get directly instead of Agent facade in command(). Remove unused Fiber import. --- packages/opencode/src/session/prompt.ts | 28 +++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d21f0d99519c..bb85ba45e7f8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,7 +45,7 @@ import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Fiber, Layer, Option, Scope, ServiceMap } from "effect" +import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -726,7 +726,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const model = input.model ?? agent.model ?? (yield* Effect.promise(() => lastModelImpl(input.sessionID))) + const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), sessionID: input.sessionID, @@ -936,7 +936,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - const model = input.model ?? ag.model ?? (yield* Effect.promise(() => lastModelImpl(input.sessionID))) + const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID)) const full = !input.variant && ag.variant ? yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID).catch(() => undefined)) @@ -1597,14 +1597,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the } template = template.trim() - const taskModel = yield* Effect.promise(async () => { + const taskModel = yield* Effect.gen(function* () { if (cmd.model) return Provider.parseModel(cmd.model) if (cmd.agent) { - const cmdAgent = await Agent.get(cmd.agent) + const cmdAgent = yield* agents.get(cmd.agent) if (cmdAgent?.model) return cmdAgent.model } if (input.model) return Provider.parseModel(input.model) - return await lastModelImpl(input.sessionID) + return yield* lastModel(input.sessionID) }) yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) @@ -1637,7 +1637,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) - : yield* Effect.promise(() => lastModelImpl(input.sessionID)) + : yield* lastModel(input.sessionID) : taskModel yield* plugin.trigger( @@ -1832,12 +1832,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the return runPromise((svc) => svc.command(CommandInput.parse(input))) } - async function lastModelImpl(sessionID: SessionID) { - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model - } - return Provider.defaultModel() - } + const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) { + return yield* Effect.promise(async () => { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + return Provider.defaultModel() + }) + }) /** @internal Exported for testing */ export function createStructuredOutputTool(input: { From 77c01aa6d46bdb8b8bbc1f8c0e900024bef7736d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 15:32:21 -0400 Subject: [PATCH 63/66] refactor(prompt): use Truncate service directly instead of facade Yield Truncate.Service in the layer instead of going through Effect.promise(() => Truncate.output(...)). --- packages/opencode/src/session/prompt.ts | 4 +++- packages/opencode/test/session/prompt-effect.test.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bb85ba45e7f8..67484abf9231 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -94,6 +94,7 @@ export namespace SessionPrompt { const lsp = yield* LSP.Service const filetime = yield* FileTime.Service const registry = yield* ToolRegistry.Service + const truncate = yield* Truncate.Service const scope = yield* Scope.Scope const cache = yield* InstanceState.make( @@ -517,7 +518,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } - const truncated = yield* Effect.promise(() => Truncate.output(textParts.join("\n\n"), {}, input.agent)) + const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) const metadata = { ...(result.metadata ?? {}), truncated: truncated.truncated, @@ -1687,6 +1688,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Layer.provide(LSP.defaultLayer), Layer.provide(FileTime.layer), Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(Truncate.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 04656f55cb15..9f35a21f4a4e 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -26,6 +26,7 @@ import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" +import { Truncate } from "../../src/tool/truncate" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" @@ -294,12 +295,14 @@ const deps = Layer.mergeAll( llm, ).pipe(Layer.provideMerge(infra)) const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps)) +const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) const env = SessionPrompt.layer.pipe( Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), + Layer.provideMerge(trunc), Layer.provideMerge(deps), ) From 84368fd44df757fe8f1cf49a71395172dea1c32b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 15:46:33 -0400 Subject: [PATCH 64/66] refactor(prompt): simplify finalizer forEach with discard --- packages/opencode/src/session/prompt.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 67484abf9231..eba7f7a93ccb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -102,9 +102,8 @@ export namespace SessionPrompt { const runners = new Map>() yield* Effect.addFinalizer( Effect.fnUntraced(function* () { - const entries = [...runners.values()] + yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true }) runners.clear() - yield* Effect.forEach(entries, (r) => r.cancel, { concurrency: "unbounded" }) }), ) return { runners } From 0045a3efa6534d7abe69103281d7367c33b6bf25 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 15:52:08 -0400 Subject: [PATCH 65/66] fix(prompt): finalize subtask tool state on cancellation Add Effect.onInterrupt to the subtask execute call so the tool part and assistant message are properly finalized when the session is cancelled mid-subtask. --- packages/opencode/src/session/prompt.ts | 193 +++++++++++++----------- 1 file changed, 107 insertions(+), 86 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eba7f7a93ccb..61479e84f941 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -175,7 +175,7 @@ export namespace SessionPrompt { mime: stat.type === "Directory" ? "application/x-directory" : "text/plain", }) }), - { concurrency: "unbounded" }, + { concurrency: "unbounded", discard: true }, ) return parts }) @@ -605,92 +605,114 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - let executionError: Error | undefined - const result = yield* Effect.promise((signal) => - taskTool - .execute(taskArgs, { - agent: task.agent, - messageID: assistantMessage.id, - sessionID, - abort: signal, - callID: part.callID, - extra: { bypassAgentCheck: true }, - messages: msgs, - metadata(val: { title?: string; metadata?: Record }) { - return Effect.runPromise( - Effect.gen(function* () { - part = yield* sessions.updatePart({ - ...part, - type: "tool", - state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) - }), - ) - }, - ask(req: any) { - return Effect.runPromise( - permission.ask({ - ...req, - sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }), - ) - }, - }) - .catch((error) => { - executionError = error instanceof Error ? error : new Error(String(error)) - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }), - ) + let err: Error | undefined + const done = (result: Awaited> | undefined, stop: boolean) => + Effect.uninterruptible( + Effect.gen(function* () { + const attachments = result?.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID, + messageID: assistantMessage.id, + })) - const attachments = result?.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID, - messageID: assistantMessage.id, - })) + yield* plugin.trigger( + "tool.execute.after", + { tool: "task", sessionID, callID: part.id, args: taskArgs }, + result, + ) - yield* plugin.trigger( - "tool.execute.after", - { tool: "task", sessionID, callID: part.id, args: taskArgs }, - result, - ) + if (!assistantMessage.time.completed) { + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(assistantMessage) + } - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - yield* sessions.updateMessage(assistantMessage) - - if (result && part.state.status === "running") { - yield* sessions.updatePart({ - ...part, - state: { - status: "completed", - input: part.state.input, - title: result.title, - metadata: result.metadata, - output: result.output, - attachments, - time: { ...part.state.time, end: Date.now() }, - }, - } satisfies MessageV2.ToolPart) - } + if (result && part.state.status === "running") { + yield* sessions.updatePart({ + ...part, + state: { + status: "completed", + input: part.state.input, + title: result.title, + metadata: result.metadata, + output: result.output, + attachments, + time: { ...part.state.time, end: Date.now() }, + }, + } satisfies MessageV2.ToolPart) + return + } - if (!result) { - yield* sessions.updatePart({ - ...part, - state: { - status: "error", - error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", - time: { - start: part.state.status === "running" ? part.state.time.start : Date.now(), - end: Date.now(), - }, - metadata: part.state.status === "pending" ? undefined : part.state.metadata, - input: part.state.input, + if (part.state.status === "running") { + yield* sessions.updatePart({ + ...part, + state: { + status: "error", + error: stop + ? "Tool execution aborted" + : err + ? `Tool execution failed: ${err.message}` + : "Tool execution failed", + time: { + start: part.state.time.start, + end: Date.now(), + }, + metadata: part.state.metadata, + input: part.state.input, + }, + } satisfies MessageV2.ToolPart) + } + }), + ) + + const result = yield* Effect.promise((signal) => + taskTool.execute(taskArgs, { + agent: task.agent, + messageID: assistantMessage.id, + sessionID, + abort: signal, + callID: part.callID, + extra: { bypassAgentCheck: true }, + messages: msgs, + metadata(val: { title?: string; metadata?: Record }) { + return Effect.runPromise( + Effect.gen(function* () { + part = yield* sessions.updatePart({ + ...part, + type: "tool", + state: { ...part.state, ...val }, + } satisfies MessageV2.ToolPart) + }), + ) }, - } satisfies MessageV2.ToolPart) - } + ask(req: any) { + return Effect.runPromise( + permission.ask({ + ...req, + sessionID, + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + }), + ) + }, + }), + ).pipe( + Effect.onExit( + Effect.fnUntraced(function* (exit) { + const stop = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + if (Exit.isFailure(exit) && !stop) { + const squashed = Cause.squash(exit.cause) + err = squashed instanceof Error ? squashed : new Error(String(squashed)) + log.error("subtask execution failed", { error: err, agent: task.agent, description: task.description }) + } + yield* done(Exit.isSuccess(exit) ? exit.value : undefined, stop) + }), + ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => Effect.succeed(undefined), + ), + ) if (!task.command) return @@ -1230,10 +1252,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.all( - input.parts.map((part) => resolvePart(part)), - { concurrency: "unbounded" }, - ).pipe(Effect.map((x) => x.flat().map(assign))) + const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + Effect.map((x) => x.flat().map(assign)), + ) yield* plugin.trigger( "chat.message", From 5d10a4b3b45580894efc5ef0bea49ed9c0c928d1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 30 Mar 2026 16:00:24 -0400 Subject: [PATCH 66/66] refactor(prompt): simplify subtask cancellation to onInterrupt Replace the done/onExit/catchCauseIf approach with the simpler three-concern pattern: .catch() for execution errors, onInterrupt for cancellation cleanup, sequential code for normal finalization. --- packages/opencode/src/session/prompt.ts | 184 ++++++++++++------------ 1 file changed, 91 insertions(+), 93 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 61479e84f941..c627f0a10024 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -605,114 +605,112 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - let err: Error | undefined - const done = (result: Awaited> | undefined, stop: boolean) => - Effect.uninterruptible( + let error: Error | undefined + const result = yield* Effect.promise((signal) => + taskTool + .execute(taskArgs, { + agent: task.agent, + messageID: assistantMessage.id, + sessionID, + abort: signal, + callID: part.callID, + extra: { bypassAgentCheck: true }, + messages: msgs, + metadata(val: { title?: string; metadata?: Record }) { + return Effect.runPromise( + Effect.gen(function* () { + part = yield* sessions.updatePart({ + ...part, + type: "tool", + state: { ...part.state, ...val }, + } satisfies MessageV2.ToolPart) + }), + ) + }, + ask(req: any) { + return Effect.runPromise( + permission.ask({ + ...req, + sessionID, + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + }), + ) + }, + }) + .catch((e) => { + error = e instanceof Error ? e : new Error(String(e)) + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }), + ).pipe( + Effect.onInterrupt(() => Effect.gen(function* () { - const attachments = result?.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID, - messageID: assistantMessage.id, - })) - - yield* plugin.trigger( - "tool.execute.after", - { tool: "task", sessionID, callID: part.id, args: taskArgs }, - result, - ) - - if (!assistantMessage.time.completed) { - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - yield* sessions.updateMessage(assistantMessage) - } - - if (result && part.state.status === "running") { - yield* sessions.updatePart({ - ...part, - state: { - status: "completed", - input: part.state.input, - title: result.title, - metadata: result.metadata, - output: result.output, - attachments, - time: { ...part.state.time, end: Date.now() }, - }, - } satisfies MessageV2.ToolPart) - return - } - + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(assistantMessage) if (part.state.status === "running") { yield* sessions.updatePart({ ...part, state: { status: "error", - error: stop - ? "Tool execution aborted" - : err - ? `Tool execution failed: ${err.message}` - : "Tool execution failed", - time: { - start: part.state.time.start, - end: Date.now(), - }, + error: "Cancelled", + time: { start: part.state.time.start, end: Date.now() }, metadata: part.state.metadata, input: part.state.input, }, } satisfies MessageV2.ToolPart) } }), - ) + ), + ) - const result = yield* Effect.promise((signal) => - taskTool.execute(taskArgs, { - agent: task.agent, - messageID: assistantMessage.id, - sessionID, - abort: signal, - callID: part.callID, - extra: { bypassAgentCheck: true }, - messages: msgs, - metadata(val: { title?: string; metadata?: Record }) { - return Effect.runPromise( - Effect.gen(function* () { - part = yield* sessions.updatePart({ - ...part, - type: "tool", - state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) - }), - ) + const attachments = result?.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID, + messageID: assistantMessage.id, + })) + + yield* plugin.trigger( + "tool.execute.after", + { tool: "task", sessionID, callID: part.id, args: taskArgs }, + result, + ) + + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(assistantMessage) + + if (result && part.state.status === "running") { + yield* sessions.updatePart({ + ...part, + state: { + status: "completed", + input: part.state.input, + title: result.title, + metadata: result.metadata, + output: result.output, + attachments, + time: { ...part.state.time, end: Date.now() }, }, - ask(req: any) { - return Effect.runPromise( - permission.ask({ - ...req, - sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }), - ) + } satisfies MessageV2.ToolPart) + } + + if (!result) { + yield* sessions.updatePart({ + ...part, + state: { + status: "error", + error: error ? `Tool execution failed: ${error.message}` : "Tool execution failed", + time: { + start: part.state.status === "running" ? part.state.time.start : Date.now(), + end: Date.now(), + }, + metadata: part.state.status === "pending" ? undefined : part.state.metadata, + input: part.state.input, }, - }), - ).pipe( - Effect.onExit( - Effect.fnUntraced(function* (exit) { - const stop = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) - if (Exit.isFailure(exit) && !stop) { - const squashed = Cause.squash(exit.cause) - err = squashed instanceof Error ? squashed : new Error(String(squashed)) - log.error("subtask execution failed", { error: err, agent: task.agent, description: task.description }) - } - yield* done(Exit.isSuccess(exit) ? exit.value : undefined, stop) - }), - ), - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - () => Effect.succeed(undefined), - ), - ) + } satisfies MessageV2.ToolPart) + } if (!task.command) return