Files
supabase/apps/studio/state/ai-assistant-state.test.ts
Saxon FletcherandCursor 9b17ce8f2c chore(studio): default assistant to GPT-5.6 Luna (#49749)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature / chore: hide assistant model selection in the UI and default
chats to GPT-5.6 Luna.

## What is the current behavior?

The assistant composer exposes a model picker. Paid orgs default to
`gpt-5.3-codex`; everyone else defaults to `gpt-5.4-nano`.

## What is the new behavior?

- The model picker is hidden in the assistant composer and Explorer
home.
- Chats default to `gpt-5.6-luna` with `reasoningEffort: medium`.
- Model selection plumbing is kept (registry, entitlements, `setModel`,
generate-v4 request body) so a requested model can still be honored when
provided.
- Other completion endpoints still use `gpt-5.4-nano`.

## Additional context

Model selector UI can be re-enabled by passing `selectedModel` /
`onSelectModel` to `AssistantChatForm`.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for the GPT-5.6 Luna model with medium reasoning
capability.
  * Made GPT-5.6 Luna the default assistant model.
* **Improvements**
* Simplified assistant chat by removing model selection from the primary
chat experience.
  * Updated model fallback behavior to use the standard assistant model.
* Chat forms can now optionally display model selection when configured.
* **Tests**
* Updated model coverage and assistant chat tests for the new defaults
and behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:24:37 +10:00

170 lines
6.1 KiB
TypeScript

import { proxy, ref } from 'valtio/vanilla'
import { describe, expect, it } from 'vitest'
import {
createAiAssistantState,
sanitizeForCloning,
whenAiAssistantInitialized,
} from './ai-assistant-state'
describe('AI assistant chat message sync', () => {
// FE-3954: syncing the live array into valtio corrupted it with Proxies, breaking structuredClone in addToolApprovalResponse
it('does not corrupt the live SDK message array when synced into valtio state', () => {
const state = proxy<{ chats: Record<string, any>; chatInstances: Record<string, any> }>({
chats: {},
chatInstances: {},
})
state.chats['chat-1'] = { id: 'chat-1', messages: [] }
const liveSdkMessages = [
{
id: 'message-1',
role: 'assistant',
parts: [
{ type: 'text', text: "Sure, here's a query" },
{
type: 'tool-execute_sql',
toolCallId: 'tool-1',
state: 'approval-requested',
approval: { id: 'approval-1' },
},
],
},
]
state.chatInstances['chat-1'] = ref({ messages: liveSdkMessages })
const chat = state.chats['chat-1']
chat.messages = liveSdkMessages.map((message) => sanitizeForCloning(message))
// mirrors addToolApprovalResponse's own update logic
const lastMessage = liveSdkMessages[liveSdkMessages.length - 1]
const updatedParts = lastMessage.parts.map((part: any) =>
part.state === 'approval-requested' && part.approval?.id === 'approval-1'
? { ...part, state: 'approval-responded', approval: { id: 'approval-1', approved: true } }
: part
)
const replacedMessage = { ...lastMessage, parts: updatedParts }
expect(() => structuredClone(replacedMessage)).not.toThrow()
})
})
describe('AI assistant chat surface isolation', () => {
it('creates chats without changing the sidebar selection', () => {
const state = createAiAssistantState()
const sidebarChatId = state.newChat({ name: 'Sidebar chat' })
const explorerChatId = state.createChat({ name: 'Explorer chat' })
expect(explorerChatId).not.toBe(sidebarChatId)
expect(state.activeChatId).toBe(sidebarChatId)
expect(state.chats[explorerChatId]?.name).toBe('Explorer chat')
})
it('branches a specified chat without changing the sidebar selection', () => {
const state = createAiAssistantState()
const sidebarChatId = state.newChat({ name: 'Sidebar chat' })
const explorerChatId = state.createChat({ name: 'Explorer chat' })
state.chats[explorerChatId].messages = [
{ id: 'message-1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
]
const branchId = state.createBranch(explorerChatId, 'message-1')
expect(branchId).toBeDefined()
expect(state.activeChatId).toBe(sidebarChatId)
expect(state.chats[branchId!]?.branchedFrom).toEqual({
chatId: explorerChatId,
messageId: 'message-1',
})
})
it('mutates an explicit chat without changing or clearing the sidebar chat', () => {
const state = createAiAssistantState()
const sidebarChatId = state.newChat({ name: 'Sidebar chat' })
const explorerChatId = state.createChat({ name: 'Explorer chat' })
state.chats[sidebarChatId].messages = [
{ id: 'sidebar-message', role: 'user', parts: [{ type: 'text', text: 'Keep me' }] },
]
state.chats[explorerChatId].messages = [
{ id: 'explorer-message', role: 'user', parts: [{ type: 'text', text: 'Clear me' }] },
]
state.chatInstances[explorerChatId].messages = [
{ id: 'explorer-message', role: 'user', parts: [{ type: 'text', text: 'Clear me' }] },
]
state.clearMessages(explorerChatId)
expect(state.activeChatId).toBe(sidebarChatId)
expect(state.chats[sidebarChatId].messages).toHaveLength(1)
expect(state.chats[explorerChatId].messages).toHaveLength(0)
expect(state.chatInstances[explorerChatId].messages).toHaveLength(0)
})
it('keeps explicit chat message edits synchronized with the live chat instance', () => {
const state = createAiAssistantState()
const chatId = state.createChat({ name: 'Explorer chat' })
const messages = [
{ id: 'message-1', role: 'user' as const, parts: [{ type: 'text' as const, text: 'First' }] },
{
id: 'message-2',
role: 'user' as const,
parts: [{ type: 'text' as const, text: 'Second' }],
},
]
state.chats[chatId].messages = messages
state.chatInstances[chatId].messages = messages
state.deleteMessagesAfter('message-2', { chatId })
state.updateMessage(
{ id: 'message-1', role: 'user', parts: [{ type: 'text', text: 'Updated' }] },
chatId
)
expect(state.chats[chatId].messages).toEqual([
{ id: 'message-1', role: 'user', parts: [{ type: 'text', text: 'Updated' }] },
])
expect(state.chatInstances[chatId].messages).toEqual(state.chats[chatId].messages)
})
})
describe('whenAiAssistantInitialized', () => {
it('resolves immediately once the state has hydrated', async () => {
const state = createAiAssistantState()
state.isInitialized = true
await expect(whenAiAssistantInitialized(state)).resolves.toBeUndefined()
})
// loadPersistedState replaces state.chats wholesale, so a chat created before hydration is lost
it('defers chat creation until hydration lands so the new chat survives', async () => {
const state = createAiAssistantState()
const pending = whenAiAssistantInitialized(state)
const droppedChatId = state.createChat({ name: 'Created too early' })
state.loadPersistedState({
projectRef: 'default',
activeChatId: 'persisted-chat',
chats: {
'persisted-chat': {
id: 'persisted-chat',
name: 'Persisted chat',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
},
},
})
state.isInitialized = true
expect(state.chats[droppedChatId]).toBeUndefined()
await pending
const chatId = state.createChat({ name: 'Created after hydration' })
expect(state.chats[chatId]?.name).toBe('Created after hydration')
expect(state.chats['persisted-chat']).toBeDefined()
})
})