Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/32994-assistant-model-pick-survives-first-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the model picked in the AI assistant composer is the model the chat request sends, from the first message of a new conversation on, and the picker keeps showing it

Clause-②: no
Metadata-protocol: none

Every chat host (the full-page `/ai/build` and `/ai/ask` surfaces, the console dock, the Studio copilot) keys its chat pane on the conversation id, and the first send creates that conversation, so the pane remounts mid-send. The pick lived inside the pane and was dropped there: the first message went out with the environment's default model and the picker snapped back to it. The hosts now hold the pick, so it survives the remount.
26 changes: 25 additions & 1 deletion ui/packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,10 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
// remount that the id-resolution triggers; the freshly-mounted pane replays it
// via useDeferredFirstSend. See that hook for the full race.
const pendingFirstMessageRef = useRef<PendingFirstMessage | null>(null);
// ADR-0028 — the composer's picked model, owned by the PAGE for the same
// reason: it must survive the keyed <ChatPane> remount, or the pane that
// replays the first message sends (and shows) the default instead.
const [selectedModelId, setSelectedModelId] = useState<string | undefined>(undefined);
// Bumped whenever the pending ref is set OUT OF BAND (the handoff seed below).
// ChatPane's deferred-send replay lists this in its deps, so it re-runs and
// fires the seed even when the conversation id was ALREADY minted. Without it a
Expand Down Expand Up @@ -1659,6 +1663,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
pendingFirstMessageRef={pendingFirstMessageRef}
pendingFirstMessageSeq={pendingFirstMessageSeq}
ensureConversation={ensureConversation}
selectedModelId={selectedModelId}
onSelectedModelChange={setSelectedModelId}
onSent={handleSent}
onShare={() => setShareOpen(true)}
onDebug={() => setDebugOpen(true)}
Expand Down Expand Up @@ -1733,6 +1739,17 @@ interface ChatPaneProps {
* `useChatConversation().ensureConversation`); no row exists before it.
* See {@link useDeferredFirstSend}. */
ensureConversation?: () => unknown;
/**
* ADR-0028 — the model the user picked in the composer, held by the HOST.
* The pane is keyed on the conversation id and remounts when the first send
* mints it, so a pick kept inside the pane would be dropped right then: the
* replayed first message and every later turn would go out with the default
* while the picker had shown the pick. A host that keys the pane passes this
* pair; absent, the pane holds the pick itself for its own lifetime.
*/
selectedModelId?: string;
/** ADR-0028 — records a pick; paired with {@link ChatPaneProps.selectedModelId}. */
onSelectedModelChange?: (modelId: string) => void;
onSent: (firstUserMessage?: string) => void;
onShare: () => void;
/** Opens the Build Doctor drawer (build agent only). */
Expand Down Expand Up @@ -1762,6 +1779,8 @@ export function ChatPane({
pendingFirstMessageRef,
pendingFirstMessageSeq,
ensureConversation,
selectedModelId: hostSelectedModelId,
onSelectedModelChange,
onSent,
onShare,
onDebug,
Expand Down Expand Up @@ -2016,7 +2035,12 @@ export function ChatPane({
// AI service answers that catalog empty and `/models` with a 501.
const { models: aiModels, defaultModelId } = useAiModels({ apiBase, enabled: agents.length > 0 });
const selectedApp = activeAppPackageId || editPackageId || undefined;
const [selectedModelId, setSelectedModelId] = useState<string | undefined>(undefined);
// The pick outlives this pane only when the host holds it (see the props).
const [paneSelectedModelId, setPaneSelectedModelId] = useState<string | undefined>(undefined);
const hostHoldsModel = onSelectedModelChange !== undefined;
const selectedModelId = hostHoldsModel ? hostSelectedModelId : paneSelectedModelId;
const setSelectedModelId = hostHoldsModel ? onSelectedModelChange : setPaneSelectedModelId;
// The one value both the picker shows and the request sends.
const effectiveModelId = selectedModelId ?? defaultModelId;

const {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#32994 — the model a user picks in the composer is the model the
* chat request sends, on the first turn of a new conversation and after it.
*
* Every chat host keys `ChatPane` on the conversation id, and the first send
* mints that id (objectstack#32031), so the pane remounts mid-send. A pick held
* inside the pane died with it: the replayed first message went out with the
* environment default and the picker snapped back to it.
*
* ## Real subjects
*
* The full-page build surface (`AiChatPage` at `/ai/build`) and the console
* dock body (`ChatDockConversation`), each with the REAL `ChatPane`, the REAL
* `useChatConversation` and the REAL `useObjectChat` transport. What is pinned
* is the `model` of the JSON body of each POST to the agent's chat route
* (captured off a `fetch` stub), and the `selectedModelId` the pane hands the
* composer, which is the value its model picker shows. The stand-ins: the agent
* catalog, the model allowlist (three models, Haiku the default), and
* `ChatbotEnhanced` (a recorder exposing the composer's send and pick).
*
* Pins, on both surfaces:
* - a pick made before the first message rides that message, and the next one;
* - after each send the picker still shows the pick;
* - CONTROL: with no pick the default model is sent and shown, as before.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import React from 'react';

const HAIKU = 'anthropic/claude-haiku-5.5';
const SONNET = 'anthropic/claude-sonnet-5.5';
const MODELS = [
{ id: 'openai/gpt-5.6-luna', label: 'Standard' },
{ id: HAIKU, label: 'Fast' },
{ id: SONNET, label: 'Advanced' },
];

const state = {
/** The latest props `ChatPane` handed the composer. */
pane: undefined as
| {
onSendMessage?: (text: string) => void;
onModelChange?: (modelId: string) => void;
selectedModelId?: string;
}
| undefined,
/** The JSON body of every chat POST, in order. */
sent: [] as Array<{ model?: string }>,
minted: 0,
};

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
useAiModels: () => ({ models: MODELS, defaultModelId: HAIKU }),
useHitlInChat: () => ({ decide: vi.fn(), decisions: {} }),
ChatbotEnhanced: (props: typeof state.pane) => {
state.pane = props;
return <div data-testid="pane" />;
},
};
});
vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-chatbot/core')>()),
useAgents: () => ({
agents: [{ name: 'build', label: 'Build', capabilities: ['build'] }],
isLoading: false,
loading: false,
error: undefined,
refetch: vi.fn(),
}),
}));
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1' } }),
}));
vi.mock('../../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => ({ apps: [] }),
}));
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => null,
}));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true }));
vi.mock('../ConversationsSidebar', () => ({ ConversationsSidebar: () => null }));
vi.mock('../LiveCanvas', () => ({ LiveCanvas: () => null }));
vi.mock('../PendingDraftsBar', () => ({ PendingDraftsBar: () => null }));

