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
3 changes: 3 additions & 0 deletions cmd/codeaf/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1939,6 +1939,9 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire.
if door, ok := conn.Conv.(interface{ ModelIdentity() session.ModelIdentity }); ok {
state := door.ModelIdentity()
modelIdentity = &state
if state.Selected != "" {
model = state.Selected
}
}
effort := conn.Conv.ReasoningFor(model)
if dial, ok := conn.Conv.(interface{ ConversationEffort() string }); ok {
Expand Down
136 changes: 136 additions & 0 deletions cmd/codeaf/app_model_for_now_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package main

import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/appwire"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/modelsource/sourcestub"
"github.com/Agent-Field/codeaf/internal/session"
)

// C1, C2, C3: the app reopens the saved choice and title route, leaving settings
// and setup state byte-identical across an automatic run-only selection.
func TestAppReopensConfiguredModelAfterRunOnlyReplacement(t *testing.T) {
t.Setenv(home.EnvVar, t.TempDir())
profile := t.TempDir()
t.Setenv(config.ProfileDirEnv, profile)
dir, workspace := t.TempDir(), t.TempDir()
path := filepath.Join(dir, "transcript.jsonl")
provider := sourcestub.New("vendor/configured", "available/model", "vendor/backup")
defer provider.Close()
settings := []byte(`{
"model.talk": "vendor/configured",
"models.tiers.low": "",
"models.tiers.reflex": "vendor/reflex",
"models.tiers.worker": "vendor/worker",
"models.tiers.high": "vendor/checker",
"models.tiers.mastermind": "vendor/planner",
"models.roles": "planner:vendor/planner",
"task.model": "vendor/task",
"models.tasks.mode": "auto",
"models.tasks.one": "vendor/one"
}
`)
if err := os.WriteFile(config.BudgetConfigPath(profile), settings, 0600); err != nil {
t.Fatal(err)
}
rolesSource, err := v3RolesSource(workspace, profile)
if err != nil {
t.Fatal(err)
}
cfg := session.Config{Workspace: workspace, Place: session.Place{Dir: dir, Workspace: workspace}, SessionFile: path,
Model: "vendor/configured", APIKey: "fixture", BaseURL: provider.URL(), ProfileDir: profile, System: "SYSTEM",
RolesSource: rolesSource,
ConversationModels: &session.ConversationModels{Main: "vendor/configured", Fallback: "vendor/backup"},
}
a, err := session.New(cfg)
if err != nil {
t.Fatal(err)
}
defer a.Close()
var reopened *session.Agent
b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) {
meta, err := session.LoadMeta(dir)
if err != nil {
return nil, err
}
fresh := cfg
fresh.Model = meta.Model
reopened, err = session.New(fresh)
if err != nil {
return nil, err
}
return &appConn{Conv: reopened, Workspace: workspace, Transcript: path, Release: func() { _ = reopened.Close() }}, nil
})
defer b.Close()
b.state = &appState{path: filepath.Join(t.TempDir(), appStateName)}
if err := b.state.write(func(f *appStateFile) error {
f.Setup = &appwire.Setup{DefaultModel: cfg.Model}
return nil
}); err != nil {
t.Fatal(err)
}
stateBefore, err := os.ReadFile(b.state.where())
if err != nil {
t.Fatal(err)
}
if err := a.UseModelForNow("available/model"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
events, err := a.Submit(ctx, "respond on the available model")
if err != nil {
t.Fatal(err)
}
for event := range events {
if event.Kind == session.EventError {
t.Fatalf("scripted turn failed: %v", event.Err)
}
}
if err := a.Close(); err != nil {
t.Fatal(err)
}
a.SettleWrites()
profileAfter, err := os.ReadFile(config.BudgetConfigPath(profile))
if err != nil || !bytes.Equal(settings, profileAfter) {
t.Fatalf("fallback wrote profile: %s, %v", profileAfter, err)
}
stateAfter, err := os.ReadFile(b.state.where())
if err != nil || !bytes.Equal(stateBefore, stateAfter) {
t.Fatalf("fallback wrote app setup: %s, %v", stateAfter, err)
}
row, ok := session.ReadRows([]string{path})[filepath.Clean(path)]
if !ok {
t.Fatal("saved conversation is missing from app inventory")
}
listed := appRowView(row)
if listed.Model != cfg.Model || listed.ModelIdentity == nil || listed.ModelIdentity.Selected != cfg.Model {
t.Fatalf("agents.list row=%+v", listed)
}
b.remember(row)
opened, err := b.OpenAgent(listed.ID)
if err != nil || opened.Model != cfg.Model {
t.Fatalf("agents.open=%+v, %v", opened, err)
}
routes, _, err := b.errandRoutes(listed.ID)
if err != nil {
t.Fatal(err)
}
for _, route := range routes {
if route.Role == "title" {
if route.Model != cfg.Model {
t.Fatalf("agents.errand.models title=%+v", route)
}
return
}
}
t.Fatal("agents.errand.models omitted title")
}
12 changes: 8 additions & 4 deletions cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,15 +452,19 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C
}
if options.Agent != nil {
// THE ENGINE RESOLVES THE PROFILE. A connected service is already on
// disk when this callback runs; setting the current model through the
// existing wire door makes the engine re-read that disk without sending
// a key or address through a second protocol.
// disk when this callback runs. Reapplying the run-only model makes the
// engine re-read that disk without saving an automatic replacement or
// sending a key or address through the wire.
stock := options.ApplyModelSources
options.ApplyModelSources = func(sources modelsource.Set) {
if stock != nil {
stock(sources)
}
options.Agent.SetModel(options.Agent.Model())
if door, ok := options.Agent.(interface{ UseModelForNow(string) error }); ok {
_ = door.UseModelForNow(options.Agent.Model())
} else {
options.Agent.SetModel(options.Agent.Model())
}
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions cmd/codeaf/chatv3_model_for_now_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"testing"

"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/modelsource"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/tui3"
)

// C1, C2, C4: refreshing linked-local provider sources must not confirm the
// temporary model as a saved choice before catalog reconciliation can restore it.
func TestLinkedLocalSourceRefreshKeepsRunOnlyModelTemporary(t *testing.T) {
dir, profile := t.TempDir(), t.TempDir()
agent, err := session.New(session.Config{Workspace: t.TempDir(), Place: session.Place{Dir: dir}, Model: "vendor/configured", APIKey: "fixture", BaseURL: "http://127.0.0.1:1/v1"})
if err != nil {
t.Fatal(err)
}
defer agent.Close()
loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) {
return &remote.Engine{Agent: agent, RefreshModelSources: func() { agent.SetSources(modelsource.Set{}) }}, nil
}})
if err != nil {
t.Fatal(err)
}
defer loop.Close()
if err := loop.Client.Agent().UseModelForNow("available/model"); err != nil {
t.Fatal(err)
}
options := tui3.Options{Agent: loop.Client.Agent(), ProfileDir: profile}
localDoors(&options, remote.Welcome{ProfileDir: profile}, config.Config{ProfileDir: profile})
options.ApplyModelSources(modelsource.Set{})
agent.SettleWrites()
meta, err := session.LoadMeta(dir)
if err != nil || meta.Model != "vendor/configured" || agent.Model() != "available/model" || loop.Client.Agent().ConfiguredModel() != "vendor/configured" {
t.Fatalf("source refresh persisted the run-only model: %+v, %v", meta, err)
}
if config.ChatModelAt(profile) != "" {
t.Fatal("source refresh wrote a talk preference")
}
}
13 changes: 13 additions & 0 deletions docs/changes/unreleased/2465-fallback-model-run-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
kind: fixed
title: a fallback model is used for that run only and never becomes the conversation's saved model
pr: 2465
surface: [chat, engine, remote, app, docs]
invalidates:
- "Was true: when a local launch replaced an unlisted configured model with the first listed model, the replacement became the conversation's saved model and the agent reopened on it. Is true now: the replacement serves requests only, the saved model stays the configured one, and the conversation returns to it as soon as it is listed."
---

