From 39a8098b2ef5a0bd0b11c367e8ccdd9043ef4b6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:45:48 -0700 Subject: [PATCH] fix(mobile): stop a stale session-start report reverting a model pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-entering a chat tab re-delivers the same `agentStatus.model`, and the reported-model effect re-applied it unconditionally — so picking a model, moving to another tab, and coming back reverted the pill to the model the agent reported at session start, which cannot have observed the `/model` sent after it. The status stream reconnecting had the same effect. A report is now only treated as evidence when the matched catalog id CHANGES for that scope; a genuinely new report still supersedes a local pick. Mobile has no screen read to confirm a switch against, so the repeat is all we can key off. --- ...mobile-native-chat-session-options.test.ts | 24 +++++++++++++++++++ .../use-mobile-native-chat-session-options.ts | 16 +++++++++++++ 2 files changed, 40 insertions(+) diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index 8eff217445f..f8e811ca1e6 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -135,6 +135,30 @@ describe('useMobileNativeChatSessionOptions', () => { expect(effort!.kind).toMatchObject({ currentValue: 'low' }) }) + it('does not revive a stale session-start report over a newer local pick', async () => { + mount({ reportedModel: 'claude-sonnet-5' }) + await act(async () => { + await api!.setOption('model', 'opus') + }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' }) + // Leaving the tab and returning re-delivers the SAME session-start report, + // which cannot have observed the `/model opus` sent after it. + update({ scopeKey: 'host\0worktree\0other' }) + update({ scopeKey: 'host\0worktree\0tab' }) + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' }) + }) + + it('still lets a genuinely new report supersede a local pick', async () => { + mount({ reportedModel: 'claude-sonnet-5' }) + await act(async () => { + await api!.setOption('model', 'opus') + }) + update({ reportedModel: 'claude-haiku-4-5' }) + expect(api!.snapshot[0]).toMatchObject({ valueSource: 'reported' }) + expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' }) + }) + it('keeps the live tab’s tracked model when other tabs overflow the record cap', async () => { mount() await act(async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index e91956565fe..7bfcd0467b0 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -39,11 +39,17 @@ type PendingOperation = { id: string; token: number } // scope cache. Bounded so long sessions across many tabs can't grow unbounded. const MOBILE_SESSION_OPTION_RECORD_CAP = 32 const recordsByScope = new Map() +// The catalog model id last taken from a hook report, per scope. Mobile cannot +// read the agent's screen, so a repeat of the same report is not new evidence. +const appliedReportByScope = new Map() function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOptionRecord { const existing = recordsByScope.get(scopeKey) const record = existing && existing.agent === agent ? existing : createNativeChatSessionOptionRecord(agent) + if (record !== existing) { + appliedReportByScope.delete(scopeKey) + } // Why: delete-then-set on every read makes the touched scope most-recent, so // eviction only sheds the oldest UNTOUCHED tab. Insertion order alone would let // a long-lived active tab be the oldest key and lose its tracked model. @@ -55,12 +61,14 @@ function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOpti break } recordsByScope.delete(oldest) + appliedReportByScope.delete(oldest) } return record } export function clearMobileSessionOptionRecordsForTests(): void { recordsByScope.clear() + appliedReportByScope.clear() } const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = [] @@ -109,6 +117,14 @@ export function useMobileNativeChatSessionOptions(args: { if (!matched) { return } + // Why: the same report is re-delivered whenever the tab is re-entered or the + // status stream reconnects, and a session-start report cannot have observed a + // `/model` sent after it. Re-applying it would revert the user's pick. Only a + // report that CHANGES is evidence; the value itself still wins when it does. + if (appliedReportByScope.get(scopeKey) === matched) { + return + } + appliedReportByScope.set(scopeKey, matched) const record = getScopedRecord(scopeKey, agent) if (applyNativeChatReportedSessionOptions(record, { model: matched })) { bump()