import { I18nProvider } from '@object-ui/i18n';
import { AiChatPage } from '../AiChatPage';
import { ChatDockConversation } from '../../../layout/ChatDockConversation';

window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;

/** A minimal, well-formed UI-message stream so a send completes. */
function dataStreamResponse(): Response {
return new Response('data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n', {
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'x-vercel-ai-ui-message-stream': 'v1' },
});
}

function installFetch(): void {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? 'GET';
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
if (/\/agents\/[^/]+\/chat$/.test(url) && method === 'POST') {
state.sent.push(JSON.parse(String(init?.body ?? '{}')));
return dataStreamResponse();
}
if (/\/conversations$/.test(url) && method === 'POST') {
state.minted += 1;
return json({ id: `conv-${state.minted}`, messages: [] });
}
const one = /\/conversations\/([^/?]+)$/.exec(url);
if (one && method === 'GET') return json({ id: decodeURIComponent(one[1]), messages: [] });
return json({ success: true, data: [] });
}),
);
}

function mountPage(): void {
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={['/ai/build']}>
<Routes>
<Route path="/ai/:agent/:conversationId" element={<AiChatPage />} />
<Route path="/ai/:agent" element={<AiChatPage />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
}

function mountDock(): void {
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={['/apps/crm_app/account']}>
<ChatDockConversation userId="u1" apiBase="/api/v1/ai" />
</MemoryRouter>
</I18nProvider>,
);
}

