Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,33 @@ describe('useModelControls', () => {
expect(requestGateway).toHaveBeenCalledWith('config.set', {
session_id: 'session-1',
key: 'model',
value: 'claude-sonnet-4.6 --provider anthropic'
value: 'claude-sonnet-4.6 --provider anthropic --session'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})

it('keeps an active-session picker change session-scoped, never persisting to global config', async () => {
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
let controls!: Controls

render(
<Harness activeSessionId="session-1" onReady={value => (controls = value)} requestGateway={requestGateway} />
)

await controls.selectModel({ model: 'claude-sonnet-4.6', provider: 'anthropic' })

// Regression: the switch MUST carry --session so parse_model_flags() scopes
// it to the session instead of writing the profile default in config.yaml.
expect(requestGateway).toHaveBeenCalledWith(
'config.set',
expect.objectContaining({ value: expect.stringContaining('--session') })
)
expect(requestGateway).toHaveBeenCalledWith(
'config.set',
expect.objectContaining({ value: expect.not.stringContaining('--global') })
)
})

it('stores a no-session pick as UI state with no gateway or global write', async () => {
const requestGateway = vi.fn()
let controls!: Controls
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/session/hooks/use-model-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
await requestGateway('config.set', {
session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}`
value: `${selection.model} --provider ${selection.provider} --session`
})

void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
Expand Down
32 changes: 30 additions & 2 deletions ui-tui/src/__tests__/createSlashHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})

it('honors TUI picker session scope without adding --global', async () => {
it('rewrites the TUI picker sentinel to --session so the switch stays session-scoped', async () => {
patchUiState({ sid: 'sid-abc' })

const ctx = buildCtx({
Expand All @@ -209,10 +209,38 @@ describe('createSlashHandler', () => {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'anthropic/claude-sonnet-4.6 --provider openrouter'
value: 'anthropic/claude-sonnet-4.6 --provider openrouter --session'
})
})

it('never forwards the raw TUI sentinel and scopes the pick to the session', async () => {
patchUiState({ sid: 'sid-abc' })

const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'anthropic/claude-sonnet-4.6' }))
}
})

createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`)

// Regression: the UI-only sentinel must be rewritten, and the result must be
// session-scoped — not leaked to the gateway and not persisted globally.
expect(ctx.gateway.rpc).toHaveBeenCalledWith(
'config.set',
expect.objectContaining({ value: expect.not.stringContaining(TUI_SESSION_MODEL_FLAG) })
)
expect(ctx.gateway.rpc).toHaveBeenCalledWith(
'config.set',
expect.objectContaining({ value: expect.stringContaining('--session') })
)
expect(ctx.gateway.rpc).toHaveBeenCalledWith(
'config.set',
expect.objectContaining({ value: expect.not.stringContaining('--global') })
)
})

it('does not duplicate --global for explicit persistent model switches', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
Expand Down
5 changes: 4 additions & 1 deletion ui-tui/src/app/slash/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ const modelValueForConfigSet = (arg: string) => {
}

if (TUI_SESSION_MODEL_RE.test(trimmed)) {
return stripTuiSessionFlag(trimmed)
// Convert --tui-session → --session so the gateway's parse_model_flags()
// recognises the intent as session-scoped instead of persisting globally.
const stripped = stripTuiSessionFlag(trimmed)
return `${stripped} --session`.trim()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This correctly fixes the primary overlay slash path, but the other TUI ModelPicker flow is still unscoped: ActiveSessionSwitcher uses allowPersistGlobal={false} at ui-tui/src/components/activeSessionSwitcher.tsx:663-669, strips the sentinel at :207-219, and startPromptLiveSession() forwards the plain value at ui-tui/src/app/useMainApp.ts:119-123. Please carry --session through that path too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roger that

}

return trimmed
Expand Down