diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index e235025a..f7f9e1f2 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -915,25 +915,29 @@ export const ModelControls: React.FC = ({ ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) : null; if (savedAgentName) { - if (currentAgentName !== savedAgentName) { - setAgent(savedAgentName); - } - const savedModel = getAgentModelForSession(currentSessionId, savedAgentName); if (savedModel) { const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName); if (result === 'applied') { + if (currentAgentName !== savedAgentName) { + setAgent(savedAgentName); + } return 'resolved'; } if (result === 'provider-missing') { return 'waiting'; } + } else if (currentAgentName !== savedAgentName) { + setAgent(savedAgentName); } } if (savedSessionModel) { const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined); if (result === 'applied') { + if (savedAgentName && currentAgentName !== savedAgentName) { + setAgent(savedAgentName); + } return 'resolved'; } if (result === 'provider-missing') { @@ -947,16 +951,15 @@ export const ModelControls: React.FC = ({ continue; } - if (currentAgentName !== agent.name) { - setAgent(agent.name); - } - - const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; - if (!existingSelection) { - saveSessionAgentSelection(currentSessionId, agent.name); - } const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name); if (result === 'applied') { + if (currentAgentName !== agent.name) { + setAgent(agent.name); + } + const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; + if (!existingSelection) { + saveSessionAgentSelection(currentSessionId, agent.name); + } return 'resolved'; } if (result === 'provider-missing') { diff --git a/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts b/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts new file mode 100644 index 00000000..ff341f3f --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts @@ -0,0 +1,33 @@ +/** + * Regression coverage for https://github.com/openchamber/openchamber/issues/3036. + * + * Restoring persisted agent/model pairs used to switch agents before checking + * whether each model still existed. Several stale pairs could therefore keep + * changing the active agent on every effect pass until React hit its nested + * update limit. The API error belongs in the assistant message; an invalid + * persisted pair must not mutate the current selection while it is rendered. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8'); + +describe('issue #3036 stale persisted models', () => { + test('changes the agent only after its persisted model is accepted', () => { + const candidateLoop = modelControlsSource.slice( + modelControlsSource.indexOf('for (const agent of agents)'), + modelControlsSource.indexOf("return 'continue';"), + ); + + const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection'); + const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')"); + const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)'); + + expect(applyIndex).toBeGreaterThanOrEqual(0); + expect(acceptedIndex).toBeGreaterThan(applyIndex); + expect(setAgentIndex).toBeGreaterThan(acceptedIndex); + }); +});