diff --git a/core/engine.go b/core/engine.go index 1b29aab..cfb5535 100644 --- a/core/engine.go +++ b/core/engine.go @@ -1830,6 +1830,9 @@ func (e *Engine) getOrCreateWorkspaceAgent(workspace string) (Agent, *SessionMan if ps, ok := e.agent.(ProviderSwitcher); ok { if ps2, ok2 := agent.(ProviderSwitcher); ok2 { ps2.SetProviders(ps.ListProviders()) + if active := ps.GetActiveProvider(); active != nil && active.Name != "" { + ps2.SetActiveProvider(active.Name) + } } } @@ -4714,7 +4717,13 @@ func (e *Engine) GetAllCommands() []BotCommandInfo { } func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { - switcher, ok := e.agent.(ModelSwitcher) + agent, sessions, interactiveKey, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + + switcher, ok := agent.(ModelSwitcher) if !ok { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgModelNotSupported)) return @@ -4776,7 +4785,7 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { e.replyWithButtons(p, msg.ReplyCtx, sb.String(), buttons) return } - e.replyWithCard(p, msg.ReplyCtx, e.renderModelCard()) + e.replyWithCard(p, msg.ReplyCtx, e.renderModelCard(msg.SessionKey)) return } @@ -4797,17 +4806,17 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { target = resolveModelAlias(models, target) } - target, err := e.switchModel(target) + target, err = e.switchModelOnAgent(agent, target, agent == e.agent) if err != nil { e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgModelChangeFailed, err)) return } - e.cleanupInteractiveState(e.interactiveKeyForSessionKey(msg.SessionKey)) + e.cleanupInteractiveState(interactiveKey) - s := e.sessions.GetOrCreateActive(msg.SessionKey) + s := sessions.GetOrCreateActive(msg.SessionKey) s.SetAgentSessionID("", "") s.ClearHistory() - e.sessions.Save() + sessions.Save() e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgModelChanged, target)) } @@ -4840,17 +4849,22 @@ func parseModelSwitchArgs(args []string) (string, bool) { return "", false } -// switchModel applies a runtime model selection. When an active provider exists, -// its configured model is updated so new sessions use the selected model instead -// of the provider's previous fixed model. Persistence errors are returned so -// callers can avoid claiming success when the change would be lost on reload. +// switchModel applies a runtime model selection to the global engine agent and +// persists the change so reloads keep the selected default. func (e *Engine) switchModel(target string) (string, error) { - switcher, ok := e.agent.(ModelSwitcher) + return e.switchModelOnAgent(e.agent, target, true) +} + +// switchModelOnAgent applies a runtime model selection to the provided agent. +// When persistConfig is true, config-backed model/provider changes are saved so +// reloads keep the new default. Workspace-scoped runtime switches pass false. +func (e *Engine) switchModelOnAgent(agent Agent, target string, persistConfig bool) (string, error) { + switcher, ok := agent.(ModelSwitcher) if !ok { return target, nil } - providerSwitcher, ok := e.agent.(ProviderSwitcher) + providerSwitcher, ok := agent.(ProviderSwitcher) if !ok { if e.modelSaveFunc != nil { if err := e.modelSaveFunc(target); err != nil { @@ -4877,7 +4891,7 @@ func (e *Engine) switchModel(target string) (string, error) { switcher.SetModel(target) return target, nil } - if e.providerModelSaveFunc != nil { + if persistConfig && e.providerModelSaveFunc != nil { if err := e.providerModelSaveFunc(active.Name, target); err != nil { return "", fmt.Errorf("save provider model %q: %w", active.Name, err) } @@ -6020,7 +6034,7 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { case "/help": return e.renderHelpGroupCard(args) case "/model": - return e.renderModelCard() + return e.renderModelCard(sessionKey) case "/reasoning": return e.renderReasoningCard() case "/mode": @@ -6100,7 +6114,8 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { if args == "" { return } - switcher, ok := e.agent.(ModelSwitcher) + agent, sessions := e.sessionContextForKey(sessionKey) + switcher, ok := agent.(ModelSwitcher) if !ok { return } @@ -6116,16 +6131,16 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { } else { target = resolveModelAlias(models, target) } - if _, err := e.switchModel(target); err != nil { + if _, err := e.switchModelOnAgent(agent, target, agent == e.agent); err != nil { slog.Error("failed to switch model from card action", "model", target, "error", err) return } interactiveKey := e.interactiveKeyForSessionKey(sessionKey) e.cleanupInteractiveState(interactiveKey) - s := e.sessions.GetOrCreateActive(sessionKey) + s := sessions.GetOrCreateActive(sessionKey) s.SetAgentSessionID("", "") s.ClearHistory() - e.sessions.Save() + sessions.Save() case "/reasoning": if args == "" { @@ -6658,8 +6673,13 @@ func (e *Engine) renderLangCard() *Card { Build() } -func (e *Engine) renderModelCard() *Card { - switcher, ok := e.agent.(ModelSwitcher) +func (e *Engine) renderModelCard(sessionKey string) *Card { + agent := e.agent + if sessionKey != "" { + agent, _ = e.sessionContextForKey(sessionKey) + } + + switcher, ok := agent.(ModelSwitcher) if !ok { return e.simpleCard(e.i18n.T(MsgCardTitleModel), "indigo", e.i18n.T(MsgModelNotSupported)) } diff --git a/core/engine_test.go b/core/engine_test.go index d7e2264..4a116bd 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -388,6 +388,18 @@ func (a *stubModelModeAgent) AvailableReasoningEfforts() []string { return []string{"low", "medium", "high", "xhigh"} } +type namedStubModelModeAgent struct { + stubModelModeAgent + name string +} + +func (a *namedStubModelModeAgent) Name() string { + if a.name == "" { + return "named-stub-model" + } + return a.name +} + type stubWorkDirAgent struct { stubAgent workDir string @@ -2554,6 +2566,92 @@ func TestCmdModel_DoesNotClaimSuccessWhenModelSaveFails(t *testing.T) { } } +func TestCmdModel_MultiWorkspaceUsesWorkspaceAgentAndSessions(t *testing.T) { + p := &stubPlatformEngine{n: "plain"} + globalAgent := &stubModelModeAgent{model: "gpt-4.1-mini"} + e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + bindingPath := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindingPath) + + wsDir := normalizeWorkspacePath(t.TempDir()) + channelID := "C-model" + e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir) + + ws := e.workspacePool.GetOrCreate(wsDir) + wsAgent := &stubModelModeAgent{model: "gpt-4.1-mini"} + ws.agent = wsAgent + ws.sessions = NewSessionManager("") + + msg := &Message{SessionKey: "feishu:" + channelID + ":u1", ReplyCtx: "ctx"} + + globalSession := e.sessions.GetOrCreateActive(msg.SessionKey) + globalSession.SetAgentSessionID("global-session", "test") + wsSession := ws.sessions.GetOrCreateActive(msg.SessionKey) + wsSession.SetAgentSessionID("workspace-session", "test") + + e.cmdModel(p, msg, []string{"switch", "gpt"}) + + if wsAgent.model != "gpt-4.1" { + t.Fatalf("workspace agent model = %q, want gpt-4.1", wsAgent.model) + } + if globalAgent.model != "gpt-4.1-mini" { + t.Fatalf("global agent model = %q, want unchanged", globalAgent.model) + } + if got := ws.sessions.GetOrCreateActive(msg.SessionKey).AgentSessionID; got != "" { + t.Fatalf("workspace session id = %q, want cleared", got) + } + if got := e.sessions.GetOrCreateActive(msg.SessionKey).AgentSessionID; got != "global-session" { + t.Fatalf("global session id = %q, want untouched", got) + } +} + +func TestGetOrCreateWorkspaceAgent_InheritsActiveProvider(t *testing.T) { + agentName := "test-workspace-provider-inherit" + RegisterAgent(agentName, func(opts map[string]any) (Agent, error) { + agent := &namedStubModelModeAgent{name: agentName} + if model, ok := opts["model"].(string); ok { + agent.model = model + } + if mode, ok := opts["mode"].(string); ok { + agent.mode = mode + } + return agent, nil + }) + + globalAgent := &namedStubModelModeAgent{ + name: agentName, + stubModelModeAgent: stubModelModeAgent{ + model: "gpt-4.1-mini", + mode: "default", + providers: []ProviderConfig{ + {Name: "openai", Model: "gpt-4.1-mini"}, + {Name: "azure", Model: "gpt-4.1"}, + }, + active: "azure", + }, + } + e := NewEngine("test", globalAgent, []Platform{&stubPlatformEngine{n: "plain"}}, "", LangEnglish) + e.SetMultiWorkspace(t.TempDir(), filepath.Join(t.TempDir(), "bindings.json")) + + wsAgentRaw, _, err := e.getOrCreateWorkspaceAgent(normalizeWorkspacePath(t.TempDir())) + if err != nil { + t.Fatalf("getOrCreateWorkspaceAgent returned error: %v", err) + } + + wsAgent, ok := wsAgentRaw.(*namedStubModelModeAgent) + if !ok { + t.Fatalf("workspace agent type = %T, want *namedStubModelModeAgent", wsAgentRaw) + } + if wsAgent.model != "gpt-4.1-mini" { + t.Fatalf("workspace model = %q, want inherited global model", wsAgent.model) + } + if got := wsAgent.GetActiveProvider(); got == nil || got.Name != "azure" { + t.Fatalf("workspace active provider = %#v, want azure", got) + } +} + func TestCmdDir_ShowsCurrentDirectory(t *testing.T) { p := &stubPlatformEngine{n: "plain"} agent := &stubWorkDirAgent{workDir: "/tmp/project-a"} @@ -4954,6 +5052,89 @@ func TestExecuteCardAction_ModelCleansUpWithInteractiveKey(t *testing.T) { } } +func TestExecuteCardAction_ModelUsesWorkspaceContext(t *testing.T) { + p := &stubPlatformEngine{n: "plain"} + globalAgent := &stubModelModeAgent{model: "global-old"} + e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + bindingPath := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindingPath) + + wsDir := normalizeWorkspacePath(t.TempDir()) + channelID := "channel1" + sessionKey := "feishu:" + channelID + ":user1" + e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir) + + ws := e.workspacePool.GetOrCreate(wsDir) + wsAgent := &stubModelModeAgent{model: "workspace-old"} + ws.agent = wsAgent + ws.sessions = NewSessionManager("") + + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + e.interactiveMu.Lock() + e.interactiveStates[interactiveKey] = &interactiveState{} + e.interactiveMu.Unlock() + + globalSession := e.sessions.GetOrCreateActive(sessionKey) + globalSession.SetAgentSessionID("global-session", "test") + wsSession := ws.sessions.GetOrCreateActive(sessionKey) + wsSession.SetAgentSessionID("workspace-session", "test") + + e.executeCardAction("/model", "switch 1", sessionKey) + + if wsAgent.model != "gpt-4.1" { + t.Fatalf("workspace agent model = %q, want gpt-4.1", wsAgent.model) + } + if globalAgent.model != "global-old" { + t.Fatalf("global agent model = %q, want unchanged", globalAgent.model) + } + if got := ws.sessions.GetOrCreateActive(sessionKey).AgentSessionID; got != "" { + t.Fatalf("workspace session id = %q, want cleared", got) + } + if got := e.sessions.GetOrCreateActive(sessionKey).AgentSessionID; got != "global-session" { + t.Fatalf("global session id = %q, want untouched", got) + } + + e.interactiveMu.Lock() + _, exists := e.interactiveStates[interactiveKey] + e.interactiveMu.Unlock() + if exists { + t.Error("expected workspace interactive state to be cleaned up after /model") + } +} + +func TestHandleCardNav_ModelCardUsesWorkspaceAgent(t *testing.T) { + p := &stubPlatformEngine{n: "plain"} + globalAgent := &stubModelModeAgent{model: "global-model"} + e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish) + + baseDir := t.TempDir() + bindingPath := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindingPath) + + wsDir := normalizeWorkspacePath(t.TempDir()) + channelID := "channel-nav" + sessionKey := "feishu:" + channelID + ":user1" + e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir) + + ws := e.workspacePool.GetOrCreate(wsDir) + ws.agent = &stubModelModeAgent{model: "workspace-model"} + ws.sessions = NewSessionManager("") + + card := e.handleCardNav("nav:/model", sessionKey) + if card == nil { + t.Fatal("expected /model card") + } + text := card.RenderText() + if !strings.Contains(text, "workspace-model") { + t.Fatalf("model card text = %q, want workspace model", text) + } + if strings.Contains(text, "global-model") { + t.Fatalf("model card text = %q, should not use global model", text) + } +} + func TestExecuteCardAction_ModeCleansUpWithInteractiveKey(t *testing.T) { p := &stubPlatformEngine{n: "plain"} agent := &stubModelModeAgent{mode: "default"}