fix(web): keep agent tab title synced with model (#2021)

This commit is contained in:
Carlos Florêncio
2026-07-28 23:44:19 +01:00
committed by GitHub
parent abf10d7814
commit d340512cc6
7 changed files with 177 additions and 33 deletions
@@ -3,6 +3,7 @@ import { resolveSessionTabTitle } from "./session-tab-title";
const SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const SPARK_MODEL_NAME = "GPT-5.3-Codex-Spark";
const DEFAULT_MODEL_ID = "mock-default";
const PROFILE_LABEL = "GPT-5.5 (medium)";
const baseArgs = {
@@ -41,16 +42,60 @@ describe("resolveSessionTabTitle", () => {
).toBe(SPARK_MODEL_NAME);
});
it("keeps the saved profile label when ACP reports its baseline model", () => {
it("uses the authoritative current model over the saved profile label", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
currentModelId: SPARK_MODEL_ID,
modelOptions: [{ id: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }],
}),
).toBe(PROFILE_LABEL);
).toBe(SPARK_MODEL_NAME);
});
it("uses the live model config value when the generic current model is stale", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
currentModelId: DEFAULT_MODEL_ID,
configOptions: [
{
type: "select",
id: "model",
name: "Model",
currentValue: SPARK_MODEL_ID,
options: [
{ value: DEFAULT_MODEL_ID, name: "Mock Default" },
{ value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME },
],
},
],
}),
).toBe(SPARK_MODEL_NAME);
});
it("prefers a provider-updated model config over a stale active model", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
activeModelId: DEFAULT_MODEL_ID,
configOptions: [
{
type: "select",
id: "model",
name: "Model",
currentValue: SPARK_MODEL_ID,
options: [
{ value: DEFAULT_MODEL_ID, name: "Mock Default" },
{ value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME },
],
},
],
}),
).toBe(SPARK_MODEL_NAME);
});
});
describe("resolveSessionTabTitle fallbacks", () => {
it("includes non-model config selections in the title", () => {
expect(
resolveSessionTabTitle({
@@ -62,7 +107,7 @@ describe("resolveSessionTabTitle", () => {
type: "select",
id: "model",
name: "Model",
currentValue: "gpt-5.5",
currentValue: SPARK_MODEL_ID,
options: [
{ value: "gpt-5.5", name: "GPT-5.5" },
{ value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME },
@@ -42,10 +42,12 @@ function resolveModelTitle(
export function resolveSessionTabTitle(args: ResolveSessionTabTitleArgs): string | null {
if (args.customName) return args.customName;
const modelConfig = args.configOptions.find(isModelConfigOption);
const currentModelId = modelConfig?.currentValue || args.currentModelId;
return (
resolveModelTitle(args, currentModelId) ??
resolveModelTitle(args, args.activeModelId) ??
args.agentLabel ??
resolveModelTitle(args, args.currentModelId) ??
resolveModelTitle(args, args.snapshotModel)
);
}
+13 -3
View File
@@ -362,6 +362,18 @@ function SessionTabBody({
return <SessionTabTriggerContent props={props} {...contentProps} />;
}
/** Keeps the Dockview panel title synchronized when Dockview restores a stale title. */
function useSessionTabTitleSync(api: IDockviewPanelHeaderProps["api"], tabTitle: string | null) {
useEffect(() => {
const syncTitle = () => {
if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle);
};
syncTitle();
const disposable = api.onDidTitleChange(syncTitle);
return () => disposable.dispose();
}, [tabTitle, api]);
}
/**
* Custom dockview tab for session panels.
* Shows agent logo, index badge, and star for primary; right-click for lifecycle actions.
@@ -389,9 +401,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState);
useEffect(() => {
if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle);
}, [tabTitle, api]);
useSessionTabTitleSync(api, tabTitle);
const showMultiSessionBadges = sessionCount > 1;
// Multi-session tab close means delete, not hide-only. Running/starting sessions are
+5
View File
@@ -1368,6 +1368,11 @@ export class SessionPage {
return this.sessionTabByText(text).locator(".tabler-icon-star").first();
}
/** Primary star icon inside a session tab identified by its session ID. */
primaryStarInSessionTab(sessionId: string): Locator {
return this.sessionTabBySessionId(sessionId).locator(".tabler-icon-star").first();
}
/** "Move to next step" button in the chat status bar. */
proceedNextStepButton(): Locator {
return this.page.getByTestId("proceed-next-step");
@@ -168,6 +168,60 @@ test.describe("Chat model selector — RPC failure", () => {
* session snapshot and SSR re-served the pre-change model on reload.
*/
test.describe("Chat model selector — persistence", () => {
test("agent tab keeps the selected model title after reload", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Agent Tab Model Title Test",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected an auto-started session");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
const trigger = testPage.getByRole("button", { name: "Session model settings" });
await trigger.click();
await testPage.getByRole("option", { name: /Mock Smart/ }).click();
await expect(trigger).toContainText("Mock Smart", { timeout: 5_000 });
const agentTab = session.sessionTabBySessionId(task.session_id);
await expect(agentTab).toContainText("Mock Smart", { timeout: 5_000 });
await expect
.poll(async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
const metadata = sessions.find((item) => item.id === task.session_id)?.metadata;
const runtime = metadata?.runtime_config as { model?: string } | undefined;
const selector = metadata?.acp_model_state as
| {
config_options?: Array<{ id?: string; current_value?: string }>;
}
| undefined;
const selectorModel = selector?.config_options?.find(
(option) => option.id === "model",
)?.current_value;
return `${runtime?.model}/${selectorModel}`;
})
.toBe("mock-smart/mock-smart");
await testPage.reload();
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
await expect(trigger).toContainText("Mock Smart", { timeout: 15_000 });
await expect(agentTab).toContainText("Mock Smart", { timeout: 15_000 });
});
test("completed turn metadata keeps changed options after a page reload", async ({
testPage,
apiClient,
@@ -227,10 +227,14 @@ test.describe("Workflow agent profile switching", () => {
const session = new SessionPage(testPage);
await expect(session.chat).toBeVisible({ timeout: 15_000 });
// Wait for the first session tab to appear with the correct profile name
const sessionTabs = testPage.locator('[data-testid^="session-tab-"]');
await expect(sessionTabs.first()).toBeVisible({ timeout: 30_000 });
await expect(sessionTabs.first()).toContainText("Profile A", { timeout: 10_000 });
// The session tab represents the workflow-started Profile A session. Its title
// is model-derived, so identify the tab by stable session ID instead of text.
const initialSessions = await pollSessions(apiClient, task.id, 1, 30_000);
const profileASession = initialSessions.find((item) => item.agent_profile_id === profileA.id);
expect(profileASession).toBeDefined();
await expect(session.sessionTabBySessionId(profileASession!.id)).toBeVisible({
timeout: 30_000,
});
// Wait for the agent to be ready (WAITING_FOR_INPUT) before moving
for (let i = 0; i < 20; i++) {
@@ -443,10 +447,11 @@ test.describe("Workflow agent profile switching", () => {
/**
* Verifies the StartCreatedSession fix: when a task is created in a step with an
* agent profile override, the session tab shows the step's agent profile name
* (not the workspace default).
* agent profile override, the initial session uses that profile rather than
* the workspace default. Tab titles are model-derived, so the tab is located
* by its stable session ID.
*/
test("initial task creation shows step's agent profile in session tab", async ({
test("initial task creation uses step's agent profile for the session tab", async ({
testPage,
apiClient,
seedData,
@@ -483,10 +488,12 @@ test.describe("Workflow agent profile switching", () => {
const session = new SessionPage(testPage);
await expect(session.chat).toBeVisible({ timeout: 15_000 });
// The session tab should show "Profile A" (the step's agent profile name)
const sessionTab = testPage.locator('[data-testid^="session-tab-"]').first();
await expect(sessionTab).toBeVisible({ timeout: 30_000 });
await expect(sessionTab).toContainText("Profile A", { timeout: 10_000 });
const sessions = await pollSessions(apiClient, task.id, 1, 30_000);
const profileASession = sessions.find((item) => item.agent_profile_id === profileA.id);
expect(profileASession).toBeDefined();
await expect(session.sessionTabBySessionId(profileASession!.id)).toBeVisible({
timeout: 30_000,
});
});
/**
@@ -542,8 +549,10 @@ test.describe("Workflow agent profile switching", () => {
const session = new SessionPage(testPage);
await expect(session.chat).toBeVisible({ timeout: 15_000 });
// Wait for Profile A tab to appear and be active
const profileATab = session.sessionTabByText("Profile A");
const initialSessions = await pollSessions(apiClient, task.id, 1, 60_000);
const profileASession = initialSessions.find((item) => item.agent_profile_id === profileA.id);
expect(profileASession).toBeDefined();
const profileATab = session.sessionTabBySessionId(profileASession!.id);
await expect(profileATab).toBeVisible({ timeout: 60_000 });
// Wait for agent to be ready (WAITING_FOR_INPUT)
@@ -560,11 +569,16 @@ test.describe("Workflow agent profile switching", () => {
// Move to Step2 — triggers new session with profileB
await apiClient.moveTask(task.id, workflow.id, step2.id);
// The new "Profile B" tab should appear and the primary star should move to it
// (the active tab stays on Profile A because the SSR-load pin protects it).
const profileBTab = session.sessionTabByText("Profile B");
// The new Profile B session tab should appear and the primary star should move to it.
// The active tab stays on Profile A because the SSR-load pin protects it.
const finalSessions = await pollSessions(apiClient, task.id, 2, 60_000);
const profileBSession = finalSessions.find((item) => item.agent_profile_id === profileB.id);
expect(profileBSession).toBeDefined();
const profileBTab = session.sessionTabBySessionId(profileBSession!.id);
await expect(profileBTab).toBeVisible({ timeout: 60_000 });
await expect(session.primaryStarInTab("Profile B")).toBeVisible({ timeout: 60_000 });
await expect(session.primaryStarInSessionTab(profileBSession!.id)).toBeVisible({
timeout: 60_000,
});
});
/**
@@ -620,10 +634,15 @@ test.describe("Workflow agent profile switching", () => {
// Move to Step1 → auto_start with profileA → on_turn_complete → Step2 (profileB)
await apiClient.moveTask(task.id, workflow.id, step1.id);
// After the cascade, the Profile B tab should appear and own the primary star.
const profileBTab = session.sessionTabByText("Profile B");
// After the cascade, the Profile B session tab should appear and own the primary star.
const finalSessions = await pollSessions(apiClient, task.id, 2, 90_000);
const profileBSession = finalSessions.find((item) => item.agent_profile_id === profileB.id);
expect(profileBSession).toBeDefined();
const profileBTab = session.sessionTabBySessionId(profileBSession!.id);
await expect(profileBTab).toBeVisible({ timeout: 90_000 });
await expect(session.primaryStarInTab("Profile B")).toBeVisible({ timeout: 90_000 });
await expect(session.primaryStarInSessionTab(profileBSession!.id)).toBeVisible({
timeout: 90_000,
});
});
/**
@@ -687,12 +706,17 @@ test.describe("Workflow agent profile switching", () => {
// Move to Step2
await apiClient.moveTask(task.id, workflow.id, step2.id);
// The Profile B tab should appear and own the primary star (no reload needed).
// The Profile B session tab should appear and own the primary star (no reload needed).
// The non-terminal Profile A tab stays user-pinned, so the star moving is
// what proves SetPrimarySession's WS broadcast landed.
const profileBTab = session.sessionTabByText("Profile B");
const finalSessions = await pollSessions(apiClient, task.id, 2, 60_000);
const profileBSession = finalSessions.find((item) => item.agent_profile_id === profileB.id);
expect(profileBSession).toBeDefined();
const profileBTab = session.sessionTabBySessionId(profileBSession!.id);
await expect(profileBTab).toBeVisible({ timeout: 60_000 });
await expect(session.primaryStarInTab("Profile B")).toBeVisible({ timeout: 60_000 });
await expect(session.primaryStarInSessionTab(profileBSession!.id)).toBeVisible({
timeout: 60_000,
});
});
/**
@@ -741,8 +765,11 @@ test.describe("Workflow agent profile switching", () => {
const session = new SessionPage(testPage);
await expect(session.chat).toBeVisible({ timeout: 15_000 });
// Wait for Profile B session (Step1 override) to be ready
const profileBTab = session.sessionTabByText("Profile B");
// Wait for the Profile B session (Step1 override) to be ready.
const initialSessions = await pollSessions(apiClient, task.id, 1, 60_000);
const profileBSession = initialSessions.find((item) => item.agent_profile_id === profileB.id);
expect(profileBSession).toBeDefined();
const profileBTab = session.sessionTabBySessionId(profileBSession!.id);
await expect(profileBTab).toBeVisible({ timeout: 60_000 });
await expect
.poll(
@@ -775,7 +802,6 @@ test.describe("Workflow agent profile switching", () => {
)
.toEqual({ profileBPrimary: true, profileBCompleted: false, profileACount: 0 });
await expect(profileBTab).toBeVisible({ timeout: 60_000 });
await expect(session.sessionTabByText("Profile A")).toHaveCount(0);
});
/**
@@ -20,6 +20,7 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
- Hovering or focusing the closed task selector shows a compact tooltip containing every currently rendered selector option as provider-supplied `Name: Value` rows, including baseline-matching values. The tooltip contains no descriptions or inferred provider knowledge. Opening the selector shows compact option names and selected values; entering an option submenu shows that option's provider description and the provider descriptions of its selectable values when supplied.
- Kandev preserves optional ACP descriptions for both top-level config options and selectable values throughout the adapter, backend event, WebSocket, store, and selector pipeline. Missing descriptions produce no invented or hard-coded explanatory text.
- Task-detail boot data includes the last persisted model list, live config options, and provider-default baseline so the compact label is complete on the first render instead of repainting after WebSocket reconnection.
- An unnamed agent tab derives its title from the task session's authoritative current model and live config values. The title updates when the model changes and remains correct after a task-detail refresh; a user-supplied session name continues to override the derived title.
- Each turn stores an immutable configuration snapshot when the turn is created. The snapshot contains the effective model, mode, ordered selected config values with their provider-supplied display names, and the task-session provider-default baseline used for comparison. Agent-message metadata renders from this turn snapshot instead of the session's latest mutable runtime configuration.
- Agent-message metadata always shows the attributed model and shows only non-model config values that differ from the turn's captured baseline. It keeps option names in the compact row because message attribution is read outside the selector context. Baseline-matching values and the session mode are omitted from the compact row; the mode remains available in turn metadata.
- Prompt-usage metadata may refine the turn's attributed model when the provider reports the actual model used, but it does not replace the turn's captured config values or baseline.
@@ -39,6 +40,7 @@ ACP agents can advertise an arbitrary ordered set of model-adjacent session conf
- **GIVEN** an ACP option or value supplies a description, **WHEN** the user enters that option's submenu, **THEN** Kandev shows the provider text. The closed trigger and top-level option list remain compact, and missing descriptions leave the description region absent.
- **GIVEN** a task selector has current model and config values, **WHEN** the user hovers or focuses its closed trigger, **THEN** a compact tooltip lists every selector option as `Name: Value` without provider descriptions, including values omitted from the changed-only trigger label.
- **GIVEN** a task session has persisted dynamic model configuration, **WHEN** the task-detail page is refreshed, **THEN** the first rendered selector label includes all changed values without waiting for a WebSocket event.
- **GIVEN** an unnamed agent tab whose selected model changes during the session, **WHEN** the model update converges or the task-detail page is refreshed, **THEN** the existing tab title shows the current selected model instead of the agent profile's original model label.
- **GIVEN** turn A runs with reasoning `High`, **WHEN** reasoning changes to `Low` before turn B, **THEN** turn A continues to show `Reasoning effort: High` and turn B shows `Reasoning effort: Low`.
- **GIVEN** a turn uses only provider-default options, **WHEN** its agent-message metadata renders, **THEN** the compact row shows only the attributed model.
- **GIVEN** a legacy turn has no configuration snapshot, **WHEN** the session's runtime options later change, **THEN** the legacy turn does not display those current options.