Skip to content

fix(sessions): switch the client when /resume changes provider or model - #1902

Open
diyaavirmani wants to merge 1 commit into
Nano-Collective:mainfrom
diyaavirmani:fix/resume-switch-client
Open

diyaavirmani wants to merge 1 commit into
Nano-Collective:mainfrom
diyaavirmani:fix/resume-switch-client

Conversation

@diyaavirmani

Copy link
Copy Markdown
Contributor

Description

Closes #1856.

applySession set the provider and model labels to the resumed session's values but never created a client for them. After an in-app /resume <id>, or a pick in the session picker, the next turns were still served by the previous model, while the UI, the saved session and /stats recorded them under the resumed one. Startup --resume only looked right because init creates the client afterwards and overwrites the labels.

As suggested on the issue, the in-app paths now go through the existing programmatic switch, handleModelSelect(provider, model, true), the one the mode toggle uses.

Change:

  • useAppHandlers.tsx:
    • applySession no longer sets the provider or model. Whoever owns the client sets them: init at startup, or the switch below.
    • A new resumeSession applies the conversation, then calls handleModelSelect(session.provider, session.model, true, session.messages). /resume <id> and the session picker both use it.
    • The conversation goes first, so the labels always match the live client. Autosave fires on label changes. With the switch first, it could pair the old conversation with the new labels for a moment.
  • useModeHandlers.tsx: handleModelSelect takes the history to size-check as an optional fourth argument (default: the current messages, so other callers behave as before). Without it, resuming a small session from a long conversation could warn "History exceeds model-b's window. Run /compact or /clear", which measured the conversation being left behind, and /clear would wipe the session just resumed.
  • session-handler.ts: /resume <id> awaits the switch, so the command only completes, and a queued prompt only starts, once the new client is in place.

What handleModelSelect already handles:

Resumed session Result
Another provider A client for that provider and model
Same provider, another model setModel on the live client
Same provider and model No change
A provider that is no longer configured Its existing error. The current client and labels stay, and the session is still resumed on the current model.

Startup --continue / --resume <id> is unchanged, since a switch there would race client init. The docs (session-management.md) now say which paths switch and which keep the startup provider.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Changeset

  • Added a changeset (pnpm changeset) describing this change for the changelog

Testing

Automated Tests

  • New features include passing tests in .spec.ts/tsx files
  • All existing tests pass (pnpm test:all completes successfully)
  • Tests cover both success and error scenarios

New tests:

  • useAppHandlers.spec.tsx:
    • the session picker calls handleModelSelect with the session's provider, model, true and its messages, after the conversation is applied
    • /resume <id> does the same, and doesn't signal completion until the switch resolves
    • a failed switch still resumes the conversation
  • useModeHandlers.spec.tsx:
    • switching to another provider builds a client for it and sets the labels
    • a provider that isn't configured keeps the current client and labels and reports an error
    • the overflow warning sizes the history it is given, not the current messages

pnpm run test:all passes on macOS with Node 22.23.3: format, lint, types, 9,834 AVA tests, knip and audit. Semgrep isn't installed here, so the script skipped that step.

The issue's "same provider and model creates no client" case is the existing handleModelSelect short-circuits when provider and model are unchanged test.

With the source changes reverted, the three resume tests and the history test fail. The two provider tests pass either way: they pin down the handleModelSelect behaviour that resume now relies on.

Manual Testing

  • Tested with Ollama
  • Tested with OpenRouter
  • Tested with OpenAI-compatible API
  • Tested MCP integration (if applicable)

End to end through the real interactive TUI on macOS with Node 22.23.3, driven through a pseudo-terminal:

  • Providers: two scripted local OpenAI-compatible servers. provA (models model-a, model-a2) always answers "answer served by provider A". provB (model-b) always answers "answer served by provider B".
  • Each case: start on --provider provA --model model-a, resume a saved session, send one prompt.
  • Source of truth: which server received the request, and the model in its body. Also checked: the label on the reply, the provider/model autosave wrote to the session file, and the /stats ledger.
Case v1.31.0 (npm, latest) upstream main f5d82179 v1.32.0 release PR 6111c064 this branch release PR + this fix
/resume <id> of a provB/model-b session A answers, labelled model-b; stats provB/model-b A answers, labelled model-b; session and stats provB/model-b same as main B answers (model-b); label, session and stats provB/model-b same as this branch
Session picker, same session A answers, labelled model-b; stats provB/model-b A answers, labelled model-b; session and stats provB/model-b same as main B answers (model-b); label, session and stats provB/model-b same as this branch
/resume <id> of a provGone/model-x session (provider not configured) A answers, labelled model-x; stats provGone/model-x A answers, labelled model-x; session and stats provGone/model-x same as main "Failed to switch to provGone: ConfigurationError: Provider 'provGone' not found in agents.config.json…"; A answers, label, session and stats provA/model-a same as this branch
/resume <id> of a provA/model-a2 session (same provider) request sent as model-a, labelled model-a2; stats provA/model-a2 request sent as model-a, labelled model-a2; session and stats provA/model-a2 same as main request sent as model-a2; label, session and stats provA/model-a2 same as this branch
Control: nanocoder --resume <id> of the provB session A answers; label, session and stats provA/model-a same same same same

