What the user experiences
A user switches ML Intern on, from the pill or the launch card, then starts the chat by clicking one of the suggested prompts instead of typing. The conversation that opens is not an ML Intern conversation, even though the mode looked on when they clicked. The strip disappears once the page loads and the model answers as an ordinary chat.
That is worse than the mode quietly switching itself off, because the Hub MCP server is still selected. The model can still call hf_jobs and hf_sandbox, and it does so without anything the mode normally puts around them:
- No session budget. The budget guard only runs for mode conversations, so nothing bounds what a job can cost.
- No pre-flight discipline. The preset prompt that requires a name, flavor, timeout and cost estimate before a submission is not sent.
- No billing routing. Jobs bill the user's personal account even when they selected an organization under Settings → Billing.
This was hit while testing #2567: fresh chats showed ML Intern enabled and an organization selected, and jobs launched under the personal namespace. Conversations already persisted as ML Intern behaved correctly.
Why it happens
The mode is decided at conversation creation from a client-side latch, and only one of the send paths sets that latch.
src/lib/stores/mlAssistant.svelte.ts has two flags: enabled (the toggle) and taskStarted, which only startTask() sets.
- The create request sends
mlAssistant: mlAssistant.taskStarted (src/routes/+page.svelte, src/routes/models/[...model]/+page.svelte), and the server marks the conversation only when that is true (src/routes/conversation/+server.ts).
handleSubmit in src/lib/components/chat/ChatWindow.svelte calls startTask() before onmessage. The intro's prompt examples (ChatIntroduction → onmessage) and the fix/resume path (sendFixRequest) call onmessage directly and never latch.
- So with the toggle on, a click on a prompt example creates the conversation with
mlAssistant: false. After navigation, syncConversation sees a conversation that was not started in the mode and resets the store, which is why the toggle appears to turn itself off.
More generally, the truth about the mode lives in a module singleton across a two-step flow (create, navigate, then send the pending first message), with adoption and reset heuristics in syncConversation. Every new send path has to remember to latch, and nothing checks that the UI agrees with what the server persisted.
Steps to reproduce
- On the home page, switch ML Intern on.
- Click one of the suggested prompts instead of typing a message.
- The conversation opens without the ML Intern strip, the sidebar entry is not marked, and the conversation record has no
mlAssistant: true.
- With the Hub MCP server enabled and a prompt that leads to a job, the job is submitted with no budget hold and under the personal namespace.
Proposed fix
- Route every initial send through one function that reads
enabled at send time, puts an explicit boolean in the create request, and latches taskStarted only after the server confirms.
- After creation, take the mode from the conversation the server returns (the create response already seeds it) rather than from the local latch.
- Defence in depth on the server: the budget guard and the billing rewrite exist only for mode conversations. Either gate Hub job and sandbox tools outside the mode, or install the billing rewrite for any conversation that has a Hub server, so a latch failure cannot launch unguarded compute.
What the user experiences
A user switches ML Intern on, from the pill or the launch card, then starts the chat by clicking one of the suggested prompts instead of typing. The conversation that opens is not an ML Intern conversation, even though the mode looked on when they clicked. The strip disappears once the page loads and the model answers as an ordinary chat.
That is worse than the mode quietly switching itself off, because the Hub MCP server is still selected. The model can still call
hf_jobsandhf_sandbox, and it does so without anything the mode normally puts around them:This was hit while testing #2567: fresh chats showed ML Intern enabled and an organization selected, and jobs launched under the personal namespace. Conversations already persisted as ML Intern behaved correctly.
Why it happens
The mode is decided at conversation creation from a client-side latch, and only one of the send paths sets that latch.
src/lib/stores/mlAssistant.svelte.tshas two flags:enabled(the toggle) andtaskStarted, which onlystartTask()sets.mlAssistant: mlAssistant.taskStarted(src/routes/+page.svelte,src/routes/models/[...model]/+page.svelte), and the server marks the conversation only when that istrue(src/routes/conversation/+server.ts).handleSubmitinsrc/lib/components/chat/ChatWindow.sveltecallsstartTask()beforeonmessage. The intro's prompt examples (ChatIntroduction→onmessage) and the fix/resume path (sendFixRequest) callonmessagedirectly and never latch.mlAssistant: false. After navigation,syncConversationsees a conversation that was not started in the mode and resets the store, which is why the toggle appears to turn itself off.More generally, the truth about the mode lives in a module singleton across a two-step flow (create, navigate, then send the pending first message), with adoption and reset heuristics in
syncConversation. Every new send path has to remember to latch, and nothing checks that the UI agrees with what the server persisted.Steps to reproduce
mlAssistant: true.Proposed fix
enabledat send time, puts an explicit boolean in the create request, and latchestaskStartedonly after the server confirms.