Skip to content

Commit eb1aade

Browse files
committed
fix(gateway): preserve configured conversation discovery
1 parent a575f07 commit eb1aade

2 files changed

Lines changed: 172 additions & 4 deletions

File tree

src/gateway/conversation-list.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,124 @@ describe("runGatewayConversationList", () => {
8181
]);
8282
expect(result.conversations[0]).not.toHaveProperty("sessionId");
8383
});
84+
85+
it("merges live directory adapters with config-backed entries", async () => {
86+
const listPeers = vi.fn(async () => [
87+
{ kind: "user" as const, id: "stale-peer", name: "Stale Peer" },
88+
{ kind: "user" as const, id: "shared-peer", name: "Configured Shared Peer" },
89+
]);
90+
const listPeersLive = vi.fn(async () => [
91+
{ kind: "user" as const, id: "live-peer", name: "Live Peer" },
92+
{ kind: "user" as const, id: "shared-peer", name: "Live Shared Peer" },
93+
]);
94+
const listGroups = vi.fn(async () => [
95+
{ kind: "group" as const, id: "stale-group", name: "Stale Group" },
96+
]);
97+
const listGroupsLive = vi.fn(async () => [
98+
{ kind: "group" as const, id: "live-group", name: "Live Group" },
99+
]);
100+
const resolvedTargets: string[] = [];
101+
const deps = {
102+
resolveOutboundChannelPlugin: vi.fn(() => ({
103+
id: "discord",
104+
config: {
105+
listAccountIds: () => ["default"],
106+
resolveAccount: () => ({ enabled: true, configured: true }),
107+
isEnabled: () => true,
108+
isConfigured: () => true,
109+
},
110+
directory: { listPeers, listPeersLive, listGroups, listGroupsLive },
111+
})),
112+
resolveOutboundSessionRoute: vi.fn(async ({ target }: { target: string }) => {
113+
resolvedTargets.push(target);
114+
const direct = target.endsWith("-peer");
115+
return {
116+
sessionKey: `agent:main:discord:${direct ? "direct" : "channel"}:${target}`,
117+
baseSessionKey: `agent:main:discord:${direct ? "direct" : "channel"}:${target}`,
118+
peer: { kind: direct ? ("direct" as const) : ("channel" as const), id: target },
119+
chatType: direct ? ("direct" as const) : ("channel" as const),
120+
from: `discord:${target}`,
121+
to: target,
122+
};
123+
}),
124+
registerConversationAddresses: vi.fn(),
125+
listConversations: vi.fn(() => []),
126+
};
127+
128+
await runGatewayConversationList(
129+
{ config: {}, agentId: "main", channel: "discord", limit: 50 },
130+
deps as never,
131+
);
132+
133+
expect(listPeersLive).toHaveBeenCalledOnce();
134+
expect(listGroupsLive).toHaveBeenCalledOnce();
135+
expect(listPeers).toHaveBeenCalledOnce();
136+
expect(listGroups).toHaveBeenCalledOnce();
137+
expect(resolvedTargets).toEqual([
138+
"stale-peer",
139+
"shared-peer",
140+
"live-peer",
141+
"stale-group",
142+
"live-group",
143+
]);
144+
expect(deps.resolveOutboundSessionRoute).toHaveBeenCalledWith(
145+
expect.objectContaining({
146+
target: "shared-peer",
147+
resolvedTarget: expect.objectContaining({ display: "Live Shared Peer" }),
148+
}),
149+
);
150+
});
151+
152+
it("retains configured peers when live discovery is empty or fails", async () => {
153+
const listPeers = vi.fn(async () => [
154+
{ kind: "user" as const, id: "configured-peer", name: "Configured Peer" },
155+
]);
156+
const listPeersLive = vi.fn(async ({ query }: { query?: string }) =>
157+
query ? [{ kind: "user" as const, id: "live-peer", name: "Live Peer" }] : [],
158+
);
159+
listPeersLive.mockRejectedValueOnce(new Error("directory unavailable"));
160+
const resolvedTargets: string[] = [];
161+
const deps = {
162+
resolveOutboundChannelPlugin: vi.fn(() => ({
163+
id: "discord",
164+
config: {
165+
listAccountIds: () => ["default"],
166+
resolveAccount: () => ({ enabled: true, configured: true }),
167+
isEnabled: () => true,
168+
isConfigured: () => true,
169+
},
170+
directory: { listPeers, listPeersLive },
171+
})),
172+
resolveOutboundSessionRoute: vi.fn(async ({ target }: { target: string }) => {
173+
resolvedTargets.push(target);
174+
return {
175+
sessionKey: `agent:main:discord:direct:${target}`,
176+
baseSessionKey: `agent:main:discord:direct:${target}`,
177+
peer: { kind: "direct" as const, id: target },
178+
chatType: "direct" as const,
179+
from: `discord:${target}`,
180+
to: target,
181+
};
182+
}),
183+
registerConversationAddresses: vi.fn(),
184+
listConversations: vi.fn(() => []),
185+
};
186+
187+
await runGatewayConversationList(
188+
{ config: {}, agentId: "main", channel: "discord", limit: 50 },
189+
deps as never,
190+
);
191+
await runGatewayConversationList(
192+
{ config: {}, agentId: "main", channel: "discord", limit: 50 },
193+
deps as never,
194+
);
195+
196+
expect(listPeers).toHaveBeenCalledTimes(2);
197+
expect(listPeersLive).toHaveBeenCalledTimes(2);
198+
expect(listPeersLive.mock.calls.map(([input]) => input)).toEqual([
199+
expect.not.objectContaining({ query: expect.anything() }),
200+
expect.not.objectContaining({ query: expect.anything() }),
201+
]);
202+
expect(resolvedTargets).toEqual(["configured-peer", "configured-peer"]);
203+
});
84204
});