async function composerReady(): Promise<void> {
await waitFor(() => expect(state.pane?.onSendMessage).toBeTypeOf('function'), { timeout: 4000 });
}

/** Picks a model in the composer, the way the picker's `onPick` does. */
function pick(modelId: string): void {
act(() => state.pane!.onModelChange!(modelId));
}

/** Sends one message through the composer, and returns the request body that left. */
async function send(text: string): Promise<{ model?: string }> {
const before = state.sent.length;
await act(async () => {
state.pane!.onSendMessage!(text);
});
await waitFor(() => expect(state.sent.length).toBe(before + 1), { timeout: 4000 });
// Let the remount the first send causes, and the turn's stream, settle.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
return state.sent[before];
}

beforeEach(() => {
state.pane = undefined;
state.sent = [];
state.minted = 0;
window.localStorage.clear();
window.sessionStorage.clear();
installFetch();
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe.each([
['the full-page build composer (/ai/build)', mountPage],
['the console dock', mountDock],
])('%s — the picked model is the model sent (objectstack#32994)', (_surface, mount) => {
it('a pick before the first message rides it and the next turn, and the picker keeps showing it', async () => {
mount();
await composerReady();
expect(state.pane?.selectedModelId).toBe(HAIKU);

pick(SONNET);
expect(state.pane?.selectedModelId).toBe(SONNET);

expect((await send('first')).model).toBe(SONNET);
// The first send minted the conversation and remounted the pane.
expect(state.minted).toBe(1);
expect(state.pane?.selectedModelId).toBe(SONNET);

expect((await send('second')).model).toBe(SONNET);
expect(state.pane?.selectedModelId).toBe(SONNET);
});

it('CONTROL: with no pick the default model is sent and shown', async () => {
mount();
await composerReady();

expect((await send('first')).model).toBe(HAIKU);
expect(state.minted).toBe(1);
expect(state.pane?.selectedModelId).toBe(HAIKU);
expect((await send('second')).model).toBe(HAIKU);
});
});
5 changes: 5 additions & 0 deletions ui/packages/app-shell/src/layout/ChatDockConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export function ChatDockConversation({
onBoundPackageChange?.(editPackageId);
}, [editPackageId, onBoundPackageChange]);
const pendingFirstMessageRef = React.useRef<PendingFirstMessage | null>(null);
// ADR-0028 — the composer's picked model, held here so it outlives the keyed
// pane's remount when the first send mints the conversation.
const [selectedModelId, setSelectedModelId] = React.useState<string | undefined>(undefined);

// OSS / no AI seat → the whole dock body is inert (the launcher is gated too).
if (!isLoading && agents.length === 0) return null;
Expand All @@ -163,6 +166,8 @@ export function ChatDockConversation({
initialMessages={paneInitialMessages}
pendingFirstMessageRef={pendingFirstMessageRef}
ensureConversation={ensureConversation}
selectedModelId={selectedModelId}
onSelectedModelChange={setSelectedModelId}
onSent={() => {}}
onShare={() => {}}
showDebug={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export function StudioCopilotConversation({
});

const pendingFirstMessageRef = React.useRef<PendingFirstMessage | null>(null);
// ADR-0028 — the composer's picked model, held here so it outlives the keyed
// pane's remount when the first send mints the conversation.
const [selectedModelId, setSelectedModelId] = React.useState<string | undefined>(undefined);

// No agent served (community edition / misconfigured) → no copilot, plain surface.
if (!agentsLoading && agents.length === 0) return null;
Expand All @@ -133,6 +136,8 @@ export function StudioCopilotConversation({
initialMessages={initialMessages}
pendingFirstMessageRef={pendingFirstMessageRef}
ensureConversation={ensureConversation}
selectedModelId={selectedModelId}
onSelectedModelChange={setSelectedModelId}
onSent={() => {}}
onShare={() => {}}
showDebug={false}
Expand Down
Loading