fix(tui): keep conversation model overrides sticky (#1238)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
paulbettner
2026-03-05 19:38:36 -05:00
committed by GitHub
parent a185d567ba
commit 5a6d804069
7 changed files with 178 additions and 72 deletions

View File

@@ -474,15 +474,14 @@ export async function getResumeData(
const retrievedMessages = await client.messages.retrieve(lastInContextId); const retrievedMessages = await client.messages.retrieve(lastInContextId);
// Fetch message history for backfill through the default conversation route. // Fetch message history for backfill through the default conversation route.
// For default conversation, pass agent_id as query parameter. // Default conversation is represented by the agent id at the conversations endpoint.
// Wrapped in try/catch so backfill failures don't crash the CLI (e.g., older servers // Wrapped in try/catch so backfill failures don't crash the CLI (e.g., older servers
// may not support this pattern) // may not support this pattern)
if (includeMessageHistory && isBackfillEnabled()) { if (includeMessageHistory && isBackfillEnabled()) {
try { try {
const messagesPage = await client.conversations.messages.list( const messagesPage = await client.conversations.messages.list(
"default", agent.id,
{ {
agent_id: agent.id,
limit: BACKFILL_PAGE_LIMIT, limit: BACKFILL_PAGE_LIMIT,
order: "desc", order: "desc",
}, },
@@ -491,7 +490,7 @@ export async function getResumeData(
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.log( console.log(
`[DEBUG] conversations.messages.list(default, agent_id=${agent.id}) returned ${messages.length} messages`, `[DEBUG] conversations.messages.list(${agent.id}) returned ${messages.length} messages`,
); );
} }
} catch (backfillError) { } catch (backfillError) {

View File

@@ -1477,16 +1477,25 @@ export default function App({
const [currentToolsetPreference, setCurrentToolsetPreference] = const [currentToolsetPreference, setCurrentToolsetPreference] =
useState<ToolsetPreference>("auto"); useState<ToolsetPreference>("auto");
const [llmConfig, setLlmConfig] = useState<LlmConfig | null>(null); const [llmConfig, setLlmConfig] = useState<LlmConfig | null>(null);
const [hasConversationModelOverride, setHasConversationModelOverride] = // Keep state + ref synchronized so async callbacks (e.g. syncAgentState) never
useState(false); // read a stale value and accidentally clobber conversation-scoped overrides.
const [
hasConversationModelOverride,
setHasConversationModelOverride,
hasConversationModelOverrideRef,
] = useSyncedState(false);
const llmConfigRef = useRef(llmConfig); const llmConfigRef = useRef(llmConfig);
useEffect(() => { useEffect(() => {
llmConfigRef.current = llmConfig; llmConfigRef.current = llmConfig;
}, [llmConfig]); }, [llmConfig]);
const hasConversationModelOverrideRef = useRef(hasConversationModelOverride);
useEffect(() => { // Cache the conversation's model_settings when a conversation-scoped override is active.
hasConversationModelOverrideRef.current = hasConversationModelOverride; // On resume, llm_config may omit reasoning_effort even when the conversation model_settings
}, [hasConversationModelOverride]); // includes it; this snapshot prevents the footer reasoning tag from missing.
const [
conversationOverrideModelSettings,
setConversationOverrideModelSettings,
] = useState<AgentState["model_settings"] | null>(null);
const agentStateRef = useRef(agentState); const agentStateRef = useRef(agentState);
useEffect(() => { useEffect(() => {
agentStateRef.current = agentState; agentStateRef.current = agentState;
@@ -1509,12 +1518,22 @@ export default function App({
? `${llmConfig.model_endpoint_type}/${llmConfig.model}` ? `${llmConfig.model_endpoint_type}/${llmConfig.model}`
: (llmConfig?.model ?? null)) || : (llmConfig?.model ?? null)) ||
null; null;
// Derive reasoning effort from model_settings (canonical) with llm_config as legacy fallback.
// When a conversation override is active, the server may still return an agent llm_config
// with reasoning_effort="none"; prefer the conversation model_settings snapshot.
const effectiveModelSettings = hasConversationModelOverride
? conversationOverrideModelSettings
: agentState?.model_settings;
const derivedReasoningEffort: ModelReasoningEffort | null =
deriveReasoningEffort(effectiveModelSettings, llmConfig);
// Use tier-aware resolution so the display matches the agent's reasoning effort // Use tier-aware resolution so the display matches the agent's reasoning effort
// (e.g. "GPT-5.3-Codex" not just "GPT-5" for the first match). // (e.g. "GPT-5.3-Codex" not just "GPT-5" for the first match).
const currentModelDisplay = useMemo(() => { const currentModelDisplay = useMemo(() => {
if (!currentModelLabel) return null; if (!currentModelLabel) return null;
const info = getModelInfoForLlmConfig(currentModelLabel, { const info = getModelInfoForLlmConfig(currentModelLabel, {
reasoning_effort: llmConfig?.reasoning_effort ?? null, reasoning_effort: derivedReasoningEffort ?? null,
enable_reasoner: enable_reasoner:
(llmConfig as { enable_reasoner?: boolean | null })?.enable_reasoner ?? (llmConfig as { enable_reasoner?: boolean | null })?.enable_reasoner ??
null, null,
@@ -1527,16 +1546,10 @@ export default function App({
currentModelLabel.split("/").pop() ?? currentModelLabel.split("/").pop() ??
null null
); );
}, [currentModelLabel, llmConfig]); }, [currentModelLabel, derivedReasoningEffort, llmConfig]);
const currentModelProvider = llmConfig?.provider_name ?? null; const currentModelProvider = llmConfig?.provider_name ?? null;
// Derive reasoning effort from model_settings (canonical) with llm_config as legacy fallback.
// Some providers may omit explicit effort for default tiers (e.g., Sonnet 4.6 high),
// so fall back to the selected model preset when needed.
const effectiveModelSettings = hasConversationModelOverride
? undefined
: agentState?.model_settings;
const currentReasoningEffort: ModelReasoningEffort | null = const currentReasoningEffort: ModelReasoningEffort | null =
deriveReasoningEffort(effectiveModelSettings, llmConfig) ?? derivedReasoningEffort ??
inferReasoningEffortFromModelPreset(currentModelId, currentModelLabel); inferReasoningEffortFromModelPreset(currentModelId, currentModelLabel);
// Billing tier for conditional UI and error context (fetched once on mount) // Billing tier for conditional UI and error context (fetched once on mount)
@@ -3266,6 +3279,7 @@ export default function App({
}, [loadingState, agentId]); }, [loadingState, agentId]);
// Keep effective model state in sync with the active conversation override. // Keep effective model state in sync with the active conversation override.
// biome-ignore lint/correctness/useExhaustiveDependencies: ref.current is intentionally read dynamically
useEffect(() => { useEffect(() => {
if ( if (
loadingState !== "ready" || loadingState !== "ready" ||
@@ -3283,6 +3297,7 @@ export default function App({
agentState.model ?? agentState.model ??
buildModelHandleFromLlmConfig(agentState.llm_config); buildModelHandleFromLlmConfig(agentState.llm_config);
setHasConversationModelOverride(false); setHasConversationModelOverride(false);
setConversationOverrideModelSettings(null);
setLlmConfig(agentState.llm_config); setLlmConfig(agentState.llm_config);
setCurrentModelHandle(agentModelHandle ?? null); setCurrentModelHandle(agentModelHandle ?? null);
@@ -3351,6 +3366,7 @@ export default function App({
); );
setHasConversationModelOverride(true); setHasConversationModelOverride(true);
setConversationOverrideModelSettings(conversationModelSettings ?? null);
setCurrentModelHandle(effectiveModelHandle); setCurrentModelHandle(effectiveModelHandle);
const modelInfo = getModelInfoForLlmConfig(effectiveModelHandle, { const modelInfo = getModelInfoForLlmConfig(effectiveModelHandle, {
reasoning_effort: reasoningEffort, reasoning_effort: reasoningEffort,
@@ -3391,8 +3407,15 @@ export default function App({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agentId, agentState, conversationId, loadingState]); }, [
agentId,
agentState,
conversationId,
loadingState,
setHasConversationModelOverride,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable objects, .current is read dynamically
const maybeCarryOverActiveConversationModel = useCallback( const maybeCarryOverActiveConversationModel = useCallback(
async (targetConversationId: string) => { async (targetConversationId: string) => {
if (!hasConversationModelOverrideRef.current) { if (!hasConversationModelOverrideRef.current) {
@@ -3663,6 +3686,7 @@ export default function App({
// removed. Git-backed memory uses standard git merge conflict resolution via the agent. // removed. Git-backed memory uses standard git merge conflict resolution via the agent.
// Core streaming function - iterative loop that processes conversation turns // Core streaming function - iterative loop that processes conversation turns
// biome-ignore lint/correctness/useExhaustiveDependencies: refs read .current dynamically, complex callback with intentional deps
const processConversation = useCallback( const processConversation = useCallback(
async ( async (
initialInput: Array<MessageCreate | ApprovalCreate>, initialInput: Array<MessageCreate | ApprovalCreate>,
@@ -5841,12 +5865,14 @@ export default function App({
// causing CONFLICT on the next user message. // causing CONFLICT on the next user message.
getClient() getClient()
.then((client) => { .then((client) => {
if (conversationIdRef.current === "default") { const cancelConversationId =
return client.conversations.cancel("default", { conversationIdRef.current === "default"
agent_id: agentIdRef.current, ? agentIdRef.current
}); : conversationIdRef.current;
if (!cancelConversationId || cancelConversationId === "loading") {
return;
} }
return client.conversations.cancel(conversationIdRef.current); return client.conversations.cancel(cancelConversationId);
}) })
.catch(() => { .catch(() => {
// Silently ignore - cancellation already happened client-side // Silently ignore - cancellation already happened client-side
@@ -5961,12 +5987,14 @@ export default function App({
// Don't wait for it or show errors since user already got feedback // Don't wait for it or show errors since user already got feedback
getClient() getClient()
.then((client) => { .then((client) => {
if (conversationIdRef.current === "default") { const cancelConversationId =
return client.conversations.cancel("default", { conversationIdRef.current === "default"
agent_id: agentIdRef.current, ? agentIdRef.current
}); : conversationIdRef.current;
if (!cancelConversationId || cancelConversationId === "loading") {
return;
} }
return client.conversations.cancel(conversationIdRef.current); return client.conversations.cancel(cancelConversationId);
}) })
.catch(() => { .catch(() => {
// Silently ignore - cancellation already happened client-side // Silently ignore - cancellation already happened client-side
@@ -5986,13 +6014,14 @@ export default function App({
setInterruptRequested(true); setInterruptRequested(true);
try { try {
const client = await getClient(); const client = await getClient();
if (conversationIdRef.current === "default") { const cancelConversationId =
await client.conversations.cancel("default", { conversationIdRef.current === "default"
agent_id: agentIdRef.current, ? agentIdRef.current
}); : conversationIdRef.current;
} else { if (!cancelConversationId || cancelConversationId === "loading") {
await client.conversations.cancel(conversationIdRef.current); return;
} }
await client.conversations.cancel(cancelConversationId);
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
@@ -11163,15 +11192,24 @@ ${SYSTEM_REMINDER_CLOSE}
phase: "running", phase: "running",
}); });
// Persist model change to the backend. // "default" is a virtual sentinel for the agent's primary history, not a
// For real conversations, update the conversation-scoped override. // real conversation object. When active, model changes must update the agent
// For "default" (virtual sentinel with no real conversation object), // itself (otherwise the next agent sync will snap back).
// update the agent itself so the model sticks across messages. const isDefaultConversation = conversationIdRef.current === "default";
let conversationModelSettings: let conversationModelSettings:
| AgentState["model_settings"] | AgentState["model_settings"]
| null | null
| undefined; | undefined;
if (conversationIdRef.current !== "default") { let updatedAgent: AgentState | null = null;
if (isDefaultConversation) {
const { updateAgentLLMConfig } = await import("../agent/modify");
updatedAgent = await updateAgentLLMConfig(
agentIdRef.current,
modelHandle,
model.updateArgs,
);
conversationModelSettings = updatedAgent?.model_settings;
} else {
const { updateConversationLLMConfig } = await import( const { updateConversationLLMConfig } = await import(
"../agent/modify" "../agent/modify"
); );
@@ -11185,14 +11223,6 @@ ${SYSTEM_REMINDER_CLOSE}
model_settings?: AgentState["model_settings"] | null; model_settings?: AgentState["model_settings"] | null;
} }
).model_settings; ).model_settings;
} else {
const { updateAgentLLMConfig } = await import("../agent/modify");
const updatedAgent = await updateAgentLLMConfig(
agentId,
modelHandle,
model.updateArgs,
);
conversationModelSettings = updatedAgent.model_settings;
} }
// The API may not echo reasoning_effort back, so populate it from // The API may not echo reasoning_effort back, so populate it from
@@ -11206,9 +11236,23 @@ ${SYSTEM_REMINDER_CLOSE}
llmConfigRef.current, llmConfigRef.current,
) ?? null); ) ?? null);
setHasConversationModelOverride(true); if (isDefaultConversation) {
setHasConversationModelOverride(false);
setConversationOverrideModelSettings(null);
if (updatedAgent) {
setAgentState(updatedAgent);
}
} else {
setHasConversationModelOverride(true);
setConversationOverrideModelSettings(
conversationModelSettings ?? null,
);
}
setLlmConfig({ setLlmConfig({
...(llmConfigRef.current ?? ({} as LlmConfig)), ...(updatedAgent?.llm_config ??
llmConfigRef.current ??
({} as LlmConfig)),
...mapHandleToLlmConfigPatch(modelHandle), ...mapHandleToLlmConfigPatch(modelHandle),
...(typeof resolvedReasoningEffort === "string" ...(typeof resolvedReasoningEffort === "string"
? { ? {
@@ -11306,6 +11350,7 @@ ${SYSTEM_REMINDER_CLOSE}
maybeRecordToolsetChangeReminder, maybeRecordToolsetChangeReminder,
resetPendingReasoningCycle, resetPendingReasoningCycle,
withCommandLock, withCommandLock,
setHasConversationModelOverride,
], ],
); );
@@ -11956,13 +12001,25 @@ ${SYSTEM_REMINDER_CLOSE}
const cmd = commandRunner.start("/reasoning", "Setting reasoning..."); const cmd = commandRunner.start("/reasoning", "Setting reasoning...");
try { try {
// "default" is a virtual sentinel, not a real conversation object — // "default" is a virtual sentinel for the agent's primary history. When
// skip the API call and fall through with undefined model_settings. // active, reasoning tier changes must update the agent itself so the next
// agent sync doesn't snap back.
const isDefaultConversation = conversationIdRef.current === "default";
let conversationModelSettings: let conversationModelSettings:
| AgentState["model_settings"] | AgentState["model_settings"]
| null | null
| undefined; | undefined;
if (conversationIdRef.current !== "default") { let updatedAgent: AgentState | null = null;
if (isDefaultConversation) {
const { updateAgentLLMConfig } = await import("../agent/modify");
updatedAgent = await updateAgentLLMConfig(
agentIdRef.current,
desired.modelHandle,
{
reasoning_effort: desired.effort,
},
);
} else {
const { updateConversationLLMConfig } = await import( const { updateConversationLLMConfig } = await import(
"../agent/modify" "../agent/modify"
); );
@@ -11981,14 +12038,30 @@ ${SYSTEM_REMINDER_CLOSE}
} }
const resolvedReasoningEffort = const resolvedReasoningEffort =
deriveReasoningEffort( deriveReasoningEffort(
conversationModelSettings, isDefaultConversation
? (updatedAgent?.model_settings ?? null)
: conversationModelSettings,
llmConfigRef.current, llmConfigRef.current,
) ?? desired.effort; ) ?? desired.effort;
setHasConversationModelOverride(true); if (isDefaultConversation) {
setHasConversationModelOverride(false);
setConversationOverrideModelSettings(null);
if (updatedAgent) {
setAgentState(updatedAgent);
}
} else {
setHasConversationModelOverride(true);
setConversationOverrideModelSettings(
conversationModelSettings ?? null,
);
}
// The API may not echo reasoning_effort back; preserve explicit desired effort. // The API may not echo reasoning_effort back; preserve explicit desired effort.
setLlmConfig({ setLlmConfig({
...(llmConfigRef.current ?? ({} as LlmConfig)), ...(updatedAgent?.llm_config ??
llmConfigRef.current ??
({} as LlmConfig)),
...mapHandleToLlmConfigPatch(desired.modelHandle), ...mapHandleToLlmConfigPatch(desired.modelHandle),
reasoning_effort: resolvedReasoningEffort as ModelReasoningEffort, reasoning_effort: resolvedReasoningEffort as ModelReasoningEffort,
}); });
@@ -12045,8 +12118,15 @@ ${SYSTEM_REMINDER_CLOSE}
} finally { } finally {
reasoningCycleInFlightRef.current = false; reasoningCycleInFlightRef.current = false;
} }
}, [agentId, commandRunner, isAgentBusy, withCommandLock]); }, [
agentId,
commandRunner,
isAgentBusy,
withCommandLock,
setHasConversationModelOverride,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable objects, .current is read dynamically
const handleCycleReasoningEffort = useCallback(() => { const handleCycleReasoningEffort = useCallback(() => {
void (async () => { void (async () => {
if (!agentId) return; if (!agentId) return;

View File

@@ -76,9 +76,15 @@ export function createCommandRunner({
const handle: CommandHandle = { const handle: CommandHandle = {
id, id,
input, input,
update: null!, // Placeholders are overwritten below before the handle is returned.
finish: null!, update: (_update: CommandUpdate) => {},
fail: null!, finish: (
_output: string,
_success?: boolean,
_dimOutput?: boolean,
_preformatted?: boolean,
) => {},
fail: (_output: string) => {},
}; };
const update = (updateData: CommandUpdate) => { const update = (updateData: CommandUpdate) => {

View File

@@ -243,9 +243,9 @@ export function ConversationSelector({
if (!afterCursor) { if (!afterCursor) {
try { try {
const defaultMessages = await client.conversations.messages.list( const defaultMessages = await client.conversations.messages.list(
"default", // Default conversation is represented by the agent id at the conversations endpoint.
agentId,
{ {
agent_id: agentId,
limit: 20, limit: 20,
order: "desc", order: "desc",
}, },

View File

@@ -159,8 +159,8 @@ export async function runMessagesSubcommand(argv: string[]): Promise<number> {
return 1; return 1;
} }
const response = await client.conversations.messages.list("default", { // Default conversation is represented by the agent id at the conversations endpoint.
agent_id: agentId, const response = await client.conversations.messages.list(agentId, {
limit: parseLimit(parsed.values.limit, 20), limit: parseLimit(parsed.values.limit, 20),
after: parsed.values.after, after: parsed.values.after,
before: parsed.values.before, before: parsed.values.before,

View File

@@ -71,7 +71,7 @@ describe("model preset refresh wiring", () => {
expect(updateSegment).not.toContain("client.agents.update("); expect(updateSegment).not.toContain("client.agents.update(");
}); });
test("/model handler updates conversation model and falls back to agent for default", () => { test("/model handler updates conversation model (default updates agent)", () => {
const path = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url)); const path = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url));
const source = readFileSync(path, "utf-8"); const source = readFileSync(path, "utf-8");
@@ -85,11 +85,9 @@ describe("model preset refresh wiring", () => {
const segment = source.slice(start, end); const segment = source.slice(start, end);
expect(segment).toContain("updateConversationLLMConfig("); expect(segment).toContain("updateConversationLLMConfig(");
expect(segment).toContain("conversationIdRef.current");
// For the "default" virtual conversation (no real conversation object),
// the handler falls back to updating the agent directly.
expect(segment).toContain("updateAgentLLMConfig("); expect(segment).toContain("updateAgentLLMConfig(");
expect(segment).toContain('conversationIdRef.current !== "default"'); expect(segment).toContain("conversationIdRef.current");
expect(segment).toContain('conversationIdRef.current === "default"');
}); });
test("App defines helper to carry over active conversation model", () => { test("App defines helper to carry over active conversation model", () => {
@@ -116,6 +114,28 @@ describe("model preset refresh wiring", () => {
); );
}); });
test("conversation model override flag is synced for async callbacks", () => {
const path = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url));
const source = readFileSync(path, "utf-8");
// The override flag must be safe to read inside async callbacks (e.g. the
// first streamed chunk sync) without waiting for a render/effect.
expect(source).toMatch(
/\[\s*hasConversationModelOverride,\s*setHasConversationModelOverride,\s*hasConversationModelOverrideRef,\s*\]\s*=\s*useSyncedState\(false\)/,
);
});
test("reasoning tier prefers conversation override model_settings", () => {
const path = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url));
const source = readFileSync(path, "utf-8");
// When a conversation override is active, prefer the conversation model_settings
// snapshot when deriving reasoning effort (not the base agent llm_config).
expect(source).toMatch(
/const effectiveModelSettings = hasConversationModelOverride\s*\?\s*conversationOverrideModelSettings\s*:\s*agentState\?\.model_settings;/,
);
});
test("new conversation flows reapply active conversation model before switching", () => { test("new conversation flows reapply active conversation model before switching", () => {
const path = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url)); const path = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url));
const source = readFileSync(path, "utf-8"); const source = readFileSync(path, "utf-8");

View File

@@ -46,7 +46,7 @@ describe("reasoning tier cycle wiring", () => {
expect(callbackBlocks.length).toBeGreaterThanOrEqual(2); expect(callbackBlocks.length).toBeGreaterThanOrEqual(2);
}); });
test("flush uses conversation-scoped reasoning updates", () => { test("flush uses conversation-scoped reasoning updates (default updates agent)", () => {
const appPath = fileURLToPath( const appPath = fileURLToPath(
new URL("../../cli/App.tsx", import.meta.url), new URL("../../cli/App.tsx", import.meta.url),
); );
@@ -64,8 +64,9 @@ describe("reasoning tier cycle wiring", () => {
const segment = source.slice(start, end); const segment = source.slice(start, end);
expect(segment).toContain("updateConversationLLMConfig("); expect(segment).toContain("updateConversationLLMConfig(");
expect(segment).toContain("updateAgentLLMConfig(");
expect(segment).toContain("conversationIdRef.current"); expect(segment).toContain("conversationIdRef.current");
expect(segment).not.toContain("updateAgentLLMConfig("); expect(segment).toContain('conversationIdRef.current === "default"');
}); });
test("tab-based reasoning cycling is opt-in only", () => { test("tab-based reasoning cycling is opt-in only", () => {