Skip to content

V2: pre-runner errors are not surfaced as assistant message failures #34839

Closed

Description

@rekram1-node

Summary

In V2, some errors that V1 surfaced as assistant-message failures are now logged only and never propagated into the session transcript/event stream. Downstream clients and channel adapters then have nothing to render, so the user sees silence even though the runtime logged a failure.

Observed Behavior

A personal OpenCode Agent runtime received messages over Sendblue/Discord and started typing, but replies never appeared. The only visible signal was in the service log:

Failed to drain Session SessionRunnerModel.ModelUnavailableError: Model unavailable: opencode/gpt-5.5

No assistant error message or generic failure notice was emitted to the channel.

Why This Happens

The failure occurs before the LLM event publisher exists. In packages/core/src/session/runner/llm.ts, models.resolve(session) runs during pre-stream setup. If that throws, the code exits before creating the publisher and before a durable SessionEvent.Step.Failed can be written.

Later, packages/core/src/session/execution/local.ts logs Failed to drain Session, but it does not convert that failure into a durable session event.

This means projections/subscribers only see the admitted user input and typing state; they never see a failed assistant step.

Expected Behavior

Pre-stream/pre-runner failures should still become durable assistant failure events, similar to V1 behavior where assistant-message errors were visible to the user. At minimum, these failures should produce a Step.Failed-like event so clients and integrations can render a generic failure message.

Impact

  • TUI/channel clients can hang silently after typing starts.
  • Discord/Slack/Sendblue integrations cannot notify the user because they only publish from durable session events.
  • Operators must inspect server logs to learn that a turn failed.

Possible Fix Direction

Wrap pre-stream setup in the V2 session runner, especially model resolution and other setup before the LLM publisher is created, so failures are captured into a durable assistant failure event before returning/logging the error.

Alternatively, have SessionExecution translate runner failures into a durable failure event, but the runner likely has better context for assistant message identity and event shape.

Notes

This was observed with ModelUnavailableError, but the issue is broader: any failure before the publisher/event stream exists can be logged without becoming visible user-facing state.

