regression: same-instance (opencode) model switch on mobile still leaves desktop composer stale on Orchestrator v2#15195

opened22d agobyRrohitgirdhar

Summary

Regression / incomplete fix: on Orchestrator v2 nightly, switching model within the same provider instance on mobile does not update the desktop composer for the same thread. The desktop keeps showing the old model and can silently switch the thread back on next send. This is the intra-instance variant of #10684, which was closed COMPLETED by #2829.

Environment

  • opencode version: 2.0.22
  • OS: Linux hp-laptop 7.0.0-34-generic x86_64
  • T3 desktop: 0.0.46-nightly.20261003.2623
  • T3 mobile: 2.0.0
  • Reporter: @rohitgirdhar (filed via agent triage)

Reproduction

  1. Open the same T3 thread on phone + desktop, provider opencode.
  2. Both show model A: opencode/space-bunny-free.
  3. On phone, switch same thread to model B: opencode/muse-spark-1.3-contributor-free.
  4. Send "Testing again" from phone — server runs with model B.
  5. Desktop composer / thread header still shows model A. Persists without manual refresh.

Expected Behavior

Desktop composer follows the server thread model after another client changes it, for both cross-provider and same-instance model changes. A newer explicit pick on that client should still win.

Actual Behavior

Desktop stays on A. Same silent-reversal mechanism documented in #10684: stale composer model gets written back via thread.meta.update on next desktop send.

What was fixed vs what is not

Fixed (per #2829 body): V2 added durable thread.model-selection.set command + thread.model-selection-updated projection replacing per-client sticky state. That covers server durability and the inter-provider handoff path.

NOT fixed: web composer precedence in apps/web/src/composerDraftStore.ts (deriveEffectiveComposerModelState, current main ~L1297-1313):

ts
1const instanceSelection = input.selectedInstanceId
2 ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
3 : undefined;
4const activeSelection = instanceSelection ?? legacySelection;
5const selectedModel = activeSelection?.model ? /* draft */ : baseModel;

baseModel comes from threadModelSelection, but only wins when the draft map has no entry for that selectedInstanceId. space-bunny-free → muse-spark-... share selectedInstanceId = opencode, so the seeded desktop draft (seeded once via applyStickyState, survives promotion + clearComposerContent, per #10684 triage) keeps winning. thread.meta-updated updates thread.modelSelection in threadReducer but nothing re-seeds/clears the draft entry.

#2829's diff to composerDraftStore.ts is +312/-15 adding only threadContexts / stickyOptionsByModelByProvider — no change to this precedence, no thread.model-selection-updated reseed path. So same-instance switches remain stale.

Links

  • My report closed as duplicate: #15190
  • Canonical web-direction tracker closed COMPLETED 2026-10-02 by #2829: #10684
  • Mirror mobile-direction: #10202 (mobile-only fix #10205, web not covered)
  • Prior wash: #13483 (duplicate of #10684)
  • Closer PR: #2829 (merge commit 2026-10-02T19:22:22Z)

Request

Reopen or track as regression of #10684 scoped to same-instance model switch on web: re-seed/clear stored draft selection when thread.model-selection-updated / thread.meta-updated changes the thread model, unless this client made a newer explicit pick (per #10684 suggested fix points 1-4).

Note

Grok responding on behalf of Julius.

Triage

Thanks @rohitgirdhar for the precise report. I confirmed this on current main (31a9da179e). It's the same web composer behavior as #10684 and is still present. The merged #2829 closed #10684 and #10202 because V2 now stores the thread model on the server, but it didn't change how the desktop composer chooses which model to show and send.

What I verified

deriveEffectiveComposerModelState still prefers the draft entry for the selected instance and only falls back to threadModelSelection when that entry is missing:

ts
1const instanceSelection = input.selectedInstanceId
2 ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
3 : undefined;
4const activeSelection = instanceSelection ?? legacySelection;
5const selectedModel = activeSelection?.model ? /* draft */ : baseModel;

modelSelectionExplicit isn't an input, and modelSelection.test.ts still expects a custom-instance draft model to beat a different thread selection.

A thread created on desktop gets that draft entry from applyStickyState / setModelSelection. Promotion copies the whole draft onto the server-thread key (finalizePromotedDraftThread → removeDraftThreadReferences). clearComposerContent clears text and attachments but leaves modelSelectionByProvider and activeProvider, and persistence keeps model-only server-thread drafts. Nothing on thread.model-selection-updated or thread.provider-switched re-seeds or clears it.

resolveComposerProviderSelection then tries draft.activeProvider before the thread's instance. OpenCode advertises supportsProviderSwitchingViaHandoff, so the composer isn't locked to the thread's instance. Your two models share the opencode instance, so the seeded draft keeps winning. A desktop that never stored a selection for the thread would follow thread.modelSelection, but the usual path (creating the thread or picking a model there) does store one.

The next desktop send still writes the stale model back: startThreadTurn always sends modelSelection: ctxSelectedModelSelection, and if that differs from the thread, the orchestrator records a thread.model-selection-updated (or thread.provider-switched across instances). Since the picker already shows the old model, that reversal is invisible. Sidebar rows that read thread.modelSelection should update; the stale surface is the composer picker and the next send from it.

The same precedence also affects switches across providers whenever this client still has a draft for the old instance. Same-instance switches are just the case where the draft entry can never miss.

Related

  • #10684 (closed) is this bug, closed by #2829's list on 2026-10-02 without a web composer change.
  • #15190 (closed as a duplicate of #10684) is an earlier report of the same thing.
  • #10202 (closed) is the mirror case (desktop changes, mobile stays stale). Open PR #10205 releases confirmed overrides on mobile and doesn't touch apps/web.

Likely fix area

A maintainer will decide on the fix direction. Options in the same shape as #10205, on the web draft:

  1. After the server confirms this client's choice, drop or re-seed the thread-draft model so later sends follow thread.modelSelection.
  2. When thread.model-selection-updated or thread.provider-switched changes the thread model, re-seed unless this client made a newer explicit pick.
  3. Keep a pick made here after the last confirmed send, including a same-value re-pick, and restore it if the send fails.

How to verify

Desktop A → mobile B on the same OpenCode instance → the desktop picker shows B and the next desktop send requests B. Also: an unsent desktop pick C survives a remote B, a failed send keeps C, and reopening the thread doesn't bring back a persisted A.

Synced from GitHub

closing as addressed by #20886, merged into main: web and mobile reconcile confirmed composer model overrides against the live thread state, allowing later model changes from another client to reach the composer without discarding a differing unsent pick.

thanks for reporting this. if you still face it on a build containing this fix, please ask us to reopen this issue or open a new issue linking back here, with your version, os and reproduction steps.

closed at the request of @UtkarshUsername.