Notes on the run:

  • v1.31.0 session file: that version doesn't save the new turn before /exit (the session file still holds the original 2 messages), so its saved-session result isn't evidence either way. Main and the release PR do save it.
  • Cherry-pick: the fix applies cleanly onto the v1.32.0 release PR.
  • Timeouts: the host was under heavy load from other jobs (load average 20–34). In the first pass, some cells timed out in the pty driver before the prompt was sent: waiting for boot, or an Enter that reached the trust prompt before its input handler was live. I reran those cells with longer waits. Every cell that ran gave the result above.

Checklist

  • If this was for an open issue, I was assigned to it
  • Code follows project style guidelines
  • Self-review completed
  • Documentation updated (if needed)
  • No breaking changes (or clearly documented)
  • Appropriate logging added using structured logging (no new runtime paths; a failed switch reports through handleModelSelect's existing error message)

applySession set the provider and model labels to the resumed session's
values but never created a client for them, so after an in-app /resume
or a pick in the session picker the next turns were still served by the
previous model while the UI, the saved session and /stats recorded them
under the resumed one. Startup --resume only looked right because init
creates the client afterwards and overwrites the labels.

applySession now leaves provider and model alone. The in-app paths go
through a new resumeSession that applies the conversation and then calls
the same programmatic switch the mode toggle uses
(handleModelSelect(provider, model, true)). That covers a new provider,
a new model on the same provider, and a provider that is no longer
configured, where handleModelSelect reports the error and the current
client and labels stay. The conversation is applied first, so autosave
never pairs the old messages with the new labels in between.

handleModelSelect takes the history to size-check as an optional fourth
argument. /resume passes the resumed messages; without it the overflow
warning measured the conversation being left behind and could tell the
user to /clear a session that fits. /resume <id> now awaits the switch,
so the command only completes, and a queued prompt only starts, once the
new client is in place. Startup --resume is unchanged.

Closes Nano-Collective#1856.
@github-actions

Copy link
Copy Markdown
Contributor

nc-review: comments — 1 nit

@diyaavirmani — a few things worth a look, none blocking.

The change correctly routes the in-app /resume <id> and the session picker through the existing programmatic switch (handleModelSelect), so the live LLM client now matches the resumed session's provider/model instead of only the labels; the overflow check is given the resumed history explicitly so it no longer measures the conversation being left behind, and /resume awaits the switch so a queued prompt only starts once the new client is in place. Startup --continue/--resume deliberately keeps the startup provider, which matches the docs and is now robust to effect ordering because applySession no longer touches the labels. The three new useAppHandlers tests and three new useModeHandlers tests are meaningful (they fail with the source changes reverted), the changeset uses the correct package name, and no duplicate PR exists in the corpus. One narrow edge case in the new resume path is worth a look: a resumed session whose model was removed from agents.config.json while the provider stayed configured is not validated, and leaves the live client pointed at a model the provider no longer serves.

⚪ nit · correctness · source/hooks/useModeHandlers.tsx:117

The same-provider branch of handleModelSelect mutates the live client without checking the model is still configured:

if (sameProvider) {
    if (client) {
        client.setModel(selectedModel);
        setCurrentModel(selectedModel);

AISDKClient.setModel does not validate, and handleModelSelect is only reachable with an arbitrary model string from /resume. So resuming a session whose model was removed from agents.config.json while the provider stayed configured points the live client at a model the provider no longer serves; the next request fails at the API and the client stays in that state until the user switches models by hand.

The different-provider branch does not have this problem: createLLMClient rejects an unknown model with Model 'X' not available for provider 'Y' and leaves the current client and labels alone, which is the behaviour the issue asked for when the provider is gone.

Worth a guard in the same-provider branch — e.g. check client.getProviderConfig().models and report the mismatch the way the provider branch already does — but this only fires when a model was removed from the config, and the failure is a visible API error rather than a mislabelled response, so it does not need to block the merge.


🔴 blocking · 🟠 a reviewer would ask for a change · ⚪ optional

Automated code review — correctness, security, design, tests, plus duplicates and scope. A human still decides; this is not a substitute for review and is not exhaustive. The required status checks separately cover lint, formatting, types, unused dependencies, the test suite and the build. This bot never merges. Maintainers can rerun with /re-review.

@github-actions github-actions Bot added the agent:comments nc-review left non-blocking findings label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:comments nc-review left non-blocking findings area:docs Documentation area:tui Terminal UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] In-app /resume changes the model label but keeps the old client, so replies come from the previous model

1 participant