fix(core): route workspace model changes through session context
This commit is contained in:
1 parent
2a8c8d3a4a
commit
5639e863d5
2 files changed
+221
-20
No files matched your search
+40
-20
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in new issue
Block a user