diff --git a/ui/src/app/agent-selection.test.ts b/ui/src/app/agent-selection.test.ts index fa3e30264d13..d9e3488193f6 100644 --- a/ui/src/app/agent-selection.test.ts +++ b/ui/src/app/agent-selection.test.ts @@ -138,6 +138,165 @@ describe("agent selection", () => { expect(selection.state).toEqual({ selectedId: "research", scopeId: "research" }); }); + it("adopts a changed default after the reconnect null phase", () => { + const harness = createGateway("Main"); + const roster = createRoster(); + const selection = createAgentSelectionCapability(harness.gateway, roster.roster); + roster.publish({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [{ id: "main", kind: "agent" }], + }); + const client = { request() {} } as unknown as GatewayBrowserClient; + + harness.publish({ client, assistantAgentId: null }); + expect(selection.state).toEqual({ selectedId: "main", scopeId: "main" }); + harness.publish({ client, assistantAgentId: "Ops" }); + expect(selection.state).toEqual({ selectedId: "main", scopeId: "main" }); + roster.publish({ + defaultId: "ops", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", kind: "agent" }, + { id: "ops", kind: "agent" }, + ], + }); + + expect(selection.state).toEqual({ selectedId: "ops", scopeId: "ops" }); + }); + + it("keeps following hello after the roster repairs an invalid startup default", () => { + const harness = createGateway("Stale"); + const roster = createRoster(); + roster.publish({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [{ id: "main", kind: "agent" }], + }); + const selection = createAgentSelectionCapability(harness.gateway, roster.roster); + const client = { request() {} } as unknown as GatewayBrowserClient; + + expect(selection.state).toEqual({ selectedId: "main", scopeId: "main" }); + harness.publish({ client, assistantAgentId: "Ops" }); + roster.publish({ + defaultId: "ops", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", kind: "agent" }, + { id: "ops", kind: "agent" }, + ], + }); + + expect(selection.state).toEqual({ selectedId: "ops", scopeId: "ops" }); + }); + + it("preserves an explicit owner through reconnect and a changed default", () => { + const harness = createGateway("Main"); + const selection = createAgentSelectionCapability(harness.gateway, createRoster().roster); + selection.set("Research"); + const client = { request() {} } as unknown as GatewayBrowserClient; + + harness.publish({ client, assistantAgentId: null }); + harness.publish({ client, assistantAgentId: "Ops" }); + + expect(selection.state).toEqual({ selectedId: "research", scopeId: "research" }); + }); + + it("establishes explicit ownership before notifying selection subscribers", () => { + const harness = createGateway("Main"); + const selection = createAgentSelectionCapability(harness.gateway, createRoster().roster); + const client = { request() {} } as unknown as GatewayBrowserClient; + selection.subscribe((state) => { + if (state.selectedId === "research") { + harness.publish({ client, assistantAgentId: "Ops" }); + } + }); + + selection.set("Research"); + + expect(selection.state).toEqual({ selectedId: "research", scopeId: "research" }); + }); + + it("follows hello again after the roster removes an explicit owner", () => { + const harness = createGateway("Main"); + const roster = createRoster(); + roster.publish({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", kind: "agent" }, + { id: "research", kind: "agent" }, + ], + }); + const selection = createAgentSelectionCapability(harness.gateway, roster.roster); + selection.set("Research"); + + roster.publish({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [{ id: "main", kind: "agent" }], + }); + expect(selection.state).toEqual({ selectedId: "main", scopeId: "main" }); + + const client = { request() {} } as unknown as GatewayBrowserClient; + harness.publish({ client, assistantAgentId: null }); + harness.publish({ client, assistantAgentId: "Ops" }); + roster.publish({ + defaultId: "ops", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", kind: "agent" }, + { id: "ops", kind: "agent" }, + ], + }); + + expect(selection.state).toEqual({ selectedId: "ops", scopeId: "ops" }); + }); + + it("preserves a reentrant explicit owner during roster fallback", () => { + const harness = createGateway("Main"); + const roster = createRoster(); + roster.publish({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", kind: "agent" }, + { id: "ops", kind: "agent" }, + { id: "research", kind: "agent" }, + ], + }); + const selection = createAgentSelectionCapability(harness.gateway, roster.roster); + selection.set("Research"); + selection.subscribe((state) => { + if (state.selectedId === "main") { + selection.set("Ops"); + } + }); + + roster.publish({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", kind: "agent" }, + { id: "ops", kind: "agent" }, + ], + }); + const client = { request() {} } as unknown as GatewayBrowserClient; + harness.publish({ client, assistantAgentId: null }); + harness.publish({ client, assistantAgentId: "Main" }); + + expect(selection.state).toEqual({ selectedId: "ops", scopeId: "ops" }); + }); + it("self-heals an unknown cold-load selection to the roster default", () => { const gateway = createGateway("Main"); const roster = createRoster(); diff --git a/ui/src/app/agent-selection.ts b/ui/src/app/agent-selection.ts index adda21a60dab..6dbf9e231700 100644 --- a/ui/src/app/agent-selection.ts +++ b/ui/src/app/agent-selection.ts @@ -75,6 +75,9 @@ export function createAgentSelectionCapability( scopeId: resolveScopeId(initialSelectedId), }; let assistantAgentId = initialId; + // Construction has no explicit-selection input: a roster repair still follows + // hello until a navigation or picker action establishes explicit ownership. + let followsGatewayDefault = true; const listeners = new Set<(next: AgentSelectionState) => void>(); const publish = (next: AgentSelectionState) => { @@ -97,13 +100,25 @@ export function createAgentSelectionCapability( ? normalizeAgentId(next.assistantAgentId) : null; const assistantChanged = nextAssistantAgentId !== assistantAgentId; - const followedPreviousDefault = state.selectedId === assistantAgentId; assistantAgentId = nextAssistantAgentId; - if (assistantChanged && (!state.selectedId || followedPreviousDefault)) { + // A reconnect publishes a transient null before hello. Keep the last + // implicit default selected until the next authoritative default arrives. + if (assistantChanged && followsGatewayDefault && nextAssistantAgentId) { publish({ selectedId: nextAssistantAgentId, scopeId: nextAssistantAgentId }); } }); - roster.subscribe(() => publish(state)); + roster.subscribe(() => { + // Re-enable implicit ownership before publishing the roster fallback. A + // synchronous subscriber may establish a new explicit owner during publish. + if (!followsGatewayDefault && reconcileSelectedId(state.selectedId) !== state.selectedId) { + followsGatewayDefault = true; + } + if (followsGatewayDefault && assistantAgentId) { + publish({ selectedId: assistantAgentId, scopeId: assistantAgentId }); + } else { + publish(state); + } + }); return { get state() { @@ -114,6 +129,8 @@ export function createAgentSelectionCapability( // A chip/chat switch establishes a new global page scope. The separate // scope field lets page controls expose all agents without losing the // concrete agent required by chat and new-session flows. + // Establish ownership before publish notifies synchronous subscribers. + followsGatewayDefault = reconcileSelectedId(selectedId) !== selectedId; publish({ selectedId, scopeId: selectedId }); }, setScope(agentId) {