fix(mobile): keep the live tab's session-option record from eviction

`getScopedRecord` returned an existing record without re-inserting it, so the
per-tab record map evicted by insertion order rather than recency. A long-lived
active tab is the oldest key, so crossing the 32-scope cap silently dropped its
tracked model and reset the pill to "Model". Desktop's scope cache does
delete-then-set for exactly this reason.

Also moves the shared session-option tests to src/shared so the root suite runs
them (they only exercised src/shared logic the Electron renderer consumes, but
sat under mobile/ where only mobile's vitest project sees them), and restores
two "why" comments dropped while extracting the shared modules.
This commit is contained in:
Brennan Benson
2026-08-04 01:56:05 -07:00
parent 3eafcb4500
commit fb3e18fcdc
7 changed files with 101 additions and 51 deletions
@@ -135,6 +135,21 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(effort!.kind).toMatchObject({ currentValue: 'low' })
})
it('keeps the live tab’s tracked model when other tabs overflow the record cap', async () => {
mount()
await act(async () => {
await api!.setOption('model', 'opus')
})
// Far more scopes than the cap, revisiting the live tab in between the way a
// chat↔terminal flip does — insertion-order eviction would shed it.
for (let index = 0; index < 40; index += 1) {
update({ scopeKey: `host\0worktree\0overflow-${index}` })
update({ scopeKey: 'host\0worktree\0tab' })
}
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
})
it('keeps the latest queued operation pending until it settles', async () => {
const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = []
dispatchCommand.mockImplementation(
@@ -42,18 +42,21 @@ const recordsByScope = new Map<string, NativeChatSessionOptionRecord>()
function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOptionRecord {
const existing = recordsByScope.get(scopeKey)
if (existing && existing.agent === agent) {
return existing
}
const created = createNativeChatSessionOptionRecord(agent)
if (!recordsByScope.has(scopeKey) && recordsByScope.size >= MOBILE_SESSION_OPTION_RECORD_CAP) {
const record =
existing && existing.agent === agent ? existing : createNativeChatSessionOptionRecord(agent)
// 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.
recordsByScope.delete(scopeKey)
recordsByScope.set(scopeKey, record)
while (recordsByScope.size > MOBILE_SESSION_OPTION_RECORD_CAP) {
const oldest = recordsByScope.keys().next().value
if (oldest !== undefined) {
recordsByScope.delete(oldest)
if (oldest === undefined) {
break
}
recordsByScope.delete(oldest)
}
recordsByScope.set(scopeKey, created)
return created
return record
}
export function clearMobileSessionOptionRecordsForTests(): void {
@@ -2,16 +2,16 @@ import { describe, expect, it } from 'vitest'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from '../../../src/shared/agent-session-option-catalog-claude-codex'
} from './agent-session-option-catalog-claude-codex'
import {
buildNativeChatSessionOptionCommand,
parseBuiltSessionOptionCommand,
recordNativeChatSessionOptionCommand
} from '../../../src/shared/native-chat-session-option-commands'
} from './native-chat-session-option-commands'
import {
createNativeChatSessionOptionRecord,
type NativeChatSessionOptionRecord
} from '../../../src/shared/native-chat-session-option-state'
} from './native-chat-session-option-state'
function claudeRecord(model?: string): NativeChatSessionOptionRecord {
const record = createNativeChatSessionOptionRecord('claude')
@@ -109,6 +109,8 @@ function recordCommandApply(args: {
const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null
if (optionId === 'model') {
if (previousModelId !== value) {
// Why: a model command can reset model-scoped state, so an older value
// from a prior visit is no longer evidence about this live session.
delete record.valuesByModel[value]
}
record.model = { value, source: 'dispatched' }
@@ -2,14 +2,12 @@ import { describe, expect, it } from 'vitest'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from '../../../src/shared/agent-session-option-catalog-claude-codex'
} from './agent-session-option-catalog-claude-codex'
import {
applyNativeChatReportedSessionOptions,
createNativeChatSessionOptionRecord,
matchNativeChatCatalogModelId,
type NativeChatSessionOptionRecord
} from '../../../src/shared/native-chat-session-option-state'
import { buildNativeChatSessionOptionSnapshot } from '../../../src/shared/native-chat-session-option-snapshot'
} from './native-chat-session-option-state'
import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot'
function claudeRecord(): NativeChatSessionOptionRecord {
return createNativeChatSessionOptionRecord('claude')
@@ -96,37 +94,3 @@ describe('buildNativeChatSessionOptionSnapshot', () => {
expect(fastMode).toMatchObject({ action: { type: 'toggle-command' } })
})
})
describe('applyNativeChatReportedSessionOptions', () => {
it('reports become authority and reset stale per-model values on a model change', () => {
const record = claudeRecord()
record.model = { value: 'sonnet', source: 'dispatched' }
record.valuesByModel.opus = { effort: { value: 'high', source: 'dispatched' } }
expect(applyNativeChatReportedSessionOptions(record, { model: 'opus' })).toBe(true)
expect(record.model).toEqual({ value: 'opus', source: 'reported' })
// A model change invalidates previously tracked values for the destination.
expect(record.valuesByModel.opus).toEqual({})
})
it('is a no-op when the report matches tracked state', () => {
const record = claudeRecord()
record.model = { value: 'sonnet', source: 'reported' }
expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false)
})
})
describe('matchNativeChatCatalogModelId', () => {
it('matches exact ids, labels, and provider-id containment', () => {
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'sonnet')).toBe('sonnet')
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'Sonnet 5')).toBe('sonnet')
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'claude-sonnet-5')).toBe(
'sonnet'
)
expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.5')).toBe('gpt-5.5')
})
it('returns null for unrecognized reports', () => {
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'mystery-model')).toBeNull()
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, '')).toBeNull()
})
})
@@ -58,6 +58,8 @@ function actionForApply(
if (apply.midSession?.kind === 'agent-picker') {
return { type: 'agent-picker' }
}
// Why: only unknown flip-only options are actions; once we have a tracked
// baseline the UI can show absolute On/Off without inventing a start state.
return isFlipOnlyMidSession(apply.midSession) && !tracked ? { type: 'toggle-command' } : undefined
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
CLAUDE_SESSION_OPTION_CATALOG,
CODEX_SESSION_OPTION_CATALOG
} from './agent-session-option-catalog-claude-codex'
import {
applyNativeChatReportedSessionOptions,
createNativeChatSessionOptionRecord,
matchNativeChatCatalogModelId,
type NativeChatSessionOptionRecord
} from './native-chat-session-option-state'
function claudeRecord(): NativeChatSessionOptionRecord {
return createNativeChatSessionOptionRecord('claude')
}
describe('applyNativeChatReportedSessionOptions', () => {
it('reports become authority and reset stale per-model values on a model change', () => {
const record = claudeRecord()
record.model = { value: 'sonnet', source: 'dispatched' }
record.valuesByModel.opus = { effort: { value: 'high', source: 'dispatched' } }
expect(applyNativeChatReportedSessionOptions(record, { model: 'opus' })).toBe(true)
expect(record.model).toEqual({ value: 'opus', source: 'reported' })
// A model change invalidates previously tracked values for the destination.
expect(record.valuesByModel.opus).toEqual({})
})
it('promotes a dispatched guess the report confirms', () => {
const record = claudeRecord()
record.model = { value: 'sonnet', source: 'dispatched' }
expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(true)
expect(record.model).toEqual({ value: 'sonnet', source: 'reported' })
})
it('keeps locally tracked options when the reported model is unchanged', () => {
const record = claudeRecord()
record.model = { value: 'sonnet', source: 'reported' }
record.valuesByModel.sonnet = { effort: { value: 'high', source: 'dispatched' } }
expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false)
expect(record.valuesByModel.sonnet?.effort).toEqual({ value: 'high', source: 'dispatched' })
})
it('is a no-op when the report matches tracked state', () => {
const record = claudeRecord()
record.model = { value: 'sonnet', source: 'reported' }
expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false)
})
})
describe('matchNativeChatCatalogModelId', () => {
it('matches exact ids, labels, and provider-id containment', () => {
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'sonnet')).toBe('sonnet')
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'Sonnet 5')).toBe('sonnet')
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'claude-sonnet-5')).toBe(
'sonnet'
)
expect(matchNativeChatCatalogModelId(CODEX_SESSION_OPTION_CATALOG, 'gpt-5.5')).toBe('gpt-5.5')
})
it('returns null for unrecognized reports', () => {
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'mystery-model')).toBeNull()
expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, '')).toBeNull()
})
})