Activity

  1. added
    bugSomething isn't working
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Jul 2, 2026
  2. opencode-agent commented on Jul 2, 2026

    @opencode-agent
    Contributor

    Tracker note: added to V2 TODO tracking. This is release-relevant because silent pre-runner failures leave users with no visible assistant failure state. Related areas: session runner failure projection, EventV2 durability, and channel/TUI adapters relying on durable session events.

  3. opencode-agent commented on Jul 10, 2026

    @opencode-agent
    Contributor

    Audit: likely addressed by PR #35272, which added durable execution failure events, the public SessionError envelope, toSessionError, and TUI handling for failed executions. Leaving open for one verification pass against the original pre-runner failure repro before closing.

  4. orgito commented on Aug 20, 2026

    @orgito

    Additional repro material for the verification pass noted above.

    Scope, up front: these were observed on v1.18.18, which does not contain #35272 — GitHub's compare API reports v1.18.18...bf01264 as diverged rather than behind, i.e. that merge commit is not an ancestor of the tag, and toSessionError is absent from packages/core/src on both dev and production. So this is not evidence that the fix failed. It is extra repro surface for whoever runs the verification pass.

    The affected error family is wider than the single reported error. The original report is ModelUnavailableError, but at least two siblings from SessionRunnerModel reach the same log-only path and produce byte-identical user-visible behaviour. Both were logged under the same Failed to drain Session message:

    SessionRunnerModel.VariantUnavailableError: Variant unavailable for anthropic/claude-sonnet-4-6: high
    SessionRunnerModel.UnsupportedApiError: Unsupported API for amazon-bedrock/us.anthropic.claude-sonnet-4-6: aisdk:@ai-sdk/amazon-bedrock
    

    Both surface from model resolution during pre-stream setup — the mechanism described in the issue body — so all four members of SessionRunnerModel.Error (ModelNotSelectedError, ModelUnavailableError, VariantUnavailableError, UnsupportedApiError) plausibly share the same fate.

    Wire-level behaviour, identical for each:

    • POST /api/session/{id}/prompt returns 200 with a monotonic admittedSeq
    • the session event log gains one user record and two model-switched records
    • zero assistant output, and no error event of any kind
    • the session goes idle shortly after prompted

    Observed from an HTTP API consumer with no TUI attached, so no client-side rendering is involved — the durable event genuinely isn't there.

    Suggested addition to the verification pass: exercise at least one non-ModelUnavailableError member of the family. A pass that only covers ModelUnavailableError could go green while VariantUnavailableError and UnsupportedApiError still fail silently, since the variant lookup and the API-support check sit at different points in resolution.

    For reference, on dev/production the drain failure is still log-only in packages/core/src/session/execution/local.ts (Effect.logError("Failed to drain Session", cause)), with no conversion into a durable event, consistent with the issue's diagnosis.

  5. opencodejr commented on Oct 29, 2026

    @opencodejr

    Thanks for the detailed write-up. I reproduced this on v2.2.0, and it still happens on the latest dev (8de0323).

    Steps (the script below automates them with the simulated model):

    1. Run one normal turn in the TUI.
    2. Through the SDK, switch the session to opencode/gpt-5.5, a model this server can't route, then send another prompt.

    Result: model resolution fails with Model unavailable: opencode/gpt-5.5 before the step starts. The TUI shows a "Session failed" toast for a few seconds. Once it fades, the transcript shows only the user message, with no assistant message and no error (see the recording and screenshots below). message.list ends with user → idle { outcome: "failed" }, and that idle entry has no error text.

    One correction: the event stream does get a failure event. session.execution.failed, which carries the error, is published (that's what drives the toast). What's missing is a durable failed assistant message or step in the transcript, and the error text on the idle message.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    // Reproduces anomalyco/opencode#34839: when a session's model cannot be resolved
    // (SessionRunnerModel.ModelUnavailableError, thrown in pre-stream setup before the
    // LLM event publisher exists), the turn fails but no assistant message / Step.Failed
    // is written to the transcript. Only a transient toast and an idle(outcome=failed)
    // marker without any error text remain.
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
      },
      ({ ui, llm, opencode }) =>
        Effect.gen(function* () {
          // 1. A normal turn in the TUI with the (available) simulated model.
          // (one simulated response for the title, one for the reply)
          yield* llm.queue(Llm.text("First reply works."))
          yield* llm.queue(Llm.text("First reply works."))
          yield* ui.submit("first message")
          let session: any
          for (let i = 0; i < 100 && !session; i++) {
            const list: any = yield* opencode.session.list({} as any)
            session = list.data?.[0] ?? list[0]
            if (!session) yield* Effect.sleep("100 millis")
          }
          yield* Effect.log(`session ${session.id}`)
          yield* opencode.session.wait({ sessionID: session.id }).pipe(Effect.timeout("20 seconds"), Effect.exit)
          yield* Effect.sleep("1 second")
    
          // 2. Like a channel adapter: the session's model becomes one this server cannot
          //    route (opencode/gpt-5.5 without access), then a new message is sent.
          yield* opencode.session.switchModel({ sessionID: session.id, model: { providerID: "opencode", id: "gpt-5.5" } } as any)
          yield* opencode.session.prompt({ sessionID: session.id, text: "second message, are you there?" } as any)
          yield* opencode.session.wait({ sessionID: session.id }).pipe(Effect.timeout("20 seconds"), Effect.exit)
          yield* Effect.sleep("1 second")
          yield* ui.screenshot("right-after-failure")
    
          const messages = yield* opencode.message.list({ sessionID: session.id, order: "asc" } as any)
          yield* Effect.log(`transcript: ${JSON.stringify(messages.data.map((m: any) => ({ type: m.type, outcome: m.outcome, error: m.error, text: m.text })))}`)
          const failedAssistant = messages.data.filter((m: any) => m.type === "assistant" && m.error)
          yield* Effect.log(`assistant messages carrying an error: ${failedAssistant.length}`)
    
          // 3. Once the toast is gone, the transcript shows nothing about the failure.
          yield* Effect.sleep("8 seconds")
          yield* ui.screenshot("transcript-later")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

2.0bugSomething isn't workingcoreAnything pertaining to core functionality of the application (opencode server stuff)reproduced

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions