# Diagnosis: "build agent empty input" after orchestrator output **Status:** Diagnosis only — no code change yet. **Date:** 2026-07-19 **Related PR:** #818 (`fix/preset-tui-slash-command`) — same root class as the original `/preset` fix. **Suspected sibling bug reported by user:** During `superpowers` / `brainstorm` skill conversations, when the orchestrator asks for confirmation or work is interrupted (subagent completes, background task finishes), a `build` agent turn sometimes appears with an empty user input. ## TL;DR The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input." **Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is an opt-in beta via `backgroundJobs.continueOnIdle: true`, and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below). ## Root cause (causal chain, cross-validated) 1. **Orchestrator enters input-wait.** After emitting a confirmation question (skill flow), the assistant turn finishes. opencode's per-session Runner transitions to `Idle` (`packages/opencode/src/effect/runner.ts:115-138`, `packages/opencode/src/session/run-state.ts:60-63`). The session is no longer "busy" from the Runner's perspective. 2. **Plugin hook fires `promptAsync` with a synthetic part and historically no `agent` field.** Remaining agent-less sites: - `src/interview/service.ts:622, 871, 933, 1007` — interview/skill flow injections (matches "brainstorm skill flow"). - Continuation nudge (`continuation-evaluator.ts`) previously matched "subagent completes" / "background task finishes"; it now sets `agent: 'orchestrator'` and only runs when `continueOnIdle` is enabled. 3. **opencode does not guard `promptAsync` against busy/input-wait state.** The HTTP handler at `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329` does not call `assertNotBusy` and does not consult the Question service. It proceeds straight to `promptSvc.prompt`. The Runner, being `Idle`, immediately `startRun`s the new turn (`runner.ts:131-134`). No queue, no reject, no cancellation of the pending question. 4. **Agent resolves to `build`.** `packages/opencode/src/session/prompt.ts:636-637`: ```ts const agentName = input.agent const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() ``` When `input.agent` is omitted, opencode uses `agents.defaultInfo()` (`packages/opencode/src/agent/agent.ts:328-340`), which returns the first visible `mode: "primary"` agent — **`build`** (declared first in the agent registry, `agent.ts:141-155`). omos attempts to set `default_agent = "orchestrator"` via its `config` hook (`src/index.ts:546-551`), but only when `default_agent` is absent. `build` is selected whenever: - `config.setDefaultAgent === false` (plugin config disables it) - The user's `opencode.json` already sets a different `default_agent` - The `config` hook didn't run or didn't apply (SDK/runtime version skew: plugin built against `@opencode-ai/sdk` v1.4.3, installed runtime v1.18.3) - The orchestrator agent isn't registered at config-load time 5. **"Empty input" is the synthetic flag's visual effect.** `synthetic: true` only controls TUI visibility. `packages/opencode/src/session/message-v2.ts:206` still includes synthetic text parts in model messages (the only filters are `!part.ignored` and `part.text !== ""`; there is no `synthetic` filter when building model messages). The user sees the `build` agent respond to a turn with no visible user message — perceived as "empty input." (`createInternalAgentTextPart` appends a `\n` marker, so the text is non-empty from the LLM's perspective.) 6. **Session agent is durably corrupted.** `packages/opencode/src/session/prompt.ts:672-689` compares `current.agent !== info.agent` and, if they differ, calls `sessions.setAgentModel({ agent: info.agent, ... })`. A single agent-less `promptAsync` that resolves to `build` **permanently rewrites the session's agent to `build`** — every subsequent turn also routes to `build` until explicitly reset. ## Confirmed-affected call sites | File:line | Trigger | Body omits `agent`? | Gate | |---|---|---|---| | `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when the opt-in beta `backgroundJobs.continueOnIdle` is `true` | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits | | `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state | | `src/interview/service.ts:871` | User submits interview chat | **Yes** | same | | `src/interview/service.ts:933` | User submits interview answer | **Yes** | same | | `src/interview/service.ts:1007` | User submits interview comment | **Yes** | same | | `src/interview/service.ts:504` | Interview URL notification | **Yes** (but `noReply: true`, non-synthetic text) | none | | `src/tools/smartfetch/secondary-model.ts:252` | Smartfetch secondary model query | **Yes** | none | ## Correct pattern (for comparison) `src/hooks/foreground-fallback/index.ts:635-639` explicitly includes the agent: ```ts const promptBody = { parts: lastUser.parts, model: ref, ...(agentName ? { agent: agentName } : {}), }; ``` This is the pattern every `promptAsync` caller in omos should follow. ## Why the `hasInputWait` gate in task-session-manager is not sufficient The gate exists and works in the common case (see `continuation-evaluator.ts` and `task-session-manager/index.test.ts` continuation cases). Notes: 1. **Continuation is opt-in beta.** `backgroundJobs.continueOnIdle` defaults to `false`; set it to `true` to enable continuation SDK calls. Idle reconciliation remains active either way. When enabled, a process-local reserve/commit gate allows at most one `promptAsync` per session epoch between real user messages. 2. **Documented race window (when enabled).** `IDLE_RECONCILE_DELAY_MS = 2_000`. The idle-reconciliation comment admits late completions can still race the window. If `session.idle` fires and the timer expires before `question.asked` is delivered, and the SDK liveness reads resolve before the wait is tracked, a nudge can still fire. 3. **Input-wait is not the only trigger.** The interview/skill path (`src/interview/service.ts`) does **not** consult `hasInputWait` at all — it injects on user dashboard actions, which can happen while the orchestrator is mid-question. 4. **Continuation path sets `agent: 'orchestrator'`.** The historical missing- `agent` misroute on the continuation nudge is fixed in `continuation-evaluator.ts`. Remaining agent-less `promptAsync` call sites are outside that path (interview/smartfetch below). ## Why this is the same class as the #818 `/preset` fix #818's original bug: `/preset` used `createInternalAgentTextPart()` to trigger an LLM turn that was invisible in the TUI (`synthetic: true`). The fix moved `/preset` to pure TUI dialogs (`src/tui-preset.ts` uses only `api.ui.dialog` / `DialogSelect` / `DialogPrompt` / `DialogConfirm` — no `promptAsync`). This bug: other hooks still use the same `createInternalAgentTextPart` + `promptAsync` pattern, and additionally omit the `agent` field, so the invisible turn routes to `build` instead of the orchestrator. Same shape: a synthetic part starting an invisible turn. Different symptom: `build` agent instead of orchestrator. ## Fix directions (not implemented — awaiting decision) ### Minimal fix Continuation already passes `agent: 'orchestrator'`. Add the same to remaining agent-less `promptAsync` bodies: - `src/interview/service.ts` (dashboard/chat/answer/comment injectors) This ensures interview injections always route to the orchestrator regardless of opencode's `default_agent` resolution, eliminating the path to `build`. ### Hardening (optional, larger scope) 1. **Input-wait guard on the interview/skill path.** Consult `hasInputWait` (or an equivalent signal) before injecting in `src/interview/service.ts`. Do not inject while the orchestrator is waiting for user input. 2. **Post-injection agent assertion.** After each `promptAsync`, assert `current.agent` was not changed out from under the orchestrator; if it was, restore it via `setAgentModel`. 3. **Investigate the `default_agent` application reliability** on opencode v1.18.x. The plugin was built against `@opencode-ai/sdk` v1.4.3; the installed runtime is v1.18.3. The `config` hook's `default_agent = 'orchestrator'` mutation may not be applied reliably under this skew. (Note: #799 tracks the package upgrade.) 4. **Shrink or eliminate the `IDLE_RECONCILE_DELAY_MS` race** for sessions that have a pending `question.asked` / `permission.asked`. ## Evidence index ### omos source - **Continuation nudge (fixed agent + opt-in beta + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts` - **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007` - **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639` - **omos sets `default_agent` only when absent:** `src/index.ts:546-551` - **`createInternalAgentTextPart` produces `synthetic: true`:** `src/utils/internal-initiator.ts:9-21` - **`CONTINUATION_NUDGE` is non-empty:** `src/hooks/task-session-manager/continuation-evaluator.ts` - **`hasInputWait` / continuation gates:** `continuation-evaluator.ts`, `input-wait-tracker.ts` - **`IDLE_RECONCILE_DELAY_MS` race window:** `src/hooks/task-session-manager/index.ts` (`IDLE_RECONCILE_DELAY_MS`) - **`disableDefaultAgents` preserves `build` and `plan`:** `src/cli/config-io.ts:564-600` ### opencode source (`anomalyco/opencode` @ `dev`) - **`promptAsync` HTTP handler (no busy/input-wait guard):** `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329` - **`prompt` HTTP handler (no guard):** same file, `:295-309` - **Internal `prompt` always starts a new turn:** `packages/opencode/src/session/prompt.ts:1052-1071` - **Agent selection: `input.agent ?? defaultInfo()`:** `packages/opencode/src/session/prompt.ts:636-637` - **`defaultInfo()` → `default_agent` or first visible primary:** `packages/opencode/src/agent/agent.ts:328-340` - **`build` agent definition (default primary, first in registry):** `packages/opencode/src/agent/agent.ts:141-155` - **Destructive `setAgentModel` overwrite on agent change:** `packages/opencode/src/session/prompt.ts:672-689` - **Synthetic parts included in model messages:** `packages/opencode/src/session/message-v2.ts:206` - **Runner `ensureRunning` (Idle = run now, no queue):** `packages/opencode/src/effect/runner.ts:115-138` - **Runner Idle transition on turn end:** `packages/opencode/src/session/run-state.ts:60-63` - **`assertNotBusy` (NOT used by prompt/promptAsync):** `packages/opencode/src/session/run-state.ts:71-75` ### SDK types - **`default_agent` doc: "Falls back to 'build' if not set or invalid":** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1255-1257` - **`SessionPromptAsyncData.body.agent?` is optional:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:3241-3269` - **`build` is a built-in agent:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1273-1279` ## Open questions for the fix 1. Should the fix be a new PR, or amended into #818? (#818 is currently `MERGEABLE` / `CLEAN` / CI green; amending widens its scope and may delay merge.) 2. Is the interview/skill path expected to always route to the orchestrator, or could it intentionally target a different agent in some flows? 3. Should we also harden the `default_agent` application (fix direction #3) as part of this work, or track it separately under #799? --- This report is diagnostic only. No code was changed. The fix awaits the user's decision on scope and PR strategy.