mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
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:
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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' }
|
||||
|
||||
+3
-39
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user