mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 17:31:35 +00:00
fix(ui): preserve agent selection across reconnects
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user