fix(core): route workspace model changes through session context

This commit is contained in:
Jerry authored and GitHub committed 2026-03-27 23:31:08 +08:00
1 parent 2a8c8d3a4a
commit 5639e863d5
2 files changed
+221 -20

No files matched your search

+40 -20
View File
@@ -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))
}
+181
View File
@@ -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"}