fix(mobile): stop a stale session-start report reverting a model pick

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.
This commit is contained in:
Brennan Benson
2026-08-04 02:45:48 -07:00
parent fb3e18fcdc
commit 39a8098b2e
2 changed files with 40 additions and 0 deletions
@@ -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 () => {
@@ -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<string, NativeChatSessionOptionRecord>()
// 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<string, string>()
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()