fix(web): persist agent profile mode through bulk-edit save (#626)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user