src/gateway/conversation-list.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ import {
1515
} from "../config/sessions/conversation-registry.js";
1616
import { resolveStorePath } from "../config/sessions/paths.js";
1717
import type { OpenClawConfig } from "../config/types.openclaw.js";
18+
import { formatErrorMessage } from "../infra/errors.js";
1819
import { resolveOutboundChannelPlugin } from "../infra/outbound/channel-resolution.js";
1920
import { resolveOutboundSessionRoute } from "../infra/outbound/outbound-session.js";
21+
import { createSubsystemLogger } from "../logging/subsystem.js";
2022
import { defaultRuntime } from "../runtime.js";
2123

24+
const log = createSubsystemLogger("gateway/conversations");
25+
2226
type ConversationListDeps = {
2327
listConversations: typeof listConversations;
2428
registerConversationAddresses: typeof registerConversationAddresses;
@@ -60,6 +64,25 @@ function presentConversation(conversation: ConversationRecord): ConversationList
6064
};
6165
}
6266

67+
async function listLiveDirectoryEntries(params: {
68+
channel: string;
69+
accountId: string;
70+
kind: "peers" | "groups";
71+
run: () => Promise<ChannelDirectoryEntry[]>;
72+
}): Promise<ChannelDirectoryEntry[]> {
73+
try {
74+
return await params.run();
75+
} catch (error) {
76+
log.warn("live directory discovery failed; using configured entries", {
77+
channel: params.channel,
78+
accountId: params.accountId,
79+
kind: params.kind,
80+
error: formatErrorMessage(error),
81+
});
82+
return [];
83+
}
84+
}
85+
6386
async function listDirectoryEntries(params: {
6487
config: OpenClawConfig;
6588
accountId: string;
@@ -74,11 +97,36 @@ async function listDirectoryEntries(params: {
7497
limit: params.limit,
7598
runtime: defaultRuntime,
7699
};
77-
const [peers, groups] = await Promise.all([
78-
params.plugin.directory?.listPeers?.(input) ?? [],
79-
params.plugin.directory?.listGroups?.(input) ?? [],
100+
const directory = params.plugin.directory;
101+
const listPeersLive = directory?.listPeersLive;
102+
const listGroupsLive = directory?.listGroupsLive;
103+
const [configuredPeers, livePeers, configuredGroups, liveGroups] = await Promise.all([
104+
directory?.listPeers?.(input) ?? [],
105+
listPeersLive
106+
? listLiveDirectoryEntries({
107+
channel: params.plugin.id,
108+
accountId: params.accountId,
109+
kind: "peers",
110+
run: () => listPeersLive(input),
111+
})
112+
: [],
113+
directory?.listGroups?.(input) ?? [],
114+
listGroupsLive
115+
? listLiveDirectoryEntries({
116+
channel: params.plugin.id,
117+
accountId: params.accountId,
118+
kind: "groups",
119+
run: () => listGroupsLive(input),
120+
})
121+
: [],
80122
]);
81-
return [...peers, ...groups];
123+
const entries = new Map<string, ChannelDirectoryEntry>();
124+
for (const entry of [...configuredPeers, ...livePeers, ...configuredGroups, ...liveGroups]) {
125+
// Live results replace config-only metadata without dropping configured addresses when a
126+
// transport's live adapter is search-only and returns nothing for an unfiltered listing.
127+
entries.set(`${entry.kind}\u0000${entry.id.trim()}`, entry);
128+
}
129+
return [...entries.values()];
82130
}
83131

84132
async function discoverChannelAddresses(params: {

0 commit comments

Comments
 (0)