The same holds after disconnecting the service of the running model. An untouched chat
still saves the build's default when it follows a credit reading. Choosing a model yourself, including the replacement that is already
running, and connecting a service still save the choice. The desktop app reports the
configured model. A chat continued from another computer keeps its existing replacement rule.
5 changes: 3 additions & 2 deletions internal/manual/chat/conversations-and-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,9 @@ Use the Memories tab to forget a saved memory separately. Deleting a team also p

Ask “start another chat on kimi-k3 to read the README.” Load the `chats` capability group if needed. The `start_chat` tool takes
`brief`, the complete first message, and `model`, a catalog ID or unique model name.
Omitting `model` uses the requesting chat's current model. An unknown or ambiguous
name is refused before any chat opens. The new chat opens behind the current one,
Omitting `model` saves the requesting chat's configured model in the new chat.
The new chat applies its own temporary availability replacement before sending.
An unknown or ambiguous name is refused before any chat opens. The new chat opens behind the current one,
with its model and approval posture set before its first message. Your focus stays
in the original chat. Its receipt confirms the request; the terminal reports whether
it actually opened. This needs an attached interactive terminal that can open chats; app-only sessions do not offer this tool.
Expand Down
13 changes: 11 additions & 2 deletions internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,22 @@ On a local launch, the opening model comes from the same available chat list as
is kept only if that model is listed. Otherwise codeaf selects the first available
chat model in provider order. With only Ollama connected, that is an installed Ollama
model. With several providers, the default still belongs to their combined list.
Automatic replacements do not overwrite your saved preference.
Availability replacements apply only to this run. They do not overwrite the
conversation's saved model, main/fallback pair or profile model settings. When
the configured model is listed again, codeaf returns to it automatically.
A hidden chat reconciles availability when it next comes to the front.
Choosing a model yourself, including the replacement already running, saves that
choice as usual.

