fix(web): persist agent profile mode through bulk-edit save (#626)

This commit is contained in:
Carlos Florêncio
2026-04-19 11:34:43 +01:00
committed by GitHub
parent 1f7e4dbb5e
commit 66f0cc2a16
9 changed files with 206 additions and 30 deletions
+21 -3
View File
@@ -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()
@@ -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
}
@@ -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) {
+9 -2
View File
@@ -48,7 +48,9 @@ export async function listAgentsAction(): Promise<ListAgentsResponse> {
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<Agent> {
return fetchJson<Agent>(`${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<AgentProfile> {
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, {
method: "POST",
@@ -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> = {}): 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);
});
});
@@ -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
);
@@ -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 (
<span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded">
{modeState.currentModeId}
</span>
);
}
function CenterRightActions() {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const repository = useAppStore((state) => {
@@ -469,7 +453,7 @@ function CenterRightActions() {
return (
<div className="flex items-center gap-1">
<SessionModeBadge />
{/* Mode is shown in the chat input ModeSelector instead */}
{hasDevScript && (
<Button
size="sm"
@@ -78,6 +78,7 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect
<Button
variant="ghost"
size="sm"
data-testid="session-mode-selector"
className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap"
>
<span className="text-xs">{displayName}</span>
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
/**
* Verifies the ACP-first profile editor:
@@ -8,8 +9,10 @@ import { test, expect } from "../../fixtures/test-base";
* moved to ACP session modes + per-tool-call permission_request prompts.
* - Profile name edits persist across reload (exercises the new AgentProfile
* DTO shape with `mode` / `migrated_from` columns).
* - Mode picker is hidden when the agent's capability cache has no modes —
* the mock agent in E2E isn't probed so no mode data flows through.
* - Mode picker renders when the agent's capability cache advertises modes.
* The E2E mock agent advertises modes in NewSession so the picker appears.
* - Profile mode propagates to the session UI: mode selector visible with the
* correct active mode after launching a task with a non-default profile mode.
*/
test.describe("Agent profile — ACP-first", () => {
test("profile editor loads with model picker and without legacy permission toggles", async ({
@@ -36,9 +39,8 @@ test.describe("Agent profile — ACP-first", () => {
await expect(testPage.getByText(/Skip Permissions/i)).toHaveCount(0);
await expect(testPage.getByText(/dangerously skip/i)).toHaveCount(0);
// The mock agent isn't an InferenceAgent, so the host utility cache has no
// modes for it and the mode picker is not rendered.
await expect(testPage.getByTestId("profile-mode-field")).toHaveCount(0);
// The mock agent advertises modes, so the mode picker is rendered.
await expect(testPage.getByTestId("profile-mode-field")).toBeVisible({ timeout: 10_000 });
});
test("profile name edits persist across reload", async ({ testPage, apiClient }) => {
@@ -85,4 +87,60 @@ test.describe("Agent profile — ACP-first", () => {
await apiClient.updateAgentProfile(profile.id, { name: originalName });
}
});
test("profile mode propagates to session mode selector", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT", "REVIEW"];
// 1. Get mock agent and create a profile with mode "plan-mock"
const { agents } = await apiClient.listAgents();
const agent = agents[0];
const profile = await apiClient.createAgentProfile(agent.id, "Mode Test Profile", {
model: "mock-fast",
mode: "plan-mock",
});
try {
// 2. Create task using the profile with mode set
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mode Selector Test",
profile.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// 3. Wait for session to finish its first turn
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for session to finish" },
)
.toBe(true);
// 4. Navigate to the task session
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// 5. Assert the mode selector is visible and shows the profile mode
const modeSelector = testPage.getByTestId("session-mode-selector");
await expect(modeSelector).toBeVisible({ timeout: 15_000 });
await expect(modeSelector).toContainText("Plan Mock");
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
});