From 66f0cc2a16fff6f175f29b3b50a2fe3ba27465af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 19 Apr 2026 11:34:43 +0100 Subject: [PATCH] fix(web): persist agent profile mode through bulk-edit save (#626) --- apps/backend/cmd/mock-agent/main.go | 24 ++++++- .../server/adapter/transport/acp/adapter.go | 21 +++++- .../adapter/transport/acp/conversion_test.go | 35 ++++++++++ apps/web/app/actions/agents.ts | 11 ++- .../[agentId]/agent-save-helpers.test.ts | 54 +++++++++++++++ .../agents/[agentId]/agent-save-helpers.ts | 4 ++ .../task/dockview-header-actions.tsx | 18 +---- apps/web/components/task/mode-selector.tsx | 1 + .../tests/settings/agent-profile-acp.spec.ts | 68 +++++++++++++++++-- 9 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index fce8808a29..bc20719a52 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -75,9 +75,10 @@ func (a *mockAgent) Initialize(_ context.Context, _ acp.InitializeRequest) (acp. // NewSession creates a new conversation session. // MCP servers from the ACP request are registered so callMCPTool can use them. -// The Models field advertises the available models so the host utility -// capability probe can populate them in the cache — this is what makes -// the utility-agents settings page show model options for mock-agent in E2E. +// The Models and Modes fields advertise available capabilities so the host +// utility capability probe can populate them in the cache — this is what makes +// the utility-agents settings page show model and mode options for mock-agent +// in E2E, and lets profile-mode tests select a non-default mode. func (a *mockAgent) NewSession(_ context.Context, req acp.NewSessionRequest) (acp.NewSessionResponse, error) { sid := acp.SessionId(fmt.Sprintf("mock-session-%d", os.Getpid())) a.mu.Lock() @@ -91,6 +92,7 @@ func (a *mockAgent) NewSession(_ context.Context, req acp.NewSessionRequest) (ac return acp.NewSessionResponse{ SessionId: sid, Models: mockSessionModels(), + Modes: mockSessionModes(), }, nil } @@ -109,6 +111,22 @@ func mockSessionModels() *acp.UnstableSessionModelState { } } +// mockSessionModes returns the mock agent's advertised session-mode list for +// ACP session responses. The "default" mode is current; "plan-mock" is an +// alternative used by tests that need to verify a non-default profile mode +// propagates from the agent profile through to a new task session. +func mockSessionModes() *acp.SessionModeState { + defaultDesc := "Default mock mode" + planDesc := "Plan-style mock mode for testing" + return &acp.SessionModeState{ + CurrentModeId: "default", + AvailableModes: []acp.SessionMode{ + {Id: "default", Name: "Default", Description: &defaultDesc}, + {Id: "plan-mock", Name: "Plan Mock", Description: &planDesc}, + }, + } +} + // LoadSession restores a previous session for resume. func (a *mockAgent) LoadSession(_ context.Context, req acp.LoadSessionRequest) (acp.LoadSessionResponse, error) { a.mu.Lock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 1f4741626f..d26627cc7f 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -108,6 +108,11 @@ type Adapter struct { // Used by SetModel to validate the requested model exists. availableModels []acp.UnstableModelInfo + // Available modes from the most recent session creation/load. + // Used by SetMode to include cached modes in the event so the + // frontend mode selector can render available options. + availableModes []streams.SessionModeInfo + // Synchronization mu sync.RWMutex closed bool @@ -1031,6 +1036,11 @@ func (a *Adapter) emitInitialModeState(modes *acp.SessionModeState) { Description: derefStr(m.Description), }) } + // Cache available modes so SetMode can include them in subsequent events. + a.mu.Lock() + a.availableModes = availModes + a.mu.Unlock() + a.sendUpdate(AgentEvent{ Type: streams.EventTypeSessionMode, SessionID: a.sessionID, @@ -1103,10 +1113,15 @@ func (a *Adapter) SetMode(ctx context.Context, modeID string) error { return fmt.Errorf("set session mode failed: %w", err) } + a.mu.RLock() + cachedModes := a.availableModes + a.mu.RUnlock() + a.sendUpdate(AgentEvent{ - Type: streams.EventTypeSessionMode, - SessionID: sessionID, - CurrentModeID: modeID, + Type: streams.EventTypeSessionMode, + SessionID: sessionID, + CurrentModeID: modeID, + AvailableModes: cachedModes, }) return nil } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go index 3cb0fe5514..4bbfac9544 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go @@ -885,6 +885,41 @@ func TestEmitInitialModeState(t *testing.T) { } } +func TestEmitInitialModeState_CachesAvailableModes(t *testing.T) { + a := newTestAdapter() + a.sessionID = "session-cache" + + desc := "Architect mode" + modes := &acp.SessionModeState{ + CurrentModeId: "architect", + AvailableModes: []acp.SessionMode{ + {Id: "default", Name: "Default"}, + {Id: "architect", Name: "Architect", Description: &desc}, + }, + } + + a.emitInitialModeState(modes) + drainEvents(a) // consume the emitted event + + // Verify modes were cached in the adapter + a.mu.RLock() + cached := a.availableModes + a.mu.RUnlock() + + if len(cached) != 2 { + t.Fatalf("expected 2 cached modes, got %d", len(cached)) + } + if cached[0].ID != "default" { + t.Errorf("cached[0].ID = %q, want %q", cached[0].ID, "default") + } + if cached[1].ID != "architect" { + t.Errorf("cached[1].ID = %q, want %q", cached[1].ID, "architect") + } + if cached[1].Description != "Architect mode" { + t.Errorf("cached[1].Description = %q, want %q", cached[1].Description, "Architect mode") + } +} + // --- sendUpdate --- func TestSendUpdate_NormalOperation(t *testing.T) { diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 7f2eb3dcaf..40d803accc 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -48,7 +48,9 @@ export async function listAgentsAction(): Promise { export async function createAgentAction(payload: { name: string; workspace_id?: string | null; - profiles?: Array<{ name: string; model: string; cli_passthrough: boolean } & ProfilePermissions>; + profiles?: Array< + { name: string; model: string; mode?: string; cli_passthrough: boolean } & ProfilePermissions + >; }): Promise { return fetchJson(`${apiBaseUrl}/api/v1/agents`, { method: "POST", @@ -76,7 +78,12 @@ export async function deleteAgentAction(id: string) { export async function createAgentProfileAction( agentId: string, - payload: { name: string; model: string; cli_passthrough: boolean } & ProfilePermissions, + payload: { + name: string; + model: string; + mode?: string; + cli_passthrough: boolean; + } & ProfilePermissions, ): Promise { return fetchJson(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, { method: "POST", diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts new file mode 100644 index 0000000000..e6526a577e --- /dev/null +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import type { AgentProfile } from "@/lib/types/http"; +import { isProfileDirty, type DraftProfile } from "./agent-save-helpers"; + +const baseProfile: AgentProfile = { + id: "p1", + agent_id: "a1", + name: "Profile", + agent_display_name: "Mock", + model: "mock-fast", + mode: "default", + allow_indexing: false, + cli_passthrough: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const draftFrom = (saved: AgentProfile, overrides: Partial = {}): DraftProfile => ({ + ...saved, + ...overrides, +}); + +describe("isProfileDirty", () => { + it("returns false when draft equals saved", () => { + expect(isProfileDirty(draftFrom(baseProfile), baseProfile)).toBe(false); + }); + + it("returns true when only mode changes", () => { + const draft = draftFrom(baseProfile, { mode: "plan-mock" }); + expect(isProfileDirty(draft, baseProfile)).toBe(true); + }); + + it("treats undefined mode as equal to empty string", () => { + const saved: AgentProfile = { ...baseProfile, mode: undefined }; + const draft = draftFrom(saved, { mode: "" }); + expect(isProfileDirty(draft, saved)).toBe(false); + }); + + it("returns true when mode changes from empty to a value", () => { + const saved: AgentProfile = { ...baseProfile, mode: "" }; + const draft = draftFrom(saved, { mode: "plan-mock" }); + expect(isProfileDirty(draft, saved)).toBe(true); + }); + + it("returns true when mode changes from a value to empty (cleared)", () => { + const saved: AgentProfile = { ...baseProfile, mode: "plan-mock" }; + const draft = draftFrom(saved, { mode: "" }); + expect(isProfileDirty(draft, saved)).toBe(true); + }); + + it("returns true when there is no saved profile", () => { + expect(isProfileDirty(draftFrom(baseProfile))).toBe(true); + }); +}); diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts index ad34564117..3e28c52186 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts @@ -123,6 +123,7 @@ export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentC profiles: draftAgent.profiles.map((profile) => ({ name: profile.name, model: profile.model, + mode: profile.mode, ...permissionsToProfilePatch(profile), cli_passthrough: profile.cli_passthrough ?? false, })), @@ -175,6 +176,7 @@ async function saveExistingProfiles( const createdProfile = await createAgentProfileAction(savedAgent.id, { name: profile.name, model: profile.model, + mode: profile.mode, ...permissionsToProfilePatch(profile), cli_passthrough: profile.cli_passthrough ?? false, }); @@ -190,6 +192,7 @@ async function saveExistingProfiles( const updatedProfile = await updateAgentProfileAction(profile.id, { name: profile.name, model: profile.model, + mode: profile.mode, ...permissionsToProfilePatch(profile), }); nextProfiles.push(updatedProfile); @@ -255,6 +258,7 @@ export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boole return ( draft.name !== saved.name || draft.model !== saved.model || + (draft.mode ?? "") !== (saved.mode ?? "") || arePermissionsDirty(draft, saved) || draft.cli_passthrough !== saved.cli_passthrough ); diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 873f132e7e..633f080daa 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -419,22 +419,6 @@ function RightTopGroupActions() { ); } -function SessionModeBadge() { - const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const modeState = useAppStore((state) => { - if (!activeSessionId) return null; - return state.sessionMode.bySessionId[activeSessionId] ?? null; - }); - - if (!modeState?.currentModeId) return null; - - return ( - - {modeState.currentModeId} - - ); -} - function CenterRightActions() { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const repository = useAppStore((state) => { @@ -469,7 +453,7 @@ function CenterRightActions() { return (
- + {/* Mode is shown in the chat input ModeSelector instead */} {hasDevScript && (