While every catalog is cold or empty, no default model is displayed and a message
stays in the draft. The picker opens with:
`no available model · connect a provider or refresh /model`.
When a provider's list arrives, codeaf selects an available model. Press Enter again
to send the draft. Refreshing a list or removing a connection also replaces a model
to send the draft. The replacement is displayed immediately, and each send waits
for that chat's model switch to finish. A failed selection reports
`could not change model · <reason>`; the message still sends once on the engine's
current model, and the next availability check retries the selection.
Refreshing a list or removing a connection also replaces a model
that is no longer listed; existing requests finish on the model they started with.

Remote sessions keep the engine's opening model; this laptop's catalog does not
Expand Down
7 changes: 6 additions & 1 deletion internal/manual/chat/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ A provider answering the current turn cannot be cut:
the disconnected sentence first and then says
`this conversation was on deepseek-direct/deepseek-v4-pro · it is now on ~deepseek/deepseek-v4-flash-latest`, or, when nothing can replace it,
`this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a provider or pick a model`.
The replacement is for this run only; it leaves the saved model and profile model
settings unchanged. The configured model returns when it is available again.
Choosing the replacement yourself saves it, even if it is already running.

OpenRouter uses the same two-Enter confirmation. A saved profile key is cleared from the
profile and the running session; its models disappear, while Ollama and other connected
Expand Down Expand Up @@ -306,7 +309,9 @@ empty key box keeps the saved key. A changed name is a rename: every model id al
name is re-spelled with the new one, the conversation's own pick first (a turn still
answering is waited out), and with it the stored ones: reasoning levels, the
worker, checker and planner pins, role pins, the fallback chain and the capability slots. A rename changes a label and nothing
else; it does not move the conversation onto a different model. `ctrl+r` on the row
else; it carries saved and temporary selections separately, keeping a temporary
replacement unsaved. A deferred rename stays with its conversation; a later
explicit model or main/fallback choice supersedes it. `ctrl+r` on the row
reconnects with the saved details. The `+ add a provider` row checks the address and asks for a name,
requesting a key only when the server requires authentication, so the tab never sends you to `/connect` to add one.
The `active provider` row reads
Expand Down
51 changes: 51 additions & 0 deletions internal/remote/model_for_now.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package remote

import (
"errors"
"fmt"
)

const MethodUseModelForNow = "UseModelForNow"

const modelForNowUnsupported = "run-only model selection is unsupported by this engine"

// ConfiguredModel reads the saved selection, rather than this run's replacement.
func (a *Agent) ConfiguredModel() string {
if selected := a.ModelIdentity().Selected; selected != "" {
return selected
}
return a.Model()
}

// UseModelForNow is acknowledged before the surface sends its next request.
// Older engines retain their existing SetModel behavior.
func (a *Agent) UseModelForNow(model string) error {
_, err := a.c.call(nil, MethodUseModelForNow, model)
if err != nil && (err.Error() == fmt.Sprintf("engine: no such method %q", MethodUseModelForNow) || err.Error() == modelForNowUnsupported) {
a.SetModel(model)
return nil
}
return err
}

func (s *server) modelForNowCall(agent WrappedAgent, call Frame) ([]byte, bool, error) {
if call.Method != MethodUseModelForNow {
return nil, false, nil
}
model, err := arg[string](call)
if err != nil {
return nil, true, err
}
door, ok := agent.(interface{ UseModelForNow(string) error })
if !ok {
return nil, true, errors.New(modelForNowUnsupported)
}
if refresh := s.session.engine.RefreshModelSources; refresh != nil {
refresh()
}
if err := door.UseModelForNow(model); err != nil {
return nil, true, err
}
s.session.announce()
return nil, true, nil
}
Loading
Loading