fix(ui): preserve agent selection across reconnects

This commit is contained in:
Peter Steinberger
2026-07-26 23:25:07 -07:00
parent a08fabd8a5
commit f8a3023a46
2 changed files with 179 additions and 3 deletions

View File

@@ -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();

View File

@@ -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) {