From 3870ea1241166ee6e26f62e2ef533812ea008dda Mon Sep 17 00:00:00 2001 From: Morten Fjord Christensen Date: Sun, 26 Apr 2026 13:30:32 +0200 Subject: [PATCH 01/46] feat: Add queued message editing, cancellation, and wrap-up behavior - Add Queue mode options: Steer, Wrap-up, Queue - Support in-place editing of queued messages - Allow canceling queued messages via X button - Support using ArrowUp to edit latest queued message - Prevent queuing multiple messages in steer/wrap modes - Implement mid-stream interrupt for 'steer' to gracefully abort streams without wiping turn - Implement graceful loop break for 'wrap-up' interrupt mid-tool-call --- ARCHITECTURE.md | 76 +++++++++++++++ STRUCTURE.md | 68 +++++++++++++ packages/app/src/components/prompt-input.tsx | 27 ++++-- .../components/prompt-input/submit.test.ts | 44 +++++++++ .../app/src/components/prompt-input/submit.ts | 7 +- packages/app/src/context/settings.tsx | 13 +-- packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 47 +++++++-- .../composer/session-composer-region.tsx | 4 + .../composer/session-followup-dock.tsx | 9 ++ .../pages/session/use-session-commands.tsx | 16 ++++ .../src/server/routes/instance/session.ts | 34 +++++++ packages/opencode/src/session/processor.ts | 9 ++ packages/opencode/src/session/prompt.ts | 7 ++ packages/opencode/src/session/run-state.ts | 30 +++++- .../opencode/test/session/compaction.test.ts | 2 + .../test/session/processor-effect.test.ts | 95 +++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 59 +++++++++++- .../test/session/snapshot-tool-race.test.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 41 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 36 +++++++ 21 files changed, 597 insertions(+), 30 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 STRUCTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000000..59202d5c3b98 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,76 @@ +# Architecture + +## Pattern Overview + +**Overall:** Effect-based functional architecture with a Monorepo design + +**Key Characteristics:** +- Pervasive use of `Effect.ts` for side-effect management, dependency injection (Layers/Context), and error handling. +- Monorepo using Bun and Turbo for orchestrating multiple packages. +- Strict schema validation using `zod` and `Effect/Schema` (e.g., in `packages/opencode/src/agent/agent.ts`). + +## Layers + +**CLI & Application Layer:** +- Purpose: Provides the command-line interface and orchestrates commands. +- Location: `packages/opencode/src/cli/cmd/` +- Contains: Yargs command configurations and command dispatch logic. +- Depends on: Core services, Data logic, and UI rendering (TUI). +- Used by: User terminal execution. + +**Agent & Session Logic:** +- Purpose: Manages interactions, prompts, and tool execution for LLM agents. +- Location: `packages/opencode/src/agent/`, `packages/opencode/src/session/` +- Contains: Agent schema definitions, processing pipelines, LLM interaction wrappers. +- Depends on: LLM providers, Database storage, Tool registry. + +**Core Utilities (Domain-Agnostic):** +- Purpose: Shared functional patterns and foundational implementations. +- Location: `packages/core/src/` +- Contains: Loggers, filesystem wrappers, Effect.ts runtime extensions, array utilities. +- Used by: All other packages in the monorepo. + +**Storage Layer:** +- Purpose: Persists session data, configuration, and migrations. +- Location: `packages/opencode/src/storage/` +- Contains: Drizzle ORM definitions with Bun-SQLite. + +## Data Flow + +**CLI Invocation Pipeline:** +1. User runs `opencode ` — `packages/opencode/src/index.ts` +2. Arguments parsed via Yargs middleware — `packages/opencode/src/index.ts` +3. Environment initialization and SQLite migration check — `packages/opencode/src/index.ts` +4. Command handler executed (e.g., `RunCommand`) — `packages/opencode/src/cli/cmd/run.ts` + +## Key Abstractions + +**Agent:** +- Purpose: Represents a specialized or general-purpose language model agent. +- Location: `packages/opencode/src/agent/agent.ts` +- Pattern: Schema validation mapped with Effect traits. + +**Effect / Contextual Injection:** +- Purpose: Manages service lifecycles (like databases, LLM clients, and file system readers). +- Location: Found globally (e.g., `packages/opencode/src/session/prompt.ts`) +- Pattern: `Effect.ts` Dependency Injection (Layers). + +## Entry Points + +**Main CLI Executable:** +- Location: `packages/opencode/src/index.ts` +- Triggers: User execution via shell/terminal (`bun run ...`). +- Responsibilities: Bootstrap the environment, validate errors, setup telemetry/logs, and dispatch to specific commands. + +## Error Handling + +**Strategy:** `Effect.ts` structured failure types and bounded errors. +- Extensive use of `Effect`'s native error handling (`Cause`, `Exit`) to track failure origins. +- Fallback global error catchers (`process.on("uncaughtException")`) emitting standardized `Log.Default.error`. +- Differentiated handling for user-facing formatting (`FormatError`) versus internal stack trace debugging. + +## Cross-Cutting Concerns + +**Logging:** Configured globally using `@opencode-ai/core/effect/logger.ts`, emitting to the console or log files. +**Schema Validation:** Pervasive use of Zod wrapped in custom logic (`withStatics`, `@effect/schema`) for strict runtime guarantees on inputs like Agents and Tools. +**Database:** SQLite via Drizzle ORM configured centrally, initializing silently at process start via JSON migrations. diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 000000000000..5ed714a70d3d --- /dev/null +++ b/STRUCTURE.md @@ -0,0 +1,68 @@ +# Codebase Structure + +## Directory Layout + +``` +[project-root]/ +├── packages/ +│ ├── app/ # Web frontend application (SolidJS/Vite) +│ ├── console/ # Console application and related packages +│ ├── core/ # Shared core logic, utilities, and Effect primitives +│ ├── desktop-electron/ # Desktop application (Electron) +│ ├── desktop/ # Desktop application (Tauri) +│ ├── docs/ # Project documentation site +│ ├── opencode/ # Main CLI tool and backend logic (Effect-based) +│ ├── plugin/ # Plugin system +│ ├── sdk/ # SDKs for different languages (js) +│ └── ui/ # Shared UI components +├── .github/ # GitHub Actions and templates +└── .opencode/ # Built-in opencode tools, skills, agents, plugins +``` + +## Directory Purposes + +**packages/opencode:** +- Purpose: Main application logic, agent implementations, and CLI commands. +- Contains: `yargs` CLI setup, database configuration, tools, and session logic. +- Key files: `src/index.ts`, `src/agent/agent.ts`, `src/cli/cmd/*` + +**packages/core:** +- Purpose: Reusable foundational utilities and abstractions. +- Contains: Effect logger, runtime logic, global abstractions, and various general utilities. +- Key files: `src/global.ts`, `src/util/*` + +**packages/app & packages/desktop-electron:** +- Purpose: Frontends for the opencode application (Web and Electron desktop respectively). + +**.opencode:** +- Purpose: Central repository for built-in configurations of the application itself. +- Contains: Default agents, skills, plugins, and commands. + +## Key File Locations + +**Entry Points:** +- `packages/opencode/src/index.ts`: Main entry point for the CLI, initializing Yargs commands and database migration. +- `packages/app/src/index.ts`: Web frontend entry point. +- `packages/desktop-electron/src/index.ts`: Electron desktop app entry point. + +**Configuration:** +- `package.json`: Main workspace configuration defining scripts like `dev:web`, `dev:desktop`, and dependency catalog. + +**Core Logic:** +- `packages/opencode/src/agent/agent.ts`: Schema definitions and core configuration for Agents. +- `packages/opencode/src/session/processor.ts`: Logic for session prompt processing. + +**Tests:** +- Co-located within package `test` directories (e.g., `packages/opencode/test/**/*.test.ts`). + +## Naming Conventions + +**Files:** kebab-case or dot-separated for commands/utils: `run-state.ts`, `agent.ts` +**Directories:** kebab-case: `desktop-electron`, `control-plane` + +## Where to Add New Code + +**New CLI command:** `packages/opencode/src/cli/cmd/[command-name].ts` +**New core utility:** `packages/core/src/util/[util-name].ts` +**New built-in skill:** `.opencode/skills/[skill-name]/` +**Tests:** Co-located within the specific package's `test` directory (e.g., `packages/opencode/test/`) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0a18096164f0..b9b865920f8a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,6 +13,7 @@ import { ImageAttachmentPart, AgentPart, FileAttachmentPart, + ContextItem, } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" @@ -62,10 +63,11 @@ interface PromptInputProps { ref?: (el: HTMLDivElement) => void newSessionWorktree?: string onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + edit?: { id: string; prompt: Prompt; context: ContextItem[] } onEditLoaded?: () => void shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void + onQueue?: (draft: FollowupDraft, editID?: string) => void + onEditLastQueued?: () => boolean onAbort?: () => void onSubmit?: () => void } @@ -257,6 +259,7 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + editID: string | null }>({ popover: null, historyIndex: -1, @@ -265,6 +268,7 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, + editID: null, }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) @@ -1019,6 +1023,7 @@ export const PromptInput: Component = (props) => { setStore("popover", null) setStore("historyIndex", -1) setStore("savedPrompt", null) + setStore("editID", edit.id) prompt.set(edit.prompt, promptLength(edit.prompt)) requestAnimationFrame(() => { editorRef.focus() @@ -1077,14 +1082,14 @@ export const PromptInput: Component = (props) => { queueScroll, promptLength, addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), + resetHistoryNavigation, + setMode, + setPopover: closePopover, + editID: () => store.editID, + clearEditID: () => setStore("editID", null), newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, + shouldQueue: () => props.shouldQueue?.() ?? false, onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, @@ -1225,6 +1230,12 @@ export const PromptInput: Component = (props) => { .map((part) => ("content" in part ? part.content : "")) .join("") const direction = event.key === "ArrowUp" ? "up" : "down" + + if (direction === "up" && textContent === "" && !store.editID && props.onEditLastQueued?.()) { + event.preventDefault() + return + } + if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return if (navigateHistory(direction)) { event.preventDefault() diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 83b6212dcc56..2634a005a234 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -232,6 +232,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, newSessionWorktree: () => selected, onNewSessionWorktreeReset: () => undefined, onSubmit: () => undefined, @@ -269,6 +270,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, newSessionWorktree: () => selected, onNewSessionWorktreeReset: () => undefined, onSubmit: () => undefined, @@ -299,6 +301,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, onSubmit: () => undefined, }) @@ -330,6 +333,7 @@ describe("prompt submit worktree selection", () => { resetHistoryNavigation: () => undefined, setMode: () => undefined, setPopover: () => undefined, + clearEditID: () => undefined, newSessionWorktree: () => selected, onNewSessionWorktreeReset: () => undefined, onSubmit: () => undefined, @@ -342,4 +346,44 @@ describe("prompt submit worktree selection", () => { expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) + + test("queues followup and clears edit id when in normal mode and shouldQueue is true", async () => { + params = { id: "session-1" } + + let queuedDraft: any = undefined + let queuedEditID: any = undefined + let cleared = false + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + editID: () => "test-edit-id", + clearEditID: () => { cleared = true }, + shouldQueue: () => true, + onQueue: (draft, editID) => { + queuedDraft = draft + queuedEditID = editID + }, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(queuedDraft).toBeDefined() + expect(queuedEditID).toBe("test-edit-id") + expect(cleared).toBe(true) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05f0a3ed2cb3..90a4540f476d 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -184,10 +184,12 @@ type PromptSubmitInput = { resetHistoryNavigation: () => void setMode: (mode: "normal" | "shell") => void setPopover: (popover: "at" | "slash" | null) => void + editID?: Accessor + clearEditID: () => void newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void shouldQueue?: Accessor - onQueue?: (draft: FollowupDraft) => void + onQueue?: (draft: FollowupDraft, editID?: string) => void onAbort?: () => void onSubmit?: () => void } @@ -409,6 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { prompt.reset() input.setMode("normal") input.setPopover(null) + input.clearEditID() } const restoreInput = () => { @@ -425,7 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { - input.onQueue?.(draft) + input.onQueue?.(draft, input.editID?.() ?? undefined) clearContext() clearInput() return diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index be2fb49d7e0c..0ccaa2c13303 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -22,7 +22,7 @@ export interface Settings { general: { autoSave: boolean releaseNotes: boolean - followup: "queue" | "steer" + followup: "queue" | "steer" | "wrap" showFileTree: boolean showNavigation: boolean showSearch: boolean @@ -162,11 +162,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) }) - createEffect(() => { - if (store.general?.followup !== "queue") return - setStore("general", "followup", "steer") - }) - return { ready, get current() { @@ -182,11 +177,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("general", "releaseNotes", value) }, followup: withFallback( - () => (store.general?.followup === "queue" ? "steer" : store.general?.followup), + () => store.general?.followup, defaultSettings.general.followup, ), - setFollowup(value: "queue" | "steer") { - setStore("general", "followup", value === "queue" ? "steer" : value) + setFollowup(value: "queue" | "steer" | "wrap") { + setStore("general", "followup", value) }, showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree), setShowFileTree(value: boolean) { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7326f7c8bb6b..25890be00a67 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -743,6 +743,7 @@ export const dict = { "settings.general.row.followup.title": "Follow-up behavior", "settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue", "settings.general.row.followup.option.queue": "Queue", + "settings.general.row.followup.option.wrap": "Wrap-up", "settings.general.row.followup.option.steer": "Steer", "settings.general.row.showFileTree.title": "File tree", "settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1345e355eb25..12e1ecbe794f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1554,7 +1554,9 @@ export default function Page() { const queueEnabled = createMemo(() => { const id = params.id if (!id) return false - return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() + const mode = settings.general.followup() + if ((mode === "steer" || mode === "wrap") && queuedFollowups().length >= 1) return false + return busy(id) && !composer.blocked() && !isChildSession() }) const followupText = (item: FollowupDraft) => { @@ -1574,13 +1576,29 @@ export default function Page() { return `[${language.t("common.attachment")}]` } - const queueFollowup = (draft: FollowupDraft) => { - setFollowup("items", draft.sessionID, (items) => [ - ...(items ?? []), - { id: Identifier.ascending("message"), ...draft }, - ]) + const queueFollowup = (draft: FollowupDraft, editID?: string) => { + setFollowup("items", draft.sessionID, (items) => { + const nextItems = items ? [...items] : [] + if (editID) { + const index = nextItems.findIndex((i) => i.id === editID) + if (index !== -1) { + nextItems[index] = { ...nextItems[index], ...draft } + return nextItems + } + } + return [...nextItems, { id: Identifier.ascending("message"), ...draft }] + }) setFollowup("failed", draft.sessionID, undefined) setFollowup("paused", draft.sessionID, undefined) + + const mode = settings.general.followup() + if (!editID && mode === "steer") { + // In steer mode, we request the agent to halt + // The actual queued message will be sent automatically when the agent becomes idle + void sdk.client.session.interrupt({ sessionID: draft.sessionID, type: mode }) + } else if (!editID && mode === "wrap") { + void sdk.client.session.interrupt({ sessionID: draft.sessionID, type: mode }) + } } const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) @@ -1602,7 +1620,6 @@ export default function Page() { const item = queuedFollowups().find((entry) => entry.id === id) if (!item) return - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) setFollowup("edit", sessionID, { id: item.id, @@ -1611,6 +1628,15 @@ export default function Page() { }) } + const cancelFollowup = (id: string) => { + const sessionID = params.id + if (!sessionID) return + if (followupBusy(sessionID)) return + + setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) + setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) + } + const clearFollowupEdit = () => { const id = params.id if (!id) return @@ -1916,7 +1942,14 @@ export default function Page() { void sendFollowup(params.id!, id, { manual: true }) }, onEdit: editFollowup, + onCancel: cancelFollowup, onEditLoaded: clearFollowupEdit, + onEditLastQueued: () => { + const items = queuedFollowups() + if (items.length === 0) return false + editFollowup(items[items.length - 1].id) + return true + }, } : undefined } diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 60447566ed01..7bf2ca3209bd 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -35,7 +35,9 @@ export function SessionComposerRegion(props: { onAbort: () => void onSend: (id: string) => void onEdit: (id: string) => void + onCancel: (id: string) => void onEditLoaded: () => void + onEditLastQueued: () => boolean } revert?: { items: { id: string; text: string }[] @@ -244,6 +246,7 @@ export function SessionComposerRegion(props: { sending={props.followup!.sending} onSend={props.followup!.onSend} onEdit={props.followup!.onEdit} + onCancel={props.followup!.onCancel} /> void onEdit: (id: string) => void + onCancel: (id: string) => void }) { const language = useLanguage() const [store, setStore] = createStore({ @@ -99,6 +100,14 @@ export function SessionFollowupDock(props: { > {language.t("session.followupDock.edit")} + props.onCancel(item.id)} + aria-label="Cancel" + /> )} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 922299bec198..749a59d71a3d 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -396,6 +396,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !params.id || visibleUserMessages().length === 0, onSelect: undo, }), + sessionCommand({ + id: "session.toggle-queue-mode", + title: "Toggle Queue Mode", + keybind: "mod+shift+q", + onSelect: () => { + const current = settings.general.followup() + if (current === "steer") settings.general.setFollowup("wrap") + else if (current === "wrap") settings.general.setFollowup("queue") + else settings.general.setFollowup("steer") + + showToast({ + title: "Queue Mode Changed", + description: `Queue mode is now: ${settings.general.followup()}`, + }) + }, + }), sessionCommand({ id: "session.redo", title: language.t("command.session.redo"), diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 52a8034672e8..fd688d74f05a 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -998,6 +998,40 @@ export const SessionRoutes = lazy(() => return yield* svc.shell({ ...body, sessionID }) }), ) + .post( + "/:sessionID/interrupt", + describeRoute({ + summary: "Interrupt session", + description: "Interrupt a running session with a specific behavior type.", + operationId: "session.interrupt", + responses: { + 200: { + description: "Session interrupted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", z.object({ type: z.enum(["steer", "wrap"]) })), + async (c) => + jsonRequest("SessionRoutes.interrupt", c, function* () { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const state = yield* SessionRunState.Service + yield* state.requestInterrupt(sessionID, body.type) + return true + }), + ) .post( "/:sessionID/revert", describeRoute({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 21f9329c6fce..68af8796f2a9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -14,6 +14,7 @@ import { PartID } from "./schema" import type { SessionID } from "./schema" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" +import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" import type { Provider } from "@/provider" import { Question } from "@/question" @@ -90,6 +91,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionSummary.Service | SessionStatus.Service + | SessionRunState.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -104,6 +106,7 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const runState = yield* SessionRunState.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -556,6 +559,11 @@ export const layer: Layer.Layer< Effect.onInterrupt(() => Effect.gen(function* () { aborted = true + const interruptType = yield* runState.getInterrupt(ctx.sessionID) + if (interruptType === "steer") { + yield* runState.clearInterrupt(ctx.sessionID) + return // Don't halt, just return so we can process the steer + } if (!ctx.assistantMessage.error) { yield* halt(new DOMException("Aborted", "AbortError")) } @@ -611,6 +619,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(SessionRunState.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), ), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 600eb42f795e..6f1a04478ddf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1517,6 +1517,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (result === "stop") return "break" as const + + const interrupt = yield* state.getInterrupt(sessionID) + if (interrupt === "wrap") { + yield* state.clearInterrupt(sessionID) + return "break" as const + } + if (result === "compact") { yield* compaction.create({ sessionID, diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 7a106f8a4ca4..db27e66f4767 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -9,6 +9,9 @@ import { SessionStatus } from "./status" export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly requestInterrupt: (sessionID: SessionID, type: "steer" | "wrap") => Effect.Effect + readonly clearInterrupt: (sessionID: SessionID) => Effect.Effect + readonly getInterrupt: (sessionID: SessionID) => Effect.Effect<"steer" | "wrap" | undefined> readonly ensureRunning: ( sessionID: SessionID, onInterrupt: Effect.Effect, @@ -32,6 +35,7 @@ export const layer = Layer.effect( Effect.fn("SessionRunState.state")(function* () { const scope = yield* Scope.Scope const runners = new Map>() + const interrupts = new Map() yield* Effect.addFinalizer( Effect.fnUntraced(function* () { yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { @@ -39,9 +43,10 @@ export const layer = Layer.effect( discard: true, }) runners.clear() + interrupts.clear() }), ) - return { runners, scope } + return { runners, interrupts, scope } }), ) @@ -83,6 +88,27 @@ export const layer = Layer.effect( yield* existing.cancel }) + const requestInterrupt = Effect.fn("SessionRunState.requestInterrupt")(function* ( + sessionID: SessionID, + type: "steer" | "wrap", + ) { + const data = yield* InstanceState.get(state) + data.interrupts.set(sessionID, type) + if (type === "steer") { + yield* cancel(sessionID) + } + }) + + const clearInterrupt = Effect.fn("SessionRunState.clearInterrupt")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + data.interrupts.delete(sessionID) + }) + + const getInterrupt = Effect.fn("SessionRunState.getInterrupt")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + return data.interrupts.get(sessionID) + }) + const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( sessionID: SessionID, onInterrupt: Effect.Effect, @@ -99,7 +125,7 @@ export const layer = Layer.effect( return yield* (yield* runner(sessionID, onInterrupt)).startShell(work) }) - return Service.of({ assertNotBusy, cancel, ensureRunning, startShell }) + return Service.of({ assertNotBusy, cancel, requestInterrupt, clearInterrupt, getInterrupt, ensureRunning, startShell }) }), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 1b2b120b6164..248707457464 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -18,6 +18,7 @@ import { Session as SessionNs } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionRunState } from "../../src/session/run-state" import { SessionSummary } from "../../src/session/summary" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "../../src/provider" @@ -285,6 +286,7 @@ function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fa Layer.provide(Permission.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(SessionRunState.defaultLayer), Layer.provide(status), Layer.provide(bus), Layer.provide(config), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index fee42a9397a1..ad300cec8dd5 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -16,6 +16,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionProcessor } from "../../src/session/processor" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionRunState } from "../../src/session/run-state" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util" @@ -164,6 +165,7 @@ const deps = Layer.mergeAll( Config.defaultLayer, LLM.defaultLayer, Provider.defaultLayer, + SessionRunState.defaultLayer, status, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( @@ -840,3 +842,96 @@ it.live("session.processor effect tests mark interruptions aborted without manua { git: true, config: (url) => providerCfg(url) }, ), ) + +it.live("session.processor effect tests allow graceful steer interrupt", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + const sts = yield* SessionStatus.Service + const runState = yield* SessionRunState.Service + + // Provide partial text then hang to wait for interrupt + yield* llm.push( + raw({ + head: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { content: "part1 " } }], + }, + ], + tail: [], + tailDelay: 100, + hang: true, + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "steer") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + 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: "steer" }], + tools: {}, + }) + .pipe(Effect.forkChild) + + // Wait a bit to ensure it has received the start of the stream + yield* llm.wait(1) + yield* Effect.sleep("250 millis") + + // Signal steer interrupt + yield* runState.requestInterrupt(chat.id, "steer") + yield* Fiber.interrupt(run) // this happens normally via runState.cancel + + const exit = yield* Fiber.await(run) + const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id }) + const state = yield* sts.get(chat.id) + + // Should return a success string not a failure, or fail cleanly without an error flag + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } + + // No error should be written because it's a steer interrupt + expect(handle.message.error).toBeUndefined() + expect(stored.info.role).toBe("assistant") + if (stored.info.role === "assistant") { + expect(stored.info.error).toBeUndefined() + } + + // State should be idle + expect(state).toMatchObject({ type: "idle" }) + + // Check that the partial text was saved + const parts = MessageV2.parts(msg.id) + expect(parts.some((part) => part.type === "text" && part.text.includes("part1"))).toBe(true) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7e33777463d8..31b4e43a4165 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -182,7 +182,7 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, @@ -1907,3 +1907,60 @@ it.live( ), 30_000, ) + +it.live( + "wrap interrupt correctly breaks out of the loop after current step finishes", + () => + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const runState = yield* SessionRunState.Service + + // Queue two assistant responses. First response finishes with a tool call. + // If the loop continues, it would pull the second response. + // By sending a wrap interrupt, the loop should break after the first response finishes! + yield* llm.push( + reply().text("First turn!").tool("read", { filePath: "test" }).stop().item(), + ) + yield* llm.push( + reply().hang().item() + ) + + const session = yield* sessions.create({ + permission: [{ permission: "read", pattern: "*", action: "allow" }], + }) + + // Signal wrap interrupt immediately so it's registered before the first loop finishes + yield* runState.requestInterrupt(session.id, "wrap") + + const run = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "wrap test" }], + }) + .pipe(Effect.forkChild) + + // The run should finish successfully on its own because the first LLM call stops, + // executes the tool, then the loop checks for "wrap" interrupt and breaks gracefully! + const exit = yield* Fiber.await(run) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const assistantMsg = exit.value + expect(assistantMsg.info.role).toBe("assistant") + + const parts = assistantMsg.parts + expect(parts.some((p) => p.type === "text" && p.text.includes("First turn!"))).toBe(true) + expect(parts.some((p) => p.type === "tool" && p.tool === "read")).toBe(true) + } + + // llm should have been called twice: once for the title generation, once for the main stream. + expect(yield* llm.calls).toBe(2) + }), + { git: true, config: (url) => providerCfg(url) }, + ), + 30_000, +) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 269c23148b8a..725eea122c14 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -135,7 +135,7 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6248eb8e4d64..b324fead236d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -131,6 +131,8 @@ import type { SessionGetResponses, SessionInitErrors, SessionInitResponses, + SessionInterruptErrors, + SessionInterruptResponses, SessionListResponses, SessionMessageErrors, SessionMessageResponses, @@ -2480,6 +2482,45 @@ export class Session2 extends HeyApiClient { }) } + /** + * Interrupt session + * + * Interrupt a running session with a specific behavior type. + */ + public interrupt( + parameters: { + sessionID: string + directory?: string + workspace?: string + type?: "steer" | "wrap" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "type" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/interrupt", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Revert message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 40e661b46a2d..3334d1a6812a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4137,6 +4137,42 @@ export type SessionShellResponses = { export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] +export type SessionInterruptData = { + body?: { + type: "steer" | "wrap" + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/interrupt" +} + +export type SessionInterruptErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionInterruptError = SessionInterruptErrors[keyof SessionInterruptErrors] + +export type SessionInterruptResponses = { + /** + * Session interrupted successfully + */ + 200: boolean +} + +export type SessionInterruptResponse = SessionInterruptResponses[keyof SessionInterruptResponses] + export type SessionRevertData = { body?: { messageID: string From 66837827db45efd9e5f1dc6567056bbe3291afc9 Mon Sep 17 00:00:00 2001 From: Morten Fjord Christensen Date: Sun, 26 Apr 2026 13:53:04 +0200 Subject: [PATCH 02/46] fix: test typing error --- packages/opencode/test/session/processor-effect.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index ad300cec8dd5..5689507a8831 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -867,7 +867,6 @@ it.live("session.processor effect tests allow graceful steer interrupt", () => }, ], tail: [], - tailDelay: 100, hang: true, }), ) From f5d69794da72f9a69163a36a641d48929fdd2eb6 Mon Sep 17 00:00:00 2001 From: Morten Fjord Christensen Date: Sun, 26 Apr 2026 13:59:21 +0200 Subject: [PATCH 03/46] chore: remove accidentally committed markdown files --- ARCHITECTURE.md | 76 ------------------------------------------------- STRUCTURE.md | 68 ------------------------------------------- 2 files changed, 144 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 STRUCTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 59202d5c3b98..000000000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,76 +0,0 @@ -# Architecture - -## Pattern Overview - -**Overall:** Effect-based functional architecture with a Monorepo design - -**Key Characteristics:** -- Pervasive use of `Effect.ts` for side-effect management, dependency injection (Layers/Context), and error handling. -- Monorepo using Bun and Turbo for orchestrating multiple packages. -- Strict schema validation using `zod` and `Effect/Schema` (e.g., in `packages/opencode/src/agent/agent.ts`). - -## Layers - -**CLI & Application Layer:** -- Purpose: Provides the command-line interface and orchestrates commands. -- Location: `packages/opencode/src/cli/cmd/` -- Contains: Yargs command configurations and command dispatch logic. -- Depends on: Core services, Data logic, and UI rendering (TUI). -- Used by: User terminal execution. - -**Agent & Session Logic:** -- Purpose: Manages interactions, prompts, and tool execution for LLM agents. -- Location: `packages/opencode/src/agent/`, `packages/opencode/src/session/` -- Contains: Agent schema definitions, processing pipelines, LLM interaction wrappers. -- Depends on: LLM providers, Database storage, Tool registry. - -**Core Utilities (Domain-Agnostic):** -- Purpose: Shared functional patterns and foundational implementations. -- Location: `packages/core/src/` -- Contains: Loggers, filesystem wrappers, Effect.ts runtime extensions, array utilities. -- Used by: All other packages in the monorepo. - -**Storage Layer:** -- Purpose: Persists session data, configuration, and migrations. -- Location: `packages/opencode/src/storage/` -- Contains: Drizzle ORM definitions with Bun-SQLite. - -## Data Flow - -**CLI Invocation Pipeline:** -1. User runs `opencode ` — `packages/opencode/src/index.ts` -2. Arguments parsed via Yargs middleware — `packages/opencode/src/index.ts` -3. Environment initialization and SQLite migration check — `packages/opencode/src/index.ts` -4. Command handler executed (e.g., `RunCommand`) — `packages/opencode/src/cli/cmd/run.ts` - -## Key Abstractions - -**Agent:** -- Purpose: Represents a specialized or general-purpose language model agent. -- Location: `packages/opencode/src/agent/agent.ts` -- Pattern: Schema validation mapped with Effect traits. - -**Effect / Contextual Injection:** -- Purpose: Manages service lifecycles (like databases, LLM clients, and file system readers). -- Location: Found globally (e.g., `packages/opencode/src/session/prompt.ts`) -- Pattern: `Effect.ts` Dependency Injection (Layers). - -## Entry Points - -**Main CLI Executable:** -- Location: `packages/opencode/src/index.ts` -- Triggers: User execution via shell/terminal (`bun run ...`). -- Responsibilities: Bootstrap the environment, validate errors, setup telemetry/logs, and dispatch to specific commands. - -## Error Handling - -**Strategy:** `Effect.ts` structured failure types and bounded errors. -- Extensive use of `Effect`'s native error handling (`Cause`, `Exit`) to track failure origins. -- Fallback global error catchers (`process.on("uncaughtException")`) emitting standardized `Log.Default.error`. -- Differentiated handling for user-facing formatting (`FormatError`) versus internal stack trace debugging. - -## Cross-Cutting Concerns - -**Logging:** Configured globally using `@opencode-ai/core/effect/logger.ts`, emitting to the console or log files. -**Schema Validation:** Pervasive use of Zod wrapped in custom logic (`withStatics`, `@effect/schema`) for strict runtime guarantees on inputs like Agents and Tools. -**Database:** SQLite via Drizzle ORM configured centrally, initializing silently at process start via JSON migrations. diff --git a/STRUCTURE.md b/STRUCTURE.md deleted file mode 100644 index 5ed714a70d3d..000000000000 --- a/STRUCTURE.md +++ /dev/null @@ -1,68 +0,0 @@ -# Codebase Structure - -## Directory Layout - -``` -[project-root]/ -├── packages/ -│ ├── app/ # Web frontend application (SolidJS/Vite) -│ ├── console/ # Console application and related packages -│ ├── core/ # Shared core logic, utilities, and Effect primitives -│ ├── desktop-electron/ # Desktop application (Electron) -│ ├── desktop/ # Desktop application (Tauri) -│ ├── docs/ # Project documentation site -│ ├── opencode/ # Main CLI tool and backend logic (Effect-based) -│ ├── plugin/ # Plugin system -│ ├── sdk/ # SDKs for different languages (js) -│ └── ui/ # Shared UI components -├── .github/ # GitHub Actions and templates -└── .opencode/ # Built-in opencode tools, skills, agents, plugins -``` - -## Directory Purposes - -**packages/opencode:** -- Purpose: Main application logic, agent implementations, and CLI commands. -- Contains: `yargs` CLI setup, database configuration, tools, and session logic. -- Key files: `src/index.ts`, `src/agent/agent.ts`, `src/cli/cmd/*` - -**packages/core:** -- Purpose: Reusable foundational utilities and abstractions. -- Contains: Effect logger, runtime logic, global abstractions, and various general utilities. -- Key files: `src/global.ts`, `src/util/*` - -**packages/app & packages/desktop-electron:** -- Purpose: Frontends for the opencode application (Web and Electron desktop respectively). - -**.opencode:** -- Purpose: Central repository for built-in configurations of the application itself. -- Contains: Default agents, skills, plugins, and commands. - -## Key File Locations - -**Entry Points:** -- `packages/opencode/src/index.ts`: Main entry point for the CLI, initializing Yargs commands and database migration. -- `packages/app/src/index.ts`: Web frontend entry point. -- `packages/desktop-electron/src/index.ts`: Electron desktop app entry point. - -**Configuration:** -- `package.json`: Main workspace configuration defining scripts like `dev:web`, `dev:desktop`, and dependency catalog. - -**Core Logic:** -- `packages/opencode/src/agent/agent.ts`: Schema definitions and core configuration for Agents. -- `packages/opencode/src/session/processor.ts`: Logic for session prompt processing. - -**Tests:** -- Co-located within package `test` directories (e.g., `packages/opencode/test/**/*.test.ts`). - -## Naming Conventions - -**Files:** kebab-case or dot-separated for commands/utils: `run-state.ts`, `agent.ts` -**Directories:** kebab-case: `desktop-electron`, `control-plane` - -## Where to Add New Code - -**New CLI command:** `packages/opencode/src/cli/cmd/[command-name].ts` -**New core utility:** `packages/core/src/util/[util-name].ts` -**New built-in skill:** `.opencode/skills/[skill-name]/` -**Tests:** Co-located within the specific package's `test` directory (e.g., `packages/opencode/test/`) From 7cc85065c72ab493c9e4c4446375be3d909b88c1 Mon Sep 17 00:00:00 2001 From: Morten Fjord Christensen Date: Sun, 26 Apr 2026 14:04:51 +0200 Subject: [PATCH 04/46] feat: clearing queued messages cancels Steer/Wrap interrupt --- packages/app/src/pages/session.tsx | 8 +++++++- packages/opencode/src/server/routes/instance/session.ts | 8 ++++++-- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 12e1ecbe794f..cf55b3761b96 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1633,7 +1633,13 @@ export default function Page() { if (!sessionID) return if (followupBusy(sessionID)) return - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) + setFollowup("items", sessionID, (items) => { + const nextItems = (items ?? []).filter((entry) => entry.id !== id) + if (nextItems.length === 0) { + void sdk.client.session.interrupt({ sessionID, type: "clear" }) + } + return nextItems + }) setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) } diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index fd688d74f05a..9ab87c2d38c6 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -1022,13 +1022,17 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", z.object({ type: z.enum(["steer", "wrap"]) })), + validator("json", z.object({ type: z.enum(["steer", "wrap", "clear"]) })), async (c) => jsonRequest("SessionRoutes.interrupt", c, function* () { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") const state = yield* SessionRunState.Service - yield* state.requestInterrupt(sessionID, body.type) + if (body.type === "clear") { + yield* state.clearInterrupt(sessionID) + } else { + yield* state.requestInterrupt(sessionID, body.type) + } return true }), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b324fead236d..5dcab815f7a5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2492,7 +2492,7 @@ export class Session2 extends HeyApiClient { sessionID: string directory?: string workspace?: string - type?: "steer" | "wrap" + type?: "steer" | "wrap" | "clear" }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3334d1a6812a..46ad1f7fe796 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4139,7 +4139,7 @@ export type SessionShellResponse = SessionShellResponses[keyof SessionShellRespo export type SessionInterruptData = { body?: { - type: "steer" | "wrap" + type: "steer" | "wrap" | "clear" } path: { sessionID: string From d6eae4635ab452c2248b31bbac8fd3acde222fd8 Mon Sep 17 00:00:00 2001 From: Morten Fjord Christensen Date: Sun, 26 Apr 2026 14:51:05 +0200 Subject: [PATCH 05/46] feat: Add steering and wrapping up status indicators --- .../app/src/components/settings-general.tsx | 24 +++++++++++++++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 21 ++++++++++++++++ packages/opencode/src/config/config.ts | 3 +++ packages/opencode/src/config/keybinds.ts | 1 + packages/opencode/src/session/status.ts | 6 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 10 ++++++++ packages/ui/src/components/session-turn.tsx | 10 ++++++-- packages/ui/src/i18n/en.ts | 2 ++ 8 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index f38442379d88..1ded37681c65 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -234,6 +234,30 @@ export const SettingsGeneral: Component = () => { /> + +