diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index d8671bd3bfda..690573c2bb90 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -386,6 +386,7 @@ enum class GatewayMethod( UiCommand("ui.command"), ApprovalHistory("approval.history"), PluginSurfaceRefresh("plugin.surface.refresh"), + ConversationsList("conversations.list"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index c3688ac67ce4..11a9619a9152 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1216,6 +1216,92 @@ public struct ConversationSendResult: Codable, Sendable { } } +public struct ConversationListItem: Codable, Sendable { + public let conversationref: String + public let channel: String + public let accountid: String + public let kind: AnyCodable + public let target: String + public let threadid: String? + public let label: String? + public let firstseenat: Int + public let lastseenat: Int + + public init( + conversationref: String, + channel: String, + accountid: String, + kind: AnyCodable, + target: String, + threadid: String? = nil, + label: String? = nil, + firstseenat: Int, + lastseenat: Int) + { + self.conversationref = conversationref + self.channel = channel + self.accountid = accountid + self.kind = kind + self.target = target + self.threadid = threadid + self.label = label + self.firstseenat = firstseenat + self.lastseenat = lastseenat + } + + private enum CodingKeys: String, CodingKey { + case conversationref = "conversationRef" + case channel + case accountid = "accountId" + case kind + case target + case threadid = "threadId" + case label + case firstseenat = "firstSeenAt" + case lastseenat = "lastSeenAt" + } +} + +public struct ConversationListParams: Codable, Sendable { + public let agentid: String + public let channel: String? + public let query: String? + public let limit: Int? + + public init( + agentid: String, + channel: String? = nil, + query: String? = nil, + limit: Int? = nil) + { + self.agentid = agentid + self.channel = channel + self.query = query + self.limit = limit + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case channel + case query + case limit + } +} + +public struct ConversationListResult: Codable, Sendable { + public let conversations: [ConversationListItem] + + public init( + conversations: [ConversationListItem]) + { + self.conversations = conversations + } + + private enum CodingKeys: String, CodingKey { + case conversations + } +} + public struct ConversationTurnCancelParams: Codable, Sendable { public let agentid: String public let turnid: String diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 02366c0be829..db0d604be375 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -65,7 +65,7 @@ If you need the exact raw transcript, inspect the scoped SQLite transcript rows A **session** is local model context. A **conversation** is an exact external address such as one peer, channel, or thread. The two are linked, but they are not interchangeable: direct messages can share one `main` session while retaining separate conversation addresses. -`conversations_list` returns opaque `conversationRef` values for the active agent. Conversation discovery and delivery are owner-only because they use the Gateway's channel credentials. Use `conversations_send` for fire-and-forget delivery. Use `conversations_turn` when the remote reply belongs to the current model turn: the Gateway reserves one transport message ID, persists a delivery operation and queue intent before transport I/O, and returns the correlated reply from the tool instead of starting a second local agent turn. Delivery operations live outside model transcripts; a captured reply is retained only as a side artifact while the tool result owns model context. If the Gateway restarts after queueing, delivery can recover but a later reply follows ordinary inbound dispatch because the process-local waiter is gone. Unsolicited inbound messages always continue through the normal channel dispatch path. +`conversations_list` returns opaque `conversationRef` values for the active agent. With an explicit `channel`, the Gateway also refreshes addresses from that channel's local directory, such as approved Reef peers; use `query` to find a specific peer beyond the current result page. Discovery catalogs the address without creating a model-context session; the backing session is created only when delivery or inbound context needs it. Conversation discovery and delivery are owner-only because they use the Gateway's channel credentials. Use `conversations_send` for fire-and-forget delivery. Use `conversations_turn` when the remote reply belongs to the current model turn: the Gateway reserves one transport message ID, persists a delivery operation and queue intent before transport I/O, and returns the correlated reply from the tool instead of starting a second local agent turn. Delivery operations live outside model transcripts; a captured reply is retained only as a side artifact while the tool result owns model context. If the Gateway restarts after queueing, delivery can recover but a later reply follows ordinary inbound dispatch because the process-local waiter is gone. Unsolicited inbound messages always continue through the normal channel dispatch path. Use the shared `message` tool when you already have an explicit raw channel target or need a channel-specific action. Conversation references are scoped to the active agent and should be obtained through `conversations_list`, not constructed from session keys. diff --git a/extensions/reef/src/channel.test.ts b/extensions/reef/src/channel.test.ts index c27afb2fdb1f..6bd70fd01985 100644 --- a/extensions/reef/src/channel.test.ts +++ b/extensions/reef/src/channel.test.ts @@ -1,5 +1,20 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateSyncKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { defaultRuntime } from "openclaw/plugin-sdk/runtime"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { generateIdentity } from "../protocol/index.js"; +import { reefPlugin } from "./channel.js"; +import { resolveReefConfig } from "./config-schema.js"; import { resolveReefInboundDispatchContent } from "./inbound.js"; +import { setReefRuntime } from "./runtime.js"; +import { openReefTrustStore } from "./trust-store.js"; describe("Reef inbound dispatch content", () => { it("keeps provenance model-visible without storing it in the transcript body", () => { @@ -41,3 +56,50 @@ describe("Reef inbound dispatch content", () => { }); }); }); + +describe("Reef conversation directory", () => { + let stateDir = ""; + + beforeEach(() => { + resetPluginStateStoreForTests(); + // openclaw-temp-dir: allow Reef directory tests need an on-disk state root; afterEach removes it. + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "reef-directory-")); + const runtime = createPluginRuntimeMock(); + runtime.state.openSyncKeyedStore = (options: OpenKeyedStoreOptions) => + createPluginStateSyncKeyedStoreForTests("reef", { + ...options, + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + setReefRuntime(runtime); + const identity = generateIdentity(); + openReefTrustStore(runtime, resolveReefConfig({ channels: { reef: { handle: "clawd" } } })).set( + "molty", + { + autonomy: "bounded", + ed25519PublicKey: identity.signing.publicKey, + x25519PublicKey: identity.encryption.publicKey, + keyEpoch: 1, + safetyNumberChanged: false, + approvedAt: 1_752_537_600_000, + }, + ); + }); + + afterEach(() => { + resetPluginStateStoreForTests(); + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("exposes locally trusted peers as routable directory entries", async () => { + const cfg = { channels: { reef: { handle: "clawd" } } }; + await expect( + reefPlugin.directory?.listPeers?.({ + cfg, + accountId: "default", + query: "@molty", + limit: 10, + runtime: defaultRuntime, + }), + ).resolves.toEqual([{ kind: "user", id: "molty", name: "@molty's agent", handle: "@molty" }]); + }); +}); diff --git a/extensions/reef/src/channel.ts b/extensions/reef/src/channel.ts index 07ddb1c7434a..580796dfb4bb 100644 --- a/extensions/reef/src/channel.ts +++ b/extensions/reef/src/channel.ts @@ -9,7 +9,7 @@ import { buildChannelOutboundSessionRoute, type ChannelPlugin, } from "openclaw/plugin-sdk/core"; -import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; +import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { ReefChannelConfigSchema, autonomyBudget, @@ -64,6 +64,24 @@ function listTrustedPeers(config: ReefAccount["config"]): string[] { : []; } +function listTrustedPeerDirectoryEntries(params: { + config: ReefAccount["config"]; + query: string | null | undefined; + limit: number | null | undefined; +}) { + const query = normalizeReefTarget(params.query ?? "") ?? params.query?.trim().toLowerCase(); + const peers = listTrustedPeers(params.config).filter( + (peer) => !query || peer === query || peer.includes(query), + ); + const limit = params.limit == null ? peers.length : Math.max(0, params.limit); + return peers.slice(0, limit).map((peer) => ({ + kind: "user" as const, + id: peer, + name: `@${peer}'s agent`, + handle: `@${peer}`, + })); +} + function replyText(payload: unknown): string { if (!payload || typeof payload !== "object" || !("text" in payload)) { return ""; @@ -147,7 +165,15 @@ export const reefPlugin: ChannelPlugin = { : null; }, }, - directory: createEmptyChannelDirectoryAdapter(), + directory: createChannelDirectoryAdapter({ + listPeers: async ({ cfg, query, limit }) => + listTrustedPeerDirectoryEntries({ + config: resolveReefConfig(cfg as ReefCoreConfig), + query, + limit, + }), + listGroups: async () => [], + }), message: reefMessageAdapter, outbound: reefOutboundAdapter, pairing: { diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 39e82ef52da6..537737646550 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -58,6 +58,9 @@ import { AgentsListParamsSchema, AgentsListResultSchema, AgentWaitParamsSchema, + ConversationListItemSchema, + ConversationListParamsSchema, + ConversationListResultSchema, ConversationSendParamsSchema, ConversationSendResultSchema, ConversationTurnCancelParamsSchema, @@ -573,6 +576,8 @@ export const validateResponseFrame = lazyCompile(ResponseFrameSchema); export const validateEventFrame = lazyCompile(EventFrameSchema); export const validateMessageActionParams = lazyCompile(MessageActionParamsSchema); export const validateSendParams = lazyCompile(SendParamsSchema); +export const validateConversationListParams = lazyCompile(ConversationListParamsSchema); +export const validateConversationListResult = lazyCompile(ConversationListResultSchema); export const validateConversationSendParams = lazyCompile(ConversationSendParamsSchema); export const validateConversationSendResult = lazyCompile(ConversationSendResultSchema); export const validateConversationTurnCancelParams = lazyCompile(ConversationTurnCancelParamsSchema); @@ -944,6 +949,9 @@ export { SystemInfoResultSchema, StateVersionSchema, AgentEventSchema, + ConversationListItemSchema, + ConversationListParamsSchema, + ConversationListResultSchema, ConversationSendParamsSchema, ConversationSendResultSchema, ConversationTurnCancelParamsSchema, @@ -1368,6 +1376,9 @@ export type { ErrorShape, StateVersion, AgentEvent, + ConversationListItem, + ConversationListParams, + ConversationListResult, ConversationSendParams, ConversationSendResult, ConversationTurnCancelParams, diff --git a/packages/gateway-protocol/src/schema/agent.test.ts b/packages/gateway-protocol/src/schema/agent.test.ts index 1c2e0e0c9175..dd4caf32b1c1 100644 --- a/packages/gateway-protocol/src/schema/agent.test.ts +++ b/packages/gateway-protocol/src/schema/agent.test.ts @@ -3,6 +3,8 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { AgentParamsSchema, + ConversationListParamsSchema, + ConversationListResultSchema, ConversationSendParamsSchema, ConversationSendResultSchema, ConversationTurnCancelParamsSchema, @@ -150,6 +152,33 @@ describe("MessageActionParamsSchema", () => { }); describe("Conversation schemas", () => { + it("accepts Gateway-owned address discovery without session internals", () => { + expect( + Value.Check(ConversationListParamsSchema, { + agentId: "main", + channel: "reef", + query: "@molty", + limit: 50, + }), + ).toBe(true); + expect( + Value.Check(ConversationListResultSchema, { + conversations: [ + { + conversationRef: "conv_0123456789abcdef0123456789abcdef", + channel: "reef", + accountId: "default", + kind: "direct", + target: "reef:molty", + label: "@molty's agent", + firstSeenAt: 100, + lastSeenAt: 100, + }, + ], + }), + ).toBe(true); + }); + it("accepts a Gateway-owned durable send and result", () => { expect( Value.Check(ConversationSendParamsSchema, { diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index 4163d7d469d7..c70f6ce571ac 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -145,6 +145,30 @@ export const SendParamsSchema = closedObject({ idempotencyKey: NonEmptyString, }); +/** Gateway-owned request that lists persisted and channel-directory addresses. */ +export const ConversationListParamsSchema = closedObject({ + agentId: NonEmptyString, + channel: Type.Optional(NonEmptyString), + query: Type.Optional(NonEmptyString), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), +}); + +export const ConversationListItemSchema = closedObject({ + conversationRef: Type.String({ pattern: CONVERSATION_REF_PATTERN }), + channel: NonEmptyString, + accountId: NonEmptyString, + kind: Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]), + target: NonEmptyString, + threadId: Type.Optional(NonEmptyString), + label: Type.Optional(NonEmptyString), + firstSeenAt: Type.Integer({ minimum: 0 }), + lastSeenAt: Type.Integer({ minimum: 0 }), +}); + +export const ConversationListResultSchema = closedObject({ + conversations: Type.Array(ConversationListItemSchema), +}); + /** Gateway-owned request that sends to one durable external conversation. */ export const ConversationSendParamsSchema = closedObject({ agentId: NonEmptyString, @@ -356,6 +380,9 @@ export const WakeParamsSchema = Type.Object( export type AgentEvent = Static; export type AgentIdentityParams = Static; export type AgentIdentityResult = Static; +export type ConversationListParams = Static; +export type ConversationListItem = Static; +export type ConversationListResult = Static; export type ConversationSendParams = Static; export type ConversationSendResult = Static; export type ConversationTurnParams = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 4d247bb4c830..817dd20c7ba8 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -9,6 +9,9 @@ import { AgentIdentityResultSchema, AgentParamsSchema, AgentWaitParamsSchema, + ConversationListItemSchema, + ConversationListParamsSchema, + ConversationListResultSchema, ConversationSendParamsSchema, ConversationSendResultSchema, ConversationTurnCancelParamsSchema, @@ -558,6 +561,9 @@ export const ProtocolSchemas = { AgentEvent: AgentEventSchema, ConversationSendParams: ConversationSendParamsSchema, ConversationSendResult: ConversationSendResultSchema, + ConversationListItem: ConversationListItemSchema, + ConversationListParams: ConversationListParamsSchema, + ConversationListResult: ConversationListResultSchema, ConversationTurnCancelParams: ConversationTurnCancelParamsSchema, ConversationTurnCancelResult: ConversationTurnCancelResultSchema, ConversationTurnParams: ConversationTurnParamsSchema, diff --git a/src/agents/tools/conversation-tools.test.ts b/src/agents/tools/conversation-tools.test.ts index 27d44766f0c2..ab87747aabc0 100644 --- a/src/agents/tools/conversation-tools.test.ts +++ b/src/agents/tools/conversation-tools.test.ts @@ -33,33 +33,46 @@ type MockGatewayCall = { function createDeps() { const callGatewayMock = vi.fn(async (input: MockGatewayCall) => - input.method === "conversations.send" + input.method === "conversations.list" ? { - status: "sent" as const, - conversationRef: conversation.conversationRef, - channel: "reef", - messageId: "reef-outbound-1", - queueId: "queue-1", + conversations: [ + { + conversationRef: conversation.conversationRef, + channel: conversation.channel, + accountId: conversation.accountId, + kind: conversation.kind, + target: conversation.target, + firstSeenAt: conversation.firstSeenAt, + lastSeenAt: conversation.lastSeenAt, + }, + ], } - : { - status: "replied" as const, - conversationRef: conversation.conversationRef, - channel: "reef", - messageId: "reef-outbound-1", - correlationPersisted: true, - reply: { + : input.method === "conversations.send" + ? { + status: "sent" as const, conversationRef: conversation.conversationRef, - messageId: "reef-inbound-1", - replyToId: "reef-outbound-1", - text: "peer acknowledged", - timestamp: 300, + channel: "reef", + messageId: "reef-outbound-1", + queueId: "queue-1", + } + : { + status: "replied" as const, + conversationRef: conversation.conversationRef, + channel: "reef", + messageId: "reef-outbound-1", + correlationPersisted: true, + reply: { + conversationRef: conversation.conversationRef, + messageId: "reef-inbound-1", + replyToId: "reef-outbound-1", + text: "peer acknowledged", + timestamp: 300, + }, }, - }, ); return { callGateway: callGatewayMock as never, callGatewayMock, - listConversations: vi.fn(() => [conversation]), }; } @@ -68,12 +81,13 @@ describe("conversation tools", () => { const deps = createDeps(); const result = await createConversationsListTool({ agentId: "main" }, deps).execute("list", { channel: "reef", + query: "@peer-agent", }); - expect(deps.listConversations).toHaveBeenCalledWith( - { agentId: "main" }, - { channel: "reef", limit: 50 }, - ); + expect(deps.callGatewayMock).toHaveBeenCalledWith({ + method: "conversations.list", + params: { agentId: "main", channel: "reef", query: "@peer-agent", limit: 50 }, + }); expect(result.details).toEqual({ conversations: [ { diff --git a/src/agents/tools/conversation-tools.ts b/src/agents/tools/conversation-tools.ts index 410e5ac4f6d3..b09cab685901 100644 --- a/src/agents/tools/conversation-tools.ts +++ b/src/agents/tools/conversation-tools.ts @@ -2,15 +2,10 @@ import crypto from "node:crypto"; import { Type } from "typebox"; import type { + ConversationListResult, ConversationSendResult, ConversationTurnResult, } from "../../../packages/gateway-protocol/src/schema/agent.js"; -import { - listConversations, - type ConversationRecord, - type ConversationRegistryScope, -} from "../../config/sessions/conversation-registry.js"; -import { resolveStorePath } from "../../config/sessions/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; @@ -29,6 +24,7 @@ const CONVERSATION_REF_PATTERN = /^conv_[a-f0-9]{32}$/u; const ConversationsListSchema = Type.Object( { channel: Type.Optional(Type.String({ minLength: 1 })), + query: Type.Optional(Type.String({ minLength: 1 })), limit: optionalPositiveIntegerSchema(), }, { additionalProperties: false }, @@ -61,27 +57,16 @@ type ConversationToolOptions = { type ConversationToolDeps = { callGateway: typeof callGateway; - listConversations: typeof listConversations; }; const defaultDeps: ConversationToolDeps = { callGateway, - listConversations, }; function resolveToolAgentId(options: ConversationToolOptions): string { return options.agentId ?? resolveAgentIdFromSessionKey(options.agentSessionKey); } -function resolveConversationScope(options: ConversationToolOptions): ConversationRegistryScope { - const agentId = resolveToolAgentId(options); - const configuredStore = options.config?.session?.store; - return { - agentId, - ...(configuredStore ? { storePath: resolveStorePath(configuredStore, { agentId }) } : {}), - }; -} - function requireOwner(options: ConversationToolOptions): void { if (options.senderIsOwner === false) { throw new ToolAuthorizationError("Conversation tools require owner access"); @@ -96,20 +81,6 @@ function readConversationRef(value: string): string { return conversationRef; } -function presentConversation(conversation: ConversationRecord) { - return { - conversationRef: conversation.conversationRef, - channel: conversation.channel, - accountId: conversation.accountId, - kind: conversation.kind, - target: conversation.target, - ...(conversation.threadId ? { threadId: conversation.threadId } : {}), - ...(conversation.label ? { label: conversation.label } : {}), - firstSeenAt: conversation.firstSeenAt, - lastSeenAt: conversation.lastSeenAt, - }; -} - function buildConversationOperationId(params: { options: ConversationToolOptions; toolCallId: string; @@ -144,14 +115,18 @@ export function createConversationsListTool( const params = args as Record; const limit = Math.min(readPositiveIntegerParam(params, "limit") ?? 50, 100); const channel = readStringParam(params, "channel"); - return jsonResult({ - conversations: deps - .listConversations(resolveConversationScope(options), { - limit, - ...(channel ? { channel } : {}), - }) - .map(presentConversation), + const query = readStringParam(params, "query"); + const result = await deps.callGateway({ + method: "conversations.list", + params: { + agentId: resolveToolAgentId(options), + limit, + ...(channel ? { channel } : {}), + ...(query ? { query } : {}), + }, + ...(options.config ? { config: options.config } : {}), }); + return jsonResult(result); }, }; } diff --git a/src/config/sessions/conversation-delivery-store.test.ts b/src/config/sessions/conversation-delivery-store.test.ts index 7ca5739937ea..0d19ceecd01d 100644 --- a/src/config/sessions/conversation-delivery-store.test.ts +++ b/src/config/sessions/conversation-delivery-store.test.ts @@ -215,7 +215,11 @@ describe("conversation delivery store", () => { }, }); - expect(resolveConversation(scope, conversationRef)).toBeUndefined(); + expect(resolveConversation(scope, conversationRef)).toMatchObject({ + conversationRef, + channel: "reef", + }); + expect(resolveConversation(scope, conversationRef)?.sessionId).toBeUndefined(); expect(getConversationDeliveryOperation(scope, "operation-pruned-session")).toMatchObject({ channel: "reef", conversationRef, diff --git a/src/config/sessions/conversation-identity.ts b/src/config/sessions/conversation-identity.ts index f8005a45b5a7..b29e95e6c86c 100644 --- a/src/config/sessions/conversation-identity.ts +++ b/src/config/sessions/conversation-identity.ts @@ -56,7 +56,8 @@ function normalizeKind(value: unknown): ConversationKind { return "direct"; } -function finalizeConversationIdentity(params: { +/** Builds one stable transport address from authoritative channel route facts. */ +export function buildConversationIdentity(params: { channel?: string; accountId?: string; kind: ConversationKind; @@ -142,7 +143,7 @@ export function conversationIdentityFromSessionEntry( const channel = routeOwnsTarget ? deliveryContext?.channel : (normalizeText(entry.origin?.provider) ?? normalizeText(entry.channel)); - return finalizeConversationIdentity({ + return buildConversationIdentity({ channel, accountId: routeOwnsTarget ? deliveryContext?.accountId : entry.origin?.accountId, kind, @@ -192,7 +193,7 @@ export function conversationIdentityFromMsgContext(params: { normalizeText(route?.provider) ?? normalizeText(params.ctx.OriginatingChannel) ?? normalizeText(params.ctx.Provider)); - return finalizeConversationIdentity({ + return buildConversationIdentity({ channel, accountId: useDirectIngressTarget ? (route?.accountId ?? params.ctx.AccountId) diff --git a/src/config/sessions/conversation-registry.test.ts b/src/config/sessions/conversation-registry.test.ts index 898d2bb224ec..0c6843c9bdf3 100644 --- a/src/config/sessions/conversation-registry.test.ts +++ b/src/config/sessions/conversation-registry.test.ts @@ -1,9 +1,23 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; -import { listConversations, resolveConversation } from "./conversation-registry.js"; -import { upsertSessionEntry } from "./session-accessor.js"; +import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../../state/openclaw-agent-db.js"; +import { buildConversationIdentity } from "./conversation-identity.js"; +import { + listConversations, + registerConversationAddresses, + resolveConversation, +} from "./conversation-registry.js"; +import { deleteSessionEntryLifecycle, upsertSessionEntry } from "./session-accessor.js"; +import { + getSessionKysely, + resolveSqliteReadScope, + toDatabaseOptions, +} from "./session-accessor.sqlite-scope.js"; describe("conversation registry", () => { let tempDir: string; @@ -52,6 +66,116 @@ describe("conversation registry", () => { ); }); + it("catalogs a directory address without inventing a model-context session", () => { + const identity = buildConversationIdentity({ + channel: "reef", + accountId: "default", + kind: "direct", + peerId: "reef:peer-a", + deliveryTarget: "reef:peer-a", + nativeDirectUserId: "peer-a", + label: "@peer-a's agent", + }); + expect(identity).toBeDefined(); + registerConversationAddresses({ agentId: "main", storePath }, [identity!], 100); + + const [conversation] = listConversations({ agentId: "main", storePath }, { channel: "reef" }); + expect(conversation).toMatchObject({ + conversationRef: identity?.conversationRef, + target: "reef:peer-a", + label: "@peer-a's agent", + firstSeenAt: 100, + lastSeenAt: 100, + }); + expect(conversation?.sessionId).toBeUndefined(); + expect(conversation?.sessionKey).toBeUndefined(); + expect(conversation?.role).toBeUndefined(); + expect(resolveConversation({ agentId: "main", storePath }, identity!.conversationRef)).toEqual( + conversation, + ); + }); + + it("orders fresh directory addresses with session-backed conversation activity", async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey: "agent:main:reef:direct:peer-a", storePath }, + { + sessionId: "peer-a-session", + updatedAt: 100, + chatType: "direct", + deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-a" }, + }, + ); + const freshIdentity = buildConversationIdentity({ + channel: "reef", + accountId: "default", + kind: "direct", + peerId: "reef:peer-b", + deliveryTarget: "reef:peer-b", + }); + expect(freshIdentity).toBeDefined(); + const freshAt = Date.now() + 1_000; + registerConversationAddresses({ agentId: "main", storePath }, [freshIdentity!], freshAt); + + expect( + listConversations({ agentId: "main", storePath }, { channel: "reef", limit: 1 }), + ).toEqual([ + expect.objectContaining({ + conversationRef: freshIdentity?.conversationRef, + target: "reef:peer-b", + lastSeenAt: freshAt, + }), + ]); + }); + + it("keeps a live binding when newer historical activity has no current entry", async () => { + const liveSessionKey = "agent:main:reef:direct:peer-a-live"; + const staleSessionKey = "agent:main:reef:direct:peer-a-stale"; + for (const [sessionKey, sessionId] of [ + [liveSessionKey, "live-session"], + [staleSessionKey, "stale-session"], + ] as const) { + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId, + updatedAt: 100, + chatType: "direct", + deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-a" }, + }, + ); + } + const resolved = resolveSqliteReadScope({ agentId: "main", storePath }); + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + const db = getSessionKysely(database.db); + executeSqliteQuerySync( + database.db, + db + .updateTable("session_conversations") + .set({ last_seen_at: 100 }) + .where("session_id", "=", "live-session"), + ); + executeSqliteQuerySync( + database.db, + db + .updateTable("session_conversations") + .set({ last_seen_at: 200 }) + .where("session_id", "=", "stale-session"), + ); + executeSqliteQuerySync( + database.db, + db.deleteFrom("session_entries").where("session_key", "=", staleSessionKey), + ); + + expect( + listConversations({ agentId: "main", storePath }, { channel: "reef", limit: 1 })[0], + ).toMatchObject({ + target: "reef:peer-a", + sessionId: "live-session", + sessionKey: liveSessionKey, + lastSeenAt: 200, + }); + }); + it("resolves historical addresses through the current session binding after reset", async () => { const sessionKey = "agent:main:reef:direct:peer-a"; const scope = { agentId: "main", sessionKey, storePath }; @@ -80,4 +204,33 @@ describe("conversation registry", () => { target: "reef:peer-a", }); }); + + it("retains a deleted session's address without exposing a stale binding", async () => { + const sessionKey = "agent:main:reef:direct:peer-a"; + const scope = { agentId: "main", sessionKey, storePath }; + await upsertSessionEntry(scope, { + sessionId: "deleted-session", + updatedAt: 100, + chatType: "direct", + deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-a" }, + }); + const [linked] = listConversations({ agentId: "main", storePath }, { channel: "reef" }); + expect(linked?.sessionId).toBe("deleted-session"); + + await deleteSessionEntryLifecycle({ + storePath, + target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, + archiveTranscript: false, + }); + + expect( + resolveConversation({ agentId: "main", storePath }, linked?.conversationRef ?? "missing"), + ).toMatchObject({ + conversationRef: linked?.conversationRef, + target: "reef:peer-a", + }); + expect( + resolveConversation({ agentId: "main", storePath }, linked?.conversationRef ?? "missing"), + ).not.toMatchObject({ sessionId: expect.any(String), sessionKey: expect.any(String) }); + }); }); diff --git a/src/config/sessions/conversation-registry.ts b/src/config/sessions/conversation-registry.ts index b419e636ebfc..c00b7e54e581 100644 --- a/src/config/sessions/conversation-registry.ts +++ b/src/config/sessions/conversation-registry.ts @@ -1,7 +1,8 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; -import type { ConversationKind } from "./conversation-identity.js"; +import type { ConversationIdentity, ConversationKind } from "./conversation-identity.js"; +import { upsertConversationIdentity } from "./session-accessor.sqlite-conversation.js"; import { getSessionKysely, resolveSqliteReadScope, @@ -21,9 +22,9 @@ export type ConversationRecord = { nativeChannelId?: string; nativeDirectUserId?: string; label?: string; - sessionId: string; - sessionKey: string; - role: "participant" | "primary" | "related"; + sessionId?: string; + sessionKey?: string; + role?: "participant" | "primary" | "related"; firstSeenAt: number; lastSeenAt: number; }; @@ -46,26 +47,29 @@ function mapConversationRow(row: { account_id: string; channel: string; conversation_id: string; - first_seen_at: number; + conversation_created_at: number; + conversation_updated_at: number; + first_seen_at: number | null; kind: string; label: string | null; - last_seen_at: number; + last_seen_at: number | null; delivery_target: string; native_channel_id: string | null; native_direct_user_id: string | null; parent_conversation_id: string | null; peer_id: string; - role: string; - session_id: string; - session_key: string; + role: string | null; + current_session_id: string | null; + current_session_key: string | null; thread_id: string | null; }): ConversationRecord | null { if (row.kind !== "direct" && row.kind !== "group" && row.kind !== "channel") { return null; } - if (row.role !== "primary" && row.role !== "participant" && row.role !== "related") { - return null; - } + const role = + row.role === "primary" || row.role === "participant" || row.role === "related" + ? row.role + : undefined; return { conversationRef: row.conversation_id, channel: row.channel, @@ -77,11 +81,17 @@ function mapConversationRow(row: { ...(row.native_channel_id ? { nativeChannelId: row.native_channel_id } : {}), ...(row.native_direct_user_id ? { nativeDirectUserId: row.native_direct_user_id } : {}), ...(row.label ? { label: row.label } : {}), - sessionId: row.session_id, - sessionKey: row.session_key, - role: row.role, - firstSeenAt: row.first_seen_at, - lastSeenAt: row.last_seen_at, + // Only the current session_entries row can bind an address. The joined + // sessions row may be historical after reset, rebind, or deletion. + ...(role && row.current_session_id && row.current_session_key + ? { + sessionId: row.current_session_id, + sessionKey: row.current_session_key, + role, + } + : {}), + firstSeenAt: row.first_seen_at ?? row.conversation_created_at, + lastSeenAt: row.last_seen_at ?? row.conversation_updated_at, }; } @@ -98,11 +108,11 @@ function selectConversationRows( const db = getSessionKysely(database.db); let query = db .selectFrom("conversations as c") - .innerJoin("session_conversations as sc", "sc.conversation_id", "c.conversation_id") - .innerJoin("sessions as s", "s.session_id", "sc.session_id") + .leftJoin("session_conversations as sc", "sc.conversation_id", "c.conversation_id") + .leftJoin("sessions as s", "s.session_id", "sc.session_id") // Historical sessions retain address activity, while session_entries owns // the current session binding after reset/rebind. - .innerJoin("session_entries as se", "se.session_key", "s.session_key") + .leftJoin("session_entries as se", "se.session_key", "s.session_key") .select([ "c.conversation_id", "c.channel", @@ -115,11 +125,13 @@ function selectConversationRows( "c.native_channel_id", "c.native_direct_user_id", "c.label", + "c.created_at as conversation_created_at", + "c.updated_at as conversation_updated_at", "sc.role", "sc.first_seen_at", "sc.last_seen_at", - "se.session_id", - "se.session_key", + "se.session_id as current_session_id", + "se.session_key as current_session_key", ]); const channel = normalizeOptionalLowercaseString(options.channel); if (channel) { @@ -134,19 +146,56 @@ function selectConversationRows( } const rows = executeSqliteQuerySync( database.db, - query.orderBy("sc.last_seen_at", "desc").orderBy("se.updated_at", "desc"), + query + .orderBy((eb) => eb.fn.coalesce("sc.last_seen_at", "c.updated_at"), "desc") + .orderBy("se.updated_at", "desc"), ).rows; const unique = new Map(); for (const row of rows) { const mapped = mapConversationRow(row); - if (mapped && !unique.has(mapped.conversationRef)) { + if (!mapped) { + continue; + } + const existing = unique.get(mapped.conversationRef); + if (!existing) { unique.set(mapped.conversationRef, mapped); + continue; + } + if (!existing.sessionId && mapped.sessionId && mapped.sessionKey && mapped.role) { + // Keep the newest address activity while carrying forward the live binding + // when a newer historical association has no current session entry. + unique.set(mapped.conversationRef, { + ...existing, + sessionId: mapped.sessionId, + sessionKey: mapped.sessionKey, + role: mapped.role, + }); } } const values = [...unique.values()]; return options.limit === undefined ? values : values.slice(0, options.limit); } +/** Catalogs routable addresses without creating model-context sessions. */ +export function registerConversationAddresses( + scope: ConversationRegistryScope, + identities: readonly ConversationIdentity[], + discoveredAt = Date.now(), +): void { + if (identities.length === 0) { + return; + } + const resolved = resolveSqliteReadScope({ + agentId: scope.agentId, + ...(scope.env ? { env: scope.env } : {}), + ...(scope.storePath ? { storePath: scope.storePath } : {}), + }); + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + for (const identity of identities) { + upsertConversationIdentity(database, identity, discoveredAt); + } +} + /** Lists stable external addresses for one agent, newest activity first. */ export function listConversations( scope: ConversationRegistryScope, @@ -155,7 +204,7 @@ export function listConversations( return selectConversationRows(scope, options); } -/** Resolves an opaque address to one exact channel target and backing context session. */ +/** Resolves an opaque address to one exact channel target and its context binding, when present. */ export function resolveConversation( scope: ConversationRegistryScope, conversationRef: string, diff --git a/src/gateway/conversation-list.test.ts b/src/gateway/conversation-list.test.ts new file mode 100644 index 000000000000..dab5963af306 --- /dev/null +++ b/src/gateway/conversation-list.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ConversationIdentity } from "../config/sessions/conversation-identity.js"; +import { runGatewayConversationList } from "./conversation-list.js"; + +describe("runGatewayConversationList", () => { + it("discovers a trusted directory peer without creating a session", async () => { + let discovered: ConversationIdentity[] = []; + const listPeers = vi.fn(async () => [ + { kind: "user" as const, id: "peer-id-123", name: "Friendly Lobster", handle: "@molty" }, + ]); + const resolveOutboundSessionRoute = vi.fn(async () => ({ + sessionKey: "agent:main:reef:direct:peer-id-123", + baseSessionKey: "agent:main:reef:direct:peer-id-123", + peer: { kind: "direct" as const, id: "peer-id-123" }, + chatType: "direct" as const, + from: "reef:peer-id-123", + to: "reef:peer-id-123", + })); + const deps = { + resolveOutboundChannelPlugin: vi.fn(() => ({ + id: "reef", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true, configured: true }), + isEnabled: () => true, + isConfigured: () => true, + }, + directory: { listPeers, listGroups: async () => [] }, + })), + resolveOutboundSessionRoute, + registerConversationAddresses: vi.fn((_scope, identities) => { + discovered = [...identities]; + }), + listConversations: vi.fn(() => + discovered.map((identity) => ({ + conversationRef: identity.conversationRef, + channel: identity.channel, + accountId: identity.accountId, + kind: identity.kind, + target: identity.deliveryTarget, + label: identity.label, + firstSeenAt: 100, + lastSeenAt: 100, + })), + ), + }; + + const result = await runGatewayConversationList( + { config: {}, agentId: "main", channel: "reef", query: "@molty", limit: 50 }, + deps as never, + ); + + expect(listPeers).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "default", query: "@molty", limit: 50 }), + ); + expect(deps.listConversations).toHaveBeenCalledWith({ agentId: "main" }, { channel: "reef" }); + expect(resolveOutboundSessionRoute).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "reef", + agentId: "main", + accountId: "default", + target: "peer-id-123", + resolvedTarget: { + to: "peer-id-123", + kind: "user", + display: "Friendly Lobster", + source: "directory", + resolutionSource: "directory", + }, + }), + ); + expect(result.conversations).toEqual([ + expect.objectContaining({ + conversationRef: expect.stringMatching(/^conv_[a-f0-9]{32}$/u), + channel: "reef", + accountId: "default", + kind: "direct", + target: "reef:peer-id-123", + label: "Friendly Lobster", + }), + ]); + expect(result.conversations[0]).not.toHaveProperty("sessionId"); + }); + + it("keeps route identity separate from its delivery address", async () => { + let discovered: ConversationIdentity[] = []; + const deps = { + resolveOutboundChannelPlugin: vi.fn(() => ({ + id: "discord", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true, configured: true }), + isEnabled: () => true, + isConfigured: () => true, + }, + directory: { + listPeers: async () => [ + { kind: "user" as const, id: "delivery-alias-456", name: "Canonical Peer" }, + ], + listGroups: async () => [], + }, + })), + resolveOutboundSessionRoute: vi.fn(async () => ({ + sessionKey: "agent:main:discord:direct:canonical-peer-123", + baseSessionKey: "agent:main:discord:direct:canonical-peer-123", + peer: { kind: "direct" as const, id: "canonical-peer-123" }, + chatType: "direct" as const, + from: "discord:canonical-peer-123", + to: "user:delivery-alias-456", + })), + registerConversationAddresses: vi.fn((_scope, identities) => { + discovered = [...identities]; + }), + listConversations: vi.fn(() => []), + }; + + await runGatewayConversationList( + { config: {}, agentId: "main", channel: "discord", limit: 50 }, + deps as never, + ); + + expect(discovered).toEqual([ + expect.objectContaining({ + peerId: "canonical-peer-123", + deliveryTarget: "user:delivery-alias-456", + nativeDirectUserId: "canonical-peer-123", + }), + ]); + }); + + it("merges live directory adapters with config-backed entries", async () => { + const listPeers = vi.fn(async () => [ + { kind: "user" as const, id: "stale-peer", name: "Stale Peer" }, + { kind: "user" as const, id: "shared-peer", name: "Configured Shared Peer" }, + ]); + const listPeersLive = vi.fn(async () => [ + { kind: "user" as const, id: "live-peer", name: "Live Peer" }, + { kind: "user" as const, id: "shared-peer", name: "Live Shared Peer" }, + ]); + const listGroups = vi.fn(async () => [ + { kind: "group" as const, id: "stale-group", name: "Stale Group" }, + ]); + const listGroupsLive = vi.fn(async () => [ + { kind: "group" as const, id: "live-group", name: "Live Group" }, + ]); + const resolvedTargets: string[] = []; + const deps = { + resolveOutboundChannelPlugin: vi.fn(() => ({ + id: "discord", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true, configured: true }), + isEnabled: () => true, + isConfigured: () => true, + }, + directory: { listPeers, listPeersLive, listGroups, listGroupsLive }, + })), + resolveOutboundSessionRoute: vi.fn(async ({ target }: { target: string }) => { + resolvedTargets.push(target); + const direct = target.endsWith("-peer"); + return { + sessionKey: `agent:main:discord:${direct ? "direct" : "channel"}:${target}`, + baseSessionKey: `agent:main:discord:${direct ? "direct" : "channel"}:${target}`, + peer: { kind: direct ? ("direct" as const) : ("channel" as const), id: target }, + chatType: direct ? ("direct" as const) : ("channel" as const), + from: `discord:${target}`, + to: target, + }; + }), + registerConversationAddresses: vi.fn(), + listConversations: vi.fn(() => []), + }; + + await runGatewayConversationList( + { config: {}, agentId: "main", channel: "discord", limit: 50 }, + deps as never, + ); + + expect(listPeersLive).toHaveBeenCalledOnce(); + expect(listGroupsLive).toHaveBeenCalledOnce(); + expect(listPeers).toHaveBeenCalledOnce(); + expect(listGroups).toHaveBeenCalledOnce(); + expect(resolvedTargets).toEqual([ + "stale-peer", + "shared-peer", + "live-peer", + "stale-group", + "live-group", + ]); + expect(deps.resolveOutboundSessionRoute).toHaveBeenCalledWith( + expect.objectContaining({ + target: "shared-peer", + resolvedTarget: expect.objectContaining({ display: "Live Shared Peer" }), + }), + ); + }); + + it("retains configured peers when live discovery is empty or fails", async () => { + const listPeers = vi.fn(async () => [ + { kind: "user" as const, id: "configured-peer", name: "Configured Peer" }, + ]); + const listPeersLive = vi.fn(async ({ query }: { query?: string }) => + query ? [{ kind: "user" as const, id: "live-peer", name: "Live Peer" }] : [], + ); + listPeersLive.mockRejectedValueOnce(new Error("directory unavailable")); + const resolvedTargets: string[] = []; + const deps = { + resolveOutboundChannelPlugin: vi.fn(() => ({ + id: "discord", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true, configured: true }), + isEnabled: () => true, + isConfigured: () => true, + }, + directory: { listPeers, listPeersLive }, + })), + resolveOutboundSessionRoute: vi.fn(async ({ target }: { target: string }) => { + resolvedTargets.push(target); + return { + sessionKey: `agent:main:discord:direct:${target}`, + baseSessionKey: `agent:main:discord:direct:${target}`, + peer: { kind: "direct" as const, id: target }, + chatType: "direct" as const, + from: `discord:${target}`, + to: target, + }; + }), + registerConversationAddresses: vi.fn(), + listConversations: vi.fn(() => []), + }; + + await runGatewayConversationList( + { config: {}, agentId: "main", channel: "discord", limit: 50 }, + deps as never, + ); + await runGatewayConversationList( + { config: {}, agentId: "main", channel: "discord", limit: 50 }, + deps as never, + ); + + expect(listPeers).toHaveBeenCalledTimes(2); + expect(listPeersLive).toHaveBeenCalledTimes(2); + expect(listPeersLive.mock.calls.map(([input]) => input)).toEqual([ + expect.not.objectContaining({ query: expect.anything() }), + expect.not.objectContaining({ query: expect.anything() }), + ]); + expect(resolvedTargets).toEqual(["configured-peer", "configured-peer"]); + }); +}); diff --git a/src/gateway/conversation-list.ts b/src/gateway/conversation-list.ts new file mode 100644 index 000000000000..db53e0e74d41 --- /dev/null +++ b/src/gateway/conversation-list.ts @@ -0,0 +1,263 @@ +import type { + ConversationListItem, + ConversationListResult, +} from "../../packages/gateway-protocol/src/schema/agent.js"; +import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js"; +import { + buildConversationIdentity, + type ConversationIdentity, +} from "../config/sessions/conversation-identity.js"; +import { + listConversations, + registerConversationAddresses, + type ConversationRecord, + type ConversationRegistryScope, +} from "../config/sessions/conversation-registry.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { resolveOutboundChannelPlugin } from "../infra/outbound/channel-resolution.js"; +import { resolveOutboundSessionRoute } from "../infra/outbound/outbound-session.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { defaultRuntime } from "../runtime.js"; + +const log = createSubsystemLogger("gateway/conversations"); + +type ConversationListDeps = { + listConversations: typeof listConversations; + registerConversationAddresses: typeof registerConversationAddresses; + resolveOutboundChannelPlugin: typeof resolveOutboundChannelPlugin; + resolveOutboundSessionRoute: typeof resolveOutboundSessionRoute; +}; + +const defaultDeps: ConversationListDeps = { + listConversations, + registerConversationAddresses, + resolveOutboundChannelPlugin, + resolveOutboundSessionRoute, +}; + +function resolveConversationScope(params: { + agentId: string; + config: OpenClawConfig; +}): ConversationRegistryScope { + const configuredStore = params.config.session?.store; + return { + agentId: params.agentId, + ...(configuredStore + ? { storePath: resolveStorePath(configuredStore, { agentId: params.agentId }) } + : {}), + }; +} + +function presentConversation(conversation: ConversationRecord): ConversationListItem { + return { + conversationRef: conversation.conversationRef, + channel: conversation.channel, + accountId: conversation.accountId, + kind: conversation.kind, + target: conversation.target, + ...(conversation.threadId ? { threadId: conversation.threadId } : {}), + ...(conversation.label ? { label: conversation.label } : {}), + firstSeenAt: conversation.firstSeenAt, + lastSeenAt: conversation.lastSeenAt, + }; +} + +async function listLiveDirectoryEntries(params: { + channel: string; + accountId: string; + kind: "peers" | "groups"; + run: () => Promise; +}): Promise { + try { + return await params.run(); + } catch (error) { + log.warn("live directory discovery failed; using configured entries", { + channel: params.channel, + accountId: params.accountId, + kind: params.kind, + error: formatErrorMessage(error), + }); + return []; + } +} + +async function listDirectoryEntries(params: { + config: OpenClawConfig; + accountId: string; + query?: string; + limit: number; + plugin: NonNullable>; +}): Promise { + const input = { + cfg: params.config, + accountId: params.accountId, + ...(params.query ? { query: params.query } : {}), + limit: params.limit, + runtime: defaultRuntime, + }; + const directory = params.plugin.directory; + const listPeersLive = directory?.listPeersLive; + const listGroupsLive = directory?.listGroupsLive; + const [configuredPeers, livePeers, configuredGroups, liveGroups] = await Promise.all([ + directory?.listPeers?.(input) ?? [], + listPeersLive + ? listLiveDirectoryEntries({ + channel: params.plugin.id, + accountId: params.accountId, + kind: "peers", + run: () => listPeersLive(input), + }) + : [], + directory?.listGroups?.(input) ?? [], + listGroupsLive + ? listLiveDirectoryEntries({ + channel: params.plugin.id, + accountId: params.accountId, + kind: "groups", + run: () => listGroupsLive(input), + }) + : [], + ]); + const entries = new Map(); + for (const entry of [...configuredPeers, ...livePeers, ...configuredGroups, ...liveGroups]) { + // Live results replace config-only metadata without dropping configured addresses when a + // transport's live adapter is search-only and returns nothing for an unfiltered listing. + entries.set(`${entry.kind}\u0000${entry.id.trim()}`, entry); + } + return [...entries.values()]; +} + +async function discoverChannelAddresses(params: { + config: OpenClawConfig; + agentId: string; + channel: string; + query?: string; + limit: number; + scope: ConversationRegistryScope; + deps: ConversationListDeps; +}): Promise<{ channel: string; discoveredConversationRefs: ReadonlySet }> { + const plugin = params.deps.resolveOutboundChannelPlugin({ + channel: params.channel, + cfg: params.config, + }); + if (!plugin?.directory) { + return { + channel: params.channel.trim().toLowerCase(), + discoveredConversationRefs: new Set(), + }; + } + const identities = new Map(); + for (const accountId of new Set(plugin.config.listAccountIds(params.config).filter(Boolean))) { + const account = plugin.config.resolveAccount(params.config, accountId); + if (plugin.config.isEnabled?.(account, params.config) === false) { + continue; + } + if (plugin.config.isConfigured && !(await plugin.config.isConfigured(account, params.config))) { + continue; + } + const entries = await listDirectoryEntries({ + config: params.config, + accountId, + ...(params.query ? { query: params.query } : {}), + limit: params.limit, + plugin, + }); + for (const entry of entries) { + const target = entry.id.trim(); + if (!target) { + continue; + } + const display = entry.name?.trim() || entry.handle?.trim() || undefined; + const route = await params.deps.resolveOutboundSessionRoute({ + cfg: params.config, + channel: plugin.id, + plugin, + agentId: params.agentId, + accountId, + target, + resolvedTarget: { + to: target, + kind: entry.kind, + ...(display ? { display } : {}), + source: "directory", + resolutionSource: "directory", + }, + }); + if (!route) { + continue; + } + const identity = buildConversationIdentity({ + channel: plugin.id, + accountId, + kind: route.chatType, + // Match inbound MsgContext.From; the identity builder removes transport prefixes. + peerId: route.from, + deliveryTarget: route.to, + ...(route.threadId !== undefined ? { threadId: route.threadId } : {}), + ...(route.peer.kind === "direct" + ? { nativeDirectUserId: route.peer.id } + : { nativeChannelId: route.peer.id }), + ...(display ? { label: display } : {}), + }); + if (identity) { + identities.set(identity.conversationRef, identity); + } + } + } + params.deps.registerConversationAddresses(params.scope, [...identities.values()]); + return { channel: plugin.id, discoveredConversationRefs: new Set(identities.keys()) }; +} + +function matchesConversationQuery(conversation: ConversationRecord, rawQuery: string): boolean { + const query = rawQuery.trim().toLowerCase(); + if (!query) { + return true; + } + const terms = query.startsWith("@") ? [query, query.slice(1)] : [query]; + const values = [conversation.conversationRef, conversation.target, conversation.label] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + return terms.some((term) => term && values.some((value) => value.includes(term))); +} + +/** Lists persisted and channel-directory addresses from the Gateway's live plugin runtime. */ +export async function runGatewayConversationList( + params: { + config: OpenClawConfig; + agentId: string; + channel?: string; + query?: string; + limit: number; + }, + deps: ConversationListDeps = defaultDeps, +): Promise { + const scope = resolveConversationScope(params); + const query = params.query?.trim() || undefined; + const discovery = params.channel + ? await discoverChannelAddresses({ + config: params.config, + agentId: params.agentId, + channel: params.channel, + ...(query ? { query } : {}), + limit: params.limit, + scope, + deps, + }) + : undefined; + const conversations = deps.listConversations(scope, { + ...(query ? {} : { limit: params.limit }), + ...(discovery ? { channel: discovery.channel } : {}), + }); + const selected = query + ? conversations + .filter( + (entry) => + discovery?.discoveredConversationRefs.has(entry.conversationRef) === true || + matchesConversationQuery(entry, query), + ) + .slice(0, params.limit) + : conversations; + return { conversations: selected.map(presentConversation) }; +} diff --git a/src/gateway/conversation-turn.test.ts b/src/gateway/conversation-turn.test.ts index ebdb73d38085..a2b4024cd28e 100644 --- a/src/gateway/conversation-turn.test.ts +++ b/src/gateway/conversation-turn.test.ts @@ -3,6 +3,7 @@ import { ConversationDeliveryInputError, type ConversationDeliveryRecord, } from "../config/sessions/conversation-delivery-store.js"; +import type { ConversationRecord } from "../config/sessions/conversation-registry.js"; import { PlatformMessageNotDispatchedError } from "../infra/outbound/deliver-types.js"; import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js"; import { @@ -122,13 +123,22 @@ function createDeps() { update(operationId, { status: "unknown" }), ), registerPendingConversationTurn: vi.fn(registerPendingConversationTurn), - resolveConversation: vi.fn((): typeof conversation | undefined => conversation), + resolveConversation: vi.fn((): ConversationRecord | undefined => conversation), resolveOutboundChannelPlugin: vi.fn( () => ({ outbound: { prepareConversationTurnMessageId: () => "reef-outbound-1" }, }) as never, ), + resolveOutboundSessionRoute: vi.fn(async () => ({ + sessionKey: conversation.sessionKey, + baseSessionKey: conversation.sessionKey, + peer: { kind: "direct" as const, id: "molty" }, + chatType: "direct" as const, + from: "reef:molty", + to: conversation.target, + })), + ensureOutboundSessionEntry: vi.fn(async () => undefined), runMessageAction: vi.fn(async () => sentResult()) as never, operations, update, @@ -151,6 +161,44 @@ function persistIntent(input: Record): void { } describe("runGatewayConversationTurn", () => { + it("creates a context binding only when a discovered address starts a turn", async () => { + const deps = createDeps(); + const { + sessionId: _sessionId, + sessionKey: _sessionKey, + role: _role, + ...unbound + } = conversation; + deps.resolveConversation.mockReturnValueOnce(unbound).mockReturnValue(conversation); + deps.runMessageAction = vi.fn(async (input: Record) => { + persistIntent(input); + return sentResult(); + }) as never; + + await expect( + runGatewayConversationTurn( + { + config: {}, + agentId: "main", + senderIsOwner: true, + turnId: "turn-directory-peer", + conversationRef: conversation.conversationRef, + message: "hello molty", + timeoutMs: 1, + }, + deps, + ), + ).resolves.toMatchObject({ status: "timeout" }); + + expect(deps.resolveOutboundSessionRoute).toHaveBeenCalledWith( + expect.objectContaining({ channel: "reef", target: "reef:molty" }), + ); + expect(deps.ensureOutboundSessionEntry).toHaveBeenCalledOnce(); + expect(deps.registerPendingConversationTurn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: conversation.sessionId }), + ); + }); + it("registers correlation before durable delivery and consumes a fast reply inline", async () => { const deps = createDeps(); let capture: Promise | undefined; @@ -468,6 +516,13 @@ describe("runGatewayConversationTurn", () => { it("rejects unsupported channels before registering or sending", async () => { const deps = createDeps(); + const { + sessionId: _sessionId, + sessionKey: _sessionKey, + role: _role, + ...unbound + } = conversation; + deps.resolveConversation.mockReturnValue(unbound); deps.resolveOutboundChannelPlugin.mockReturnValueOnce({ outbound: {} } as never); await expect( @@ -484,6 +539,9 @@ describe("runGatewayConversationTurn", () => { deps, ), ).rejects.toBeInstanceOf(ConversationInputError); + expect(deps.resolveOutboundSessionRoute).not.toHaveBeenCalled(); + expect(deps.ensureOutboundSessionEntry).not.toHaveBeenCalled(); + expect(deps.beginOperation).not.toHaveBeenCalled(); expect(deps.registerPendingConversationTurn).not.toHaveBeenCalled(); expect(deps.runMessageAction).not.toHaveBeenCalled(); }); diff --git a/src/gateway/conversation-turn.ts b/src/gateway/conversation-turn.ts index d9fa7b7c6cb7..21be16ece037 100644 --- a/src/gateway/conversation-turn.ts +++ b/src/gateway/conversation-turn.ts @@ -1,4 +1,5 @@ import type { ConversationTurnResult } from "../../packages/gateway-protocol/src/schema/agent.js"; +import type { ChannelId } from "../channels/plugins/types.public.js"; import { ConversationDeliveryInputError } from "../config/sessions/conversation-delivery-store.js"; import { resolveConversation, @@ -14,6 +15,10 @@ import { sendGatewayConversationMessage, type ConversationDeliveryDeps, } from "../infra/outbound/conversation-delivery.js"; +import { + ensureOutboundSessionEntry, + resolveOutboundSessionRoute, +} from "../infra/outbound/outbound-session.js"; import { registerPendingConversationTurn } from "../sessions/conversation-turns.js"; import { ConversationInputError, @@ -24,13 +29,28 @@ type ConversationTurnDeps = ConversationDeliveryDeps & { registerPendingConversationTurn: typeof registerPendingConversationTurn; resolveConversation: typeof resolveConversation; resolveOutboundChannelPlugin: typeof resolveOutboundChannelPlugin; + ensureOutboundSessionEntry: typeof ensureOutboundSessionEntry; + resolveOutboundSessionRoute: typeof resolveOutboundSessionRoute; }; +type BoundConversationRecord = ConversationRecord & { + sessionId: string; + sessionKey: string; +}; + +function hasConversationSessionBinding( + conversation: ConversationRecord, +): conversation is BoundConversationRecord { + return Boolean(conversation.sessionId && conversation.sessionKey); +} + const defaultDeps: ConversationTurnDeps = { ...defaultConversationDeliveryDeps, registerPendingConversationTurn, resolveConversation, resolveOutboundChannelPlugin, + ensureOutboundSessionEntry, + resolveOutboundSessionRoute, }; function resolveConversationScope(params: { @@ -121,15 +141,12 @@ function resultForCompletedOperation(params: { } function prepareConversationMessageId(params: { - deps: ConversationTurnDeps; + plugin: ReturnType; config: OpenClawConfig; conversation: ConversationRecord; message: string; }): string { - const prepare = params.deps.resolveOutboundChannelPlugin({ - channel: params.conversation.channel, - cfg: params.config, - })?.outbound?.prepareConversationTurnMessageId; + const prepare = params.plugin?.outbound?.prepareConversationTurnMessageId; if (!prepare) { throw new ConversationInputError( `Channel ${params.conversation.channel} does not support correlated conversation turns; use conversations_send`, @@ -155,6 +172,47 @@ function prepareConversationMessageId(params: { return preparedMessageId; } +async function ensureConversationContextBinding(params: { + deps: ConversationTurnDeps; + scope: ConversationRegistryScope; + config: OpenClawConfig; + agentId: string; + conversation: ConversationRecord; + plugin: ReturnType; +}): Promise { + if (hasConversationSessionBinding(params.conversation)) { + return params.conversation; + } + const channel = (params.plugin?.id ?? params.conversation.channel) as ChannelId; + const route = await params.deps.resolveOutboundSessionRoute({ + cfg: params.config, + channel, + ...(params.plugin ? { plugin: params.plugin } : {}), + agentId: params.agentId, + accountId: params.conversation.accountId, + target: params.conversation.target, + ...(params.conversation.threadId ? { threadId: params.conversation.threadId } : {}), + }); + if (!route) { + throw new ConversationInputError( + `Conversation ${params.conversation.conversationRef} no longer resolves to a channel route`, + ); + } + await params.deps.ensureOutboundSessionEntry({ + cfg: params.config, + channel, + accountId: params.conversation.accountId, + route, + }); + const bound = params.deps.resolveConversation(params.scope, params.conversation.conversationRef); + if (!bound || !hasConversationSessionBinding(bound)) { + throw new ConversationInputError( + `Conversation ${params.conversation.conversationRef} could not create its local context binding`, + ); + } + return bound; +} + /** Owns correlation, delivery, and waiting inside the Gateway process that receives ingress. */ export async function runGatewayConversationTurn( params: { @@ -194,19 +252,38 @@ export async function runGatewayConversationTurn( throw error; } - const conversation = deps.resolveConversation(scope, params.conversationRef); - if (!conversation) { + const discoveredConversation = deps.resolveConversation(scope, params.conversationRef); + if (!discoveredConversation) { throw new ConversationInputError( `Conversation not found: ${params.conversationRef} (use conversations_list)`, ); } + const plugin = deps.resolveOutboundChannelPlugin({ + channel: discoveredConversation.channel, + cfg: params.config, + }); + const candidatePreparedMessageId = begun + ? begun.record.preparedMessageId + : prepareConversationMessageId({ + plugin, + config: params.config, + conversation: discoveredConversation, + message: params.message, + }); + if (!candidatePreparedMessageId) { + throw new ConversationInputError( + `Conversation turn ${params.turnId} is missing its prepared message id`, + ); + } + const conversation = await ensureConversationContextBinding({ + deps, + scope, + config: params.config, + agentId: params.agentId, + conversation: discoveredConversation, + plugin, + }); if (!begun) { - const candidatePreparedMessageId = prepareConversationMessageId({ - deps, - config: params.config, - conversation, - message: params.message, - }); try { begun = deps.beginOperation(scope, { operationId: params.turnId, diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 7c90c3f2a5ca..b9eb5678955b 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -105,6 +105,7 @@ describe("method scope resolution", () => { ["exec.approvals.set", ["operator.admin"]], ["exec.approvals.node.get", ["operator.admin"]], ["exec.approvals.node.set", ["operator.admin"]], + ["conversations.list", ["operator.admin"]], ["conversations.send", ["operator.admin"]], ["conversations.turn", ["operator.admin"]], ["conversations.turn.cancel", ["operator.admin"]], diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index bd6007695768..e7c9b128d3b7 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -374,6 +374,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "ui.command", scope: "operator.write" }, { name: "approval.history", scope: "operator.approvals" }, { name: "plugin.surface.refresh", scope: "operator.read" }, + { name: "conversations.list", scope: "operator.admin" }, ] as const; const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap = new Map( diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 730e43c808d6..38febd95e323 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -44,13 +44,14 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-6)).toEqual([ + expect(listGatewayMethods().slice(-7)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", "ui.command", "approval.history", "plugin.surface.refresh", + "conversations.list", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -111,7 +112,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-13)).toEqual([ + expect(coreMethods.slice(-14)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -125,6 +126,7 @@ describe("listGatewayMethods", () => { "ui.command", "approval.history", "plugin.surface.refresh", + "conversations.list", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 67b50b420ac6..4253bb03269d 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -727,7 +727,12 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { loadHandlers: loadSendHandlers, }), ...createLazyCoreHandlers({ - methods: ["conversations.send", "conversations.turn", "conversations.turn.cancel"], + methods: [ + "conversations.list", + "conversations.send", + "conversations.turn", + "conversations.turn.cancel", + ], loadHandlers: loadConversationHandlers, }), ...createLazyCoreHandlers({ diff --git a/src/gateway/server-methods/conversations.test.ts b/src/gateway/server-methods/conversations.test.ts index 4d11d2482af1..6dad43197546 100644 --- a/src/gateway/server-methods/conversations.test.ts +++ b/src/gateway/server-methods/conversations.test.ts @@ -3,6 +3,7 @@ import { ConversationInputError, ConversationOperationConflictError, } from "../conversation-errors.js"; +import type { runGatewayConversationList } from "../conversation-list.js"; import type { runGatewayConversationSend } from "../conversation-send.js"; import type { runGatewayConversationTurn } from "../conversation-turn.js"; import { createConversationHandlers } from "./conversations.js"; @@ -91,6 +92,76 @@ function invokeSend(params: { }); } +function invokeList(params: { + handler: NonNullable["conversations.list"]>; + context: GatewayRequestContext; + respond: RespondFn; + request?: Record; +}) { + return params.handler({ + params: params.request ?? { agentId: "main", channel: "reef", query: "@molty", limit: 50 }, + respond: params.respond, + context: params.context, + req: { type: "req", id: "list-1", method: "conversations.list" }, + client: adminClient, + isWebchatConnect: () => false, + }); +} + +describe("conversations.list Gateway handler", () => { + it("runs discovery and listing inside the Gateway runtime", async () => { + const listed = { + conversations: [ + { + conversationRef: request.conversationRef, + channel: "reef", + accountId: "default", + kind: "direct" as const, + target: "reef:molty", + firstSeenAt: 100, + lastSeenAt: 100, + }, + ], + }; + const runConversationList = vi.fn( + async (_params: Parameters[0]) => listed, + ); + const handler = createConversationHandlers({ runConversationList })["conversations.list"]!; + const respond = vi.fn(); + + await invokeList({ handler, context: context(), respond }); + + expect(runConversationList).toHaveBeenCalledWith({ + config: {}, + agentId: "main", + channel: "reef", + query: "@molty", + limit: 50, + }); + expect(respond).toHaveBeenCalledWith(true, listed, undefined); + }); + + it("rejects invalid limits before directory discovery", async () => { + const runConversationList = vi.fn(); + const handler = createConversationHandlers({ runConversationList })["conversations.list"]!; + const respond = vi.fn(); + + await invokeList({ + handler, + context: context(), + respond, + request: { agentId: "main", channel: "reef", limit: 101 }, + }); + + expect(runConversationList).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + }); +}); + describe("conversations.send Gateway handler", () => { it("owns the send and rejects operation-id reuse with different source input", async () => { const runConversationSend = vi.fn(async () => sendResult); diff --git a/src/gateway/server-methods/conversations.ts b/src/gateway/server-methods/conversations.ts index f13a4de70498..c398b920f494 100644 --- a/src/gateway/server-methods/conversations.ts +++ b/src/gateway/server-methods/conversations.ts @@ -3,9 +3,11 @@ import { ErrorCodes, errorShape, formatValidationErrors, + validateConversationListParams, validateConversationSendParams, validateConversationTurnCancelParams, validateConversationTurnParams, + type ConversationListParams, type ConversationSendParams, type ConversationTurnCancelParams, type ConversationTurnParams, @@ -15,6 +17,7 @@ import { ConversationInputError, ConversationOperationConflictError, } from "../conversation-errors.js"; +import { runGatewayConversationList } from "../conversation-list.js"; import { runGatewayConversationSend } from "../conversation-send.js"; import { runGatewayConversationTurn } from "../conversation-turn.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; @@ -35,6 +38,7 @@ import type { type ConversationHandlerDeps = { cancelConversationTurn: typeof cancelPendingConversationTurn; + runConversationList: typeof runGatewayConversationList; runConversationSend: typeof runGatewayConversationSend; runConversationTurn: typeof runGatewayConversationTurn; }; @@ -184,14 +188,54 @@ async function runConversationOperation(params: { } } +const defaultConversationHandlerDeps: ConversationHandlerDeps = { + cancelConversationTurn: cancelPendingConversationTurn, + runConversationList: runGatewayConversationList, + runConversationSend: runGatewayConversationSend, + runConversationTurn: runGatewayConversationTurn, +}; + export function createConversationHandlers( - deps: ConversationHandlerDeps = { - cancelConversationTurn: cancelPendingConversationTurn, - runConversationSend: runGatewayConversationSend, - runConversationTurn: runGatewayConversationTurn, - }, + overrides: Partial = {}, ): GatewayRequestHandlers { + const deps = { ...defaultConversationHandlerDeps, ...overrides }; return { + "conversations.list": async ({ params, respond, context }) => { + if (!validateConversationListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid conversations.list params: ${formatValidationErrors(validateConversationListParams.errors)}`, + ), + ); + return; + } + const request = params as ConversationListParams; + try { + respond( + true, + await deps.runConversationList({ + config: resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }), + agentId: request.agentId, + ...(request.channel ? { channel: request.channel } : {}), + ...(request.query ? { query: request.query } : {}), + limit: request.limit ?? 50, + }), + undefined, + ); + } catch (cause) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + cause instanceof Error ? cause.message : String(cause), + ), + ); + } + }, "conversations.send": async ({ params, respond, context, client }) => { if (!validateConversationSendParams(params)) { respond( diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index d91bd8e8226f..91968ed0f601 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -25,7 +25,9 @@ export type OutboundSessionRoute = { recipientSessionExact?: boolean | "direct-alias" | "delivery-identity"; peer: RoutePeer; chatType: "direct" | "group" | "channel"; + /** Canonical conversation identity mirrored into MsgContext.From. */ from: string; + /** Routable delivery address mirrored into MsgContext.To. */ to: string; threadId?: string | number; };