diff --git a/CHANGELOG.md b/CHANGELOG.md index 96cddf9c467..0f61ea483ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`. - Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach. - Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated. +- Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection. - Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi. - QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin. - QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin. diff --git a/docs/refactor/channels.md b/docs/refactor/channels.md new file mode 100644 index 00000000000..77dba391aec --- /dev/null +++ b/docs/refactor/channels.md @@ -0,0 +1,161 @@ +--- +title: "Channel route unification refactor" +sidebarTitle: "Channel route unification" +--- + +# Channel route unification refactor + +This is a temporary implementation plan. Delete this file before merging the +refactor PR after the code, tests, and PR description prove the plan is +complete. + +## Problem + +Channel routing is represented several times: + +- `ChannelRouteRef` is the SDK route identity shape. +- `ChannelOutboundSessionRoute` is the executable session route returned by + channel messaging adapters. +- `ConversationRef` and `SessionBindingRecord` identify bound conversations. +- `DeliveryContext` mirrors route fields for sessions, sentinels, tools, and + protocol compatibility. +- `SessionEntry` stores `deliveryContext`, `lastChannel`, `lastTo`, + `lastAccountId`, and `lastThreadId` as overlapping last-route fields. + +The Discord subagent thread bug happened because a plugin hook had to return a +second ad hoc route object after core already knew a binding existed. TypeScript +could not protect that path because the hook result made `deliveryOrigin` +optional and core treated it as a best-effort delivery hint. + +## Goals + +- Make `ChannelRouteRef` the canonical route metadata shape inside core. +- Keep `ChannelOutboundSessionRoute` as the channel-owned executable session + route. +- Keep `ConversationRef` as binding identity, not as a send target. +- Treat `DeliveryContext` as compatibility projection, not core routing truth. +- Share binding-to-route projection between subagent and ACP spawn paths. +- Keep sends in the durable message pipeline with `threadId` and `replyToId`. +- Deprecate ad hoc plugin hook fields once bundled callers use core projection. + +## Non-goals + +- Do not add a new route type family. +- Do not make core create provider-native threads directly. +- Do not infer child-thread support from a provider having any thread concept. +- Do not remove persisted session compatibility fields in the first pass. +- Do not bypass channel message adapters or durable final delivery. + +## Existing contracts to keep + +### `ChannelRouteRef` + +`src/plugin-sdk/channel-route.ts` owns route normalization and matching. Extend +helpers here only when the concept belongs in the SDK surface. Core-only +projection helpers should live outside the SDK. + +### `ChannelOutboundSessionRoute` + +`src/channels/plugins/types.core.ts` owns the route returned from +`messaging.resolveOutboundSessionRoute`. Plugins keep provider-native parsing +rules here and should use `buildChannelOutboundSessionRoute` or +`buildThreadAwareOutboundSessionRoute`. + +### `ConversationRef` + +`src/infra/outbound/session-binding.types.ts` owns binding identity. A +conversation can require plugin resolution before it is routable. + +### `DeliveryContext` + +`src/utils/delivery-context.types.ts` remains for protocol, session, sentinel, +and tool compatibility. New core code should convert it to `ChannelRouteRef` +before comparing or carrying route state. + +## Implementation phases + +### Phase 1: route projection helpers + +Add a core route projection module with helpers that: + +- narrow a `ChannelRouteRef` to a routable route with `channel` and `target.to`; +- project `DeliveryContext` to and from `ChannelRouteRef`; +- project `SessionEntry` route fields to `ChannelRouteRef`; +- project `ConversationRef` to a route through plugin `resolveDeliveryTarget` + with the existing generic `channel:` fallback; +- project `SessionBindingRecord` to a route using its `conversation`. + +Tests must cover: + +- normalized channel/account/to/thread fields; +- generic fallback routing when a plugin has no delivery-target projection; +- parent/child thread projection for Slack-like targets; +- same-channel merge without crossing fields between unrelated channels. + +### Phase 2: route-first session compatibility + +Add a canonical optional route field to session entries, then update session +delivery helpers to read the route first and derive legacy fields from it. Keep +writing legacy fields for compatibility. + +Tests must cover: + +- route wins over legacy fields when present; +- old session entries still hydrate; +- existing subagent session delivery context is not overwritten by spawn + request params. + +### Phase 3: shared spawn route planner + +Move subagent and ACP binding route construction into a shared planner. The +planner returns: + +- requester route; +- binding record; +- child delivery route when routable; +- compatibility delivery context; +- whether inline child delivery is allowed. + +Subagent and ACP callers must stop open-coding binding-to-delivery projection. + +Tests must cover: + +- Discord-style child thread delivery; +- Slack-style parent channel plus thread id delivery; +- current-conversation binding without routable child delivery; +- requester origin kept separate from child delivery route. + +### Phase 4: bundled plugin hook deprecation path + +Move bundled plugins toward core binding projection: + +- `subagent_spawning.deliveryOrigin` becomes deprecated compatibility output. +- `subagent_spawning.threadBindingReady` becomes deprecated compatibility + readiness. +- `subagent_delivery_target` becomes deprecated once core can resolve the bound + delivery route through `resolveDeliveryTarget`. + +Keep public SDK compatibility during the transition and document deprecations in +types and PR notes. + +### Phase 5: channel route adapter cleanup + +Normalize bundled channel route builders: + +- Discord and Slack stay on `buildThreadAwareOutboundSessionRoute`. +- Feishu, Matrix, and other channel route builders use shared route builders + where possible. +- MS Teams keeps normal thread send support separate from child-thread binding + until durable final `thread` capability and binding placement are explicitly + proven. + +## Validation checklist + +- Focused unit tests for route projection. +- Subagent thread-binding tests. +- ACP spawn route tests. +- Slack, Discord, Feishu, Matrix, and MS Teams channel route tests where touched. +- `pnpm check:changed` or Testbox equivalent before PR. +- Autoreview until no accepted actionable findings remain. +- PR description includes deprecations, compatibility behavior, and proof. +- Delete this file after the PR is green and the description is complete. diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 0d1ad94732e..cd36de09c7a 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -16,6 +16,7 @@ import { resolveChannelDefaultBindingPlacement, resolveInboundConversationResolution, } from "../channels/conversation-resolution.js"; +import { routeFromBindingRecord, routeToDeliveryFields } from "../channels/route-projection.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -63,7 +64,6 @@ import { deliveryContextFromSession, formatConversationTarget, normalizeDeliveryContext, - resolveConversationDeliveryTarget, } from "../utils/delivery-context.js"; import { type AcpSpawnParentRelayHandle, @@ -1091,11 +1091,7 @@ function resolveAcpSpawnBootstrapDeliveryPlan(params: { (params.binding?.conversation.parentConversationId ?? undefined) === (requesterConversationRef.parentConversationId ?? undefined), ); - const boundDeliveryTarget = resolveConversationDeliveryTarget({ - channel: params.requester.origin?.channel ?? params.binding?.conversation.channel, - conversationId: params.binding?.conversation.conversationId, - parentConversationId: params.binding?.conversation.parentConversationId, - }); + const boundDeliveryTarget = routeToDeliveryFields(routeFromBindingRecord(params.binding)); const inferredDeliveryTo = (bindingMatchesRequesterConversation ? normalizeOptionalString(params.requester.origin?.to) @@ -1123,7 +1119,10 @@ function resolveAcpSpawnBootstrapDeliveryPlan(params: { channel: useInlineDelivery ? params.requester.origin?.channel : undefined, accountId: useInlineDelivery ? requesterAccountId : undefined, to: useInlineDelivery ? inferredDeliveryTo : undefined, - threadId: useInlineDelivery ? resolvedDeliveryThreadId : undefined, + threadId: + useInlineDelivery && resolvedDeliveryThreadId != null + ? normalizeOptionalString(String(resolvedDeliveryThreadId)) + : undefined, }; } diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 2351ddd8171..1aff28ba0c6 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -2,6 +2,7 @@ import { completionRequiresMessageToolDelivery, resolveCompletionChatType, } from "../auto-reply/reply/completion-delivery-policy.js"; +import { routeFromConversationRef, routeToDeliveryFields } from "../channels/route-projection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; @@ -10,11 +11,7 @@ import { defaultRuntime } from "../runtime.js"; import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { - mergeDeliveryContext, - normalizeDeliveryContext, - resolveConversationDeliveryTarget, -} from "../utils/delivery-context.js"; +import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; import { INTERNAL_MESSAGE_CHANNEL, isDeliverableMessageChannel, @@ -160,11 +157,7 @@ function resolveBoundConversationOrigin(params: { }; } - const boundTarget = resolveConversationDeliveryTarget({ - channel: conversation.channel, - conversationId, - parentConversationId, - }); + const boundTarget = routeToDeliveryFields(routeFromConversationRef(conversation)); const inferredThreadId = boundTarget.threadId ?? (parentConversationId && parentConversationId !== conversationId diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 02309a9e934..d10c109d191 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -15,6 +15,7 @@ import { } from "../../gateway/session-utils.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../../shared/string-coerce.js"; +import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import { describeSessionsListTool, SESSIONS_LIST_TOOL_DISPLAY_SUMMARY, @@ -201,10 +202,7 @@ export function createSessionsListTool(opts?: { : undefined; const originChannel = typeof entryOrigin?.provider === "string" ? entryOrigin.provider : undefined; - const deliveryContext = - entry.deliveryContext && typeof entry.deliveryContext === "object" - ? (entry.deliveryContext as Record) - : undefined; + const deliveryContext = deliveryContextFromSession(entry); const deliveryChannel = readStringValue(deliveryContext?.channel); const deliveryTo = readStringValue(deliveryContext?.to); const deliveryAccountId = readStringValue(deliveryContext?.accountId); diff --git a/src/channels/route-projection.test.ts b/src/channels/route-projection.test.ts new file mode 100644 index 00000000000..da92ddc5700 --- /dev/null +++ b/src/channels/route-projection.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { + deliveryContextFromRoute, + normalizeRoutableChannelRoute, + routeFromBindingRecord, + routeFromConversationRef, + routeFromDeliveryContext, + routeFromSessionEntry, + routeToDeliveryFields, + routesShareDeliveryTarget, +} from "./route-projection.js"; + +describe("channel route projection", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "thread-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "thread-chat", label: "Thread chat" }), + messaging: { + resolveDeliveryTarget: ({ + conversationId, + parentConversationId, + }: { + conversationId: string; + parentConversationId?: string; + }) => { + const parent = parentConversationId?.trim(); + const child = conversationId.trim(); + return parent && parent !== child + ? { to: `channel:${parent}`, threadId: child } + : { to: `channel:${child}` }; + }, + }, + }, + }, + { + pluginId: "unroutable-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "unroutable-chat", + label: "Unroutable chat", + }), + messaging: { + resolveDeliveryTarget: () => null, + }, + }, + }, + ]), + ); + }); + + it("round-trips delivery context through channel route metadata", () => { + const route = routeFromDeliveryContext({ + channel: " Slack ", + to: " channel:C123 ", + accountId: " work ", + threadId: " 177000.123 ", + }); + + expect(route).toEqual({ + channel: "slack", + accountId: "work", + target: { to: "channel:C123" }, + thread: { id: "177000.123" }, + }); + expect(deliveryContextFromRoute(route)).toEqual({ + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }); + }); + + it("projects parent-child conversation refs through plugin delivery targets", () => { + expect( + routeFromConversationRef({ + channel: "thread-chat", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "room-1", + }), + ).toEqual({ + channel: "thread-chat", + accountId: "default", + target: { to: "channel:room-1" }, + thread: { id: "thread-1", source: "target" }, + }); + }); + + it("falls back to generic channel targets when a plugin has no target projection", () => { + expect( + routeFromConversationRef({ + channel: "unroutable-chat", + accountId: "default", + conversationId: "room-1", + }), + ).toEqual({ + channel: "unroutable-chat", + accountId: "default", + target: { to: "channel:room-1" }, + }); + }); + + it("projects session binding records without duplicating hook delivery origin logic", () => { + const route = routeFromBindingRecord({ + bindingId: "binding-1", + targetKind: "subagent", + targetSessionKey: "agent:worker:main", + status: "active", + boundAt: 1, + conversation: { + channel: "thread-chat", + accountId: "work", + conversationId: "thread-1", + parentConversationId: "room-1", + }, + }); + + expect(routeToDeliveryFields(route)).toEqual({ + deliveryContext: { + channel: "thread-chat", + to: "channel:room-1", + accountId: "work", + threadId: "thread-1", + }, + channel: "thread-chat", + to: "channel:room-1", + accountId: "work", + threadId: "thread-1", + }); + }); + + it("uses session route before legacy last route fields", () => { + expect( + routeFromSessionEntry({ + sessionId: "sess-1", + updatedAt: 1, + route: { + channel: "slack", + target: { to: "channel:C123" }, + thread: { id: "177000.123" }, + }, + deliveryContext: { + channel: "discord", + to: "channel:old", + threadId: "old-thread", + }, + lastChannel: "discord", + lastTo: "channel:older", + }), + ).toEqual({ + channel: "slack", + target: { to: "channel:C123" }, + thread: { id: "177000.123" }, + }); + }); + + it("narrows only routable routes and compares delivery targets", () => { + expect(normalizeRoutableChannelRoute({ channel: "slack" })).toBeUndefined(); + expect( + routesShareDeliveryTarget({ + left: { channel: "slack", target: { to: "channel:C123" } }, + right: { + channel: "slack", + accountId: "work", + target: { to: "channel:C123" }, + }, + }), + ).toBe(true); + expect( + routesShareDeliveryTarget({ + left: { + channel: "slack", + target: { to: "channel:C123" }, + thread: { id: "thread-a" }, + }, + right: { + channel: "slack", + target: { to: "channel:C123" }, + thread: { id: "thread-b" }, + }, + }), + ).toBe(false); + }); +}); diff --git a/src/channels/route-projection.ts b/src/channels/route-projection.ts new file mode 100644 index 00000000000..fdeafdc95db --- /dev/null +++ b/src/channels/route-projection.ts @@ -0,0 +1,154 @@ +import type { SessionEntry } from "../config/sessions/types.js"; +import type { + ConversationRef, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; +import { + channelRouteThreadId, + channelRouteTarget, + normalizeChannelRouteRef, + type ChannelRouteChatType, + type ChannelRouteRef, +} from "../plugin-sdk/channel-route.js"; +import { + channelRouteFromDeliveryContext, + deliveryContextFromChannelRoute, + deliveryContextFromSession, + normalizeDeliveryContext, + normalizeSessionDeliveryFields, + resolveConversationDeliveryTarget, + type DeliveryContext, +} from "../utils/delivery-context.js"; + +export type RoutableChannelRouteRef = ChannelRouteRef & { + channel: string; + target: { + to: string; + rawTo?: string; + chatType?: ChannelRouteChatType; + }; +}; + +export type SessionRouteDeliveryFields = { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + +export function normalizeRoutableChannelRoute( + route?: ChannelRouteRef | null, +): RoutableChannelRouteRef | undefined { + const normalized = normalizeChannelRouteRef({ + channel: route?.channel, + accountId: route?.accountId, + to: route?.target?.to, + rawTo: route?.target?.rawTo, + chatType: route?.target?.chatType, + threadId: route?.thread?.id, + threadKind: route?.thread?.kind, + threadSource: route?.thread?.source, + }); + if (!normalized?.channel || !normalized.target?.to) { + return undefined; + } + return normalized as RoutableChannelRouteRef; +} + +export function routeFromDeliveryContext(context?: DeliveryContext): ChannelRouteRef | undefined { + return channelRouteFromDeliveryContext(normalizeDeliveryContext(context)); +} + +export function deliveryContextFromRoute(route?: ChannelRouteRef): DeliveryContext | undefined { + return deliveryContextFromChannelRoute(route); +} + +export function routeFromSessionEntry(entry?: SessionEntry | null): ChannelRouteRef | undefined { + if (!entry) { + return undefined; + } + return ( + normalizeSessionDeliveryFields(entry).route ?? + routeFromDeliveryContext(deliveryContextFromSession(entry)) + ); +} + +export function sessionDeliveryFieldsFromRoute( + route?: ChannelRouteRef, +): SessionRouteDeliveryFields { + return normalizeSessionDeliveryFields({ route }); +} + +export function routeFromConversationRef( + conversation?: ConversationRef | null, +): ChannelRouteRef | undefined { + if (!conversation) { + return undefined; + } + const target = resolveConversationDeliveryTarget({ + channel: conversation.channel, + conversationId: conversation.conversationId, + parentConversationId: conversation.parentConversationId, + }); + return normalizeChannelRouteRef({ + channel: conversation.channel, + accountId: conversation.accountId, + to: target.to, + threadId: target.threadId, + threadSource: target.threadId ? "target" : undefined, + }); +} + +export function routableRouteFromConversationRef( + conversation?: ConversationRef | null, +): RoutableChannelRouteRef | undefined { + return normalizeRoutableChannelRoute(routeFromConversationRef(conversation)); +} + +export function routeFromBindingRecord( + binding?: SessionBindingRecord | null, +): ChannelRouteRef | undefined { + return routeFromConversationRef(binding?.conversation); +} + +export function routableRouteFromBindingRecord( + binding?: SessionBindingRecord | null, +): RoutableChannelRouteRef | undefined { + return normalizeRoutableChannelRoute(routeFromBindingRecord(binding)); +} + +export function routeToDeliveryFields(route?: ChannelRouteRef): { + deliveryContext?: DeliveryContext; + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; +} { + const deliveryContext = deliveryContextFromRoute(route); + return { + ...(deliveryContext ? { deliveryContext } : {}), + ...(deliveryContext?.channel ? { channel: deliveryContext.channel } : {}), + ...(deliveryContext?.to ? { to: deliveryContext.to } : {}), + ...(deliveryContext?.accountId ? { accountId: deliveryContext.accountId } : {}), + ...(deliveryContext?.threadId != null ? { threadId: deliveryContext.threadId } : {}), + }; +} + +export function routesShareDeliveryTarget(params: { + left?: ChannelRouteRef | null; + right?: ChannelRouteRef | null; +}): boolean { + const left = normalizeRoutableChannelRoute(params.left); + const right = normalizeRoutableChannelRoute(params.right); + if (!left || !right) { + return false; + } + return ( + left.channel === right.channel && + channelRouteTarget(left) === channelRouteTarget(right) && + (left.accountId == null || right.accountId == null || left.accountId === right.accountId) && + String(channelRouteThreadId(left) ?? "") === String(channelRouteThreadId(right) ?? "") + ); +} diff --git a/src/channels/session.ts b/src/channels/session.ts index 1e6e7c7fba5..e1810a0babd 100644 --- a/src/channels/session.ts +++ b/src/channels/session.ts @@ -65,6 +65,7 @@ export async function recordInboundSession(params: { await runtime.updateLastRoute({ storePath, sessionKey: targetSessionKey, + route: update.route, deliveryContext: { channel: update.channel, to: update.to, diff --git a/src/channels/session.types.ts b/src/channels/session.types.ts index 8da57ef8444..0b8644da9d6 100644 --- a/src/channels/session.types.ts +++ b/src/channels/session.types.ts @@ -1,5 +1,6 @@ import type { MsgContext } from "../auto-reply/templating.js"; import type { GroupKeyResolution, SessionEntry } from "../config/sessions/types.js"; +import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; export type InboundLastRouteUpdate = { sessionKey: string; @@ -7,6 +8,7 @@ export type InboundLastRouteUpdate = { to: string; accountId?: string; threadId?: string | number; + route?: ChannelRouteRef; mainDmOwnerPin?: { ownerRecipient: string; senderRecipient: string; diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index 135a6da00a0..e435fbdc73c 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -1,4 +1,5 @@ import type { MsgContext } from "../../auto-reply/templating.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { SessionMaintenanceMode } from "../types.base.js"; import type { SessionEntry, GroupKeyResolution } from "./types.js"; @@ -66,6 +67,7 @@ export type UpdateLastRoute = (params: { to?: string; accountId?: string; threadId?: string | number; + route?: ChannelRouteRef; deliveryContext?: DeliveryContext; ctx?: MsgContext; groupResolution?: GroupKeyResolution | null; diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 5839aa3d457..35231fa19f7 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -3,6 +3,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { isPluginJsonValue, type PluginJsonValue } from "../../plugins/host-hook-json.js"; import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js"; import { + normalizeDeliveryChannelRoute, normalizeDeliveryContext, normalizeSessionDeliveryFields, } from "../../utils/delivery-context.shared.js"; @@ -248,7 +249,9 @@ function normalizePluginExtensionSlotKeys(entry: SessionEntry): SessionEntry { } function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { + const entryRoute = normalizeDeliveryChannelRoute(entry.route); const normalized = normalizeSessionDeliveryFields({ + route: entryRoute, channel: entry.channel, lastChannel: entry.lastChannel, lastTo: entry.lastTo, @@ -263,6 +266,7 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { (entry.deliveryContext?.accountId ?? undefined) === nextDelivery?.accountId && (entry.deliveryContext?.threadId ?? undefined) === nextDelivery?.threadId; const sameLast = + JSON.stringify(entryRoute) === JSON.stringify(normalized.route) && entry.lastChannel === normalized.lastChannel && entry.lastTo === normalized.lastTo && entry.lastAccountId === normalized.lastAccountId && @@ -272,6 +276,7 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { } return { ...entry, + route: normalized.route, deliveryContext: nextDelivery, lastChannel: normalized.lastChannel, lastTo: normalized.lastTo, diff --git a/src/config/sessions/store.session-key-normalization.test.ts b/src/config/sessions/store.session-key-normalization.test.ts index 3f799becbf2..d4462b14060 100644 --- a/src/config/sessions/store.session-key-normalization.test.ts +++ b/src/config/sessions/store.session-key-normalization.test.ts @@ -95,6 +95,10 @@ describe("session store key normalization", () => { expect(Object.keys(store)).toEqual([CANONICAL_KEY]); expect(store[CANONICAL_KEY]?.lastChannel).toBe("webchat"); expect(store[CANONICAL_KEY]?.lastTo).toBe("webchat:user-1"); + expect(store[CANONICAL_KEY]?.route).toEqual({ + channel: "webchat", + target: { to: "webchat:user-1" }, + }); }); it("migrates legacy mixed-case entries to the canonical key on update", async () => { @@ -215,4 +219,73 @@ describe("session store key normalization", () => { expect(store[SIGNAL_GROUP_KEY]?.groupId).toBe(SIGNAL_GROUP_ID); expect(store[LEGACY_SIGNAL_GROUP_KEY]).toBeUndefined(); }); + + it("stores canonical route metadata and derives legacy delivery fields", async () => { + await updateLastRoute({ + storePath, + sessionKey: CANONICAL_KEY, + route: { + channel: "slack", + accountId: "work", + target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" }, + thread: { id: "177000.123", kind: "thread", source: "target" }, + }, + deliveryContext: { + channel: "discord", + to: "channel:old", + threadId: "old-thread", + }, + }); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(store[CANONICAL_KEY]?.route).toEqual({ + channel: "slack", + accountId: "work", + target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" }, + thread: { id: "177000.123", kind: "thread", source: "target" }, + }); + expect(store[CANONICAL_KEY]?.deliveryContext).toEqual({ + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }); + expect(store[CANONICAL_KEY]?.lastChannel).toBe("slack"); + expect(store[CANONICAL_KEY]?.lastTo).toBe("channel:C123"); + expect(store[CANONICAL_KEY]?.lastAccountId).toBe("work"); + expect(store[CANONICAL_KEY]?.lastThreadId).toBe("177000.123"); + }); + + it("normalizes malformed persisted route metadata on load", async () => { + await fs.writeFile( + storePath, + JSON.stringify( + { + [CANONICAL_KEY]: { + sessionId: "legacy-route-session", + updatedAt: 1, + route: "stale-custom-slot", + deliveryContext: { + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + clearSessionStoreCacheForTest(); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(store[CANONICAL_KEY]?.route).toEqual({ + channel: "slack", + accountId: "work", + target: { to: "channel:C123" }, + thread: { id: "177000.123" }, + }); + }); }); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 3198c28bd87..08fb79d8c64 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -4,6 +4,7 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { writeTextAtomic } from "../../infra/json-files.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { + deliveryContextFromChannelRoute, deliveryContextFromSession, mergeDeliveryContext, normalizeDeliveryContext, @@ -628,6 +629,7 @@ export async function updateLastRoute(params: { to?: string; accountId?: string; threadId?: string | number; + route?: SessionEntry["route"]; deliveryContext?: DeliveryContext; ctx?: MsgContext; groupResolution?: import("./types.js").GroupKeyResolution | null; @@ -649,7 +651,11 @@ export async function updateLastRoute(params: { accountId, threadId, }); - const mergedInput = mergeDeliveryContext(explicitContext, inlineContext); + const routeContext = deliveryContextFromChannelRoute(params.route); + const mergedInput = mergeDeliveryContext( + routeContext, + mergeDeliveryContext(explicitContext, inlineContext), + ); const explicitDeliveryContext = params.deliveryContext; const explicitThreadFromDeliveryContext = explicitDeliveryContext != null && @@ -660,6 +666,8 @@ export async function updateLastRoute(params: { explicitThreadFromDeliveryContext ?? (threadId != null && threadId !== "" ? threadId : undefined); const explicitRouteProvided = Boolean( + routeContext?.channel || + routeContext?.to || explicitContext?.channel || explicitContext?.to || inlineContext?.channel || @@ -671,6 +679,7 @@ export async function updateLastRoute(params: { : deliveryContextFromSession(existing); const merged = mergeDeliveryContext(mergedInput, fallbackContext); const normalized = normalizeSessionDeliveryFields({ + route: params.route, deliveryContext: { channel: merged?.channel, to: merged?.to, @@ -687,6 +696,7 @@ export async function updateLastRoute(params: { }) : null; const basePatch: Partial = { + route: normalized.route, deliveryContext: normalized.deliveryContext, lastChannel: normalized.lastChannel, lastTo: normalized.lastTo, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 690bae60a03..034597cfc80 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import type { Skill } from "@earendil-works/pi-coding-agent"; import type { ChatType } from "../../channels/chat-type.js"; import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { TtsAutoMode } from "../types.tts.js"; @@ -350,6 +351,7 @@ export type SessionEntry = { groupChannel?: string; space?: string; origin?: SessionOrigin; + route?: ChannelRouteRef; deliveryContext?: DeliveryContext; lastChannel?: SessionChannelId; lastTo?: string; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index f426bf12ac5..0c0bb50cb24 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1214,6 +1214,7 @@ export const agentHandlers: GatewayRequestHandlers = { systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, skillsSnapshot: entry?.skillsSnapshot, + route: effectiveDeliveryFields.route, deliveryContext: effectiveDeliveryFields.deliveryContext, lastChannel: effectiveDeliveryFields.lastChannel ?? entry?.lastChannel, lastTo: effectiveDeliveryFields.lastTo ?? entry?.lastTo, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 2024a24f99e..0bb3ab7b278 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -53,6 +53,7 @@ import { normalizeInputProvenance, type InputProvenance } from "../../sessions/i import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import { stripInlineDirectiveTagsForDisplay, sanitizeReplyDirectiveId, @@ -648,19 +649,18 @@ function resolveChatSendOriginatingRoute(params: { }; } + const sessionDeliveryContext = deliveryContextFromSession(params.entry); const routeChannelCandidate = normalizeMessageChannel( - params.entry?.deliveryContext?.channel ?? - params.entry?.lastChannel ?? - params.entry?.origin?.provider, + sessionDeliveryContext?.channel ?? params.entry?.lastChannel ?? params.entry?.origin?.provider, ); - const routeToCandidate = params.entry?.deliveryContext?.to ?? params.entry?.lastTo; + const routeToCandidate = sessionDeliveryContext?.to ?? params.entry?.lastTo; const routeAccountIdCandidate = - params.entry?.deliveryContext?.accountId ?? + sessionDeliveryContext?.accountId ?? params.entry?.lastAccountId ?? params.entry?.origin?.accountId ?? undefined; const routeThreadIdCandidate = - params.entry?.deliveryContext?.threadId ?? + sessionDeliveryContext?.threadId ?? params.entry?.lastThreadId ?? params.entry?.origin?.threadId; if (params.sessionKey.length > CHAT_SEND_SESSION_KEY_MAX_LENGTH) { diff --git a/src/gateway/server.agent.subagent-delivery-context.test.ts b/src/gateway/server.agent.subagent-delivery-context.test.ts index 441f9e94b09..474c1aa2b05 100644 --- a/src/gateway/server.agent.subagent-delivery-context.test.ts +++ b/src/gateway/server.agent.subagent-delivery-context.test.ts @@ -68,6 +68,12 @@ afterAll(async () => { }); type StoredEntry = { + route?: { + channel?: string; + accountId?: string; + target?: { to?: string }; + thread?: { id?: string | number }; + }; deliveryContext?: { channel?: string; to?: string; threadId?: string; accountId?: string }; lastChannel?: string; lastTo?: string; @@ -118,6 +124,12 @@ describe("subagent session deliveryContext from spawn request params", () => { expect(deliveryContext.to).toBe("channel:C0AF8TW48UQ"); expect(deliveryContext.threadId).toBe("1774374945.091819"); expect(deliveryContext.accountId).toBe("default"); + expect(entry.route).toEqual({ + channel: "slack", + accountId: "default", + target: { to: "channel:C0AF8TW48UQ" }, + thread: { id: "1774374945.091819" }, + }); expect(entry.lastChannel).toBe("slack"); expect(entry.lastTo).toBe("channel:C0AF8TW48UQ"); }); @@ -207,6 +219,12 @@ describe("subagent session deliveryContext from spawn request params", () => { expect(deliveryContext.to).toBe("user:U07FDR83W6N"); expect(deliveryContext.threadId).toBe("1775577152.364109"); expect(deliveryContext.accountId).toBe("default"); + expect(entry.route).toEqual({ + channel: "slack", + accountId: "default", + target: { to: "user:U07FDR83W6N" }, + thread: { id: "1775577152.364109" }, + }); expect(entry.lastThreadId).toBe("1775577152.364109"); }); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 57add9558f1..c6f8e177ff5 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -587,7 +587,17 @@ export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase; export type PluginHookSubagentSpawningResult = | { status: "ok"; + /** + * @deprecated Core now resolves thread-bound spawn routing from session + * bindings and channel route projection. Keep returning this only for + * compatibility with older OpenClaw runtimes. + */ threadBindingReady?: boolean; + /** + * @deprecated Use channel `resolveDeliveryTarget` plus core + * `SessionBindingRecord` projection instead of returning an ad hoc + * delivery route from this hook. + */ deliveryOrigin?: { channel?: string; accountId?: string; @@ -614,6 +624,11 @@ export type PluginHookSubagentDeliveryTargetEvent = { expectsCompletionMessage: boolean; }; +/** + * @deprecated Core route projection resolves subagent delivery targets from + * `SessionBindingRecord` and channel `resolveDeliveryTarget`. This hook result + * remains for plugin compatibility during the transition. + */ export type PluginHookSubagentDeliveryTargetResult = { origin?: { channel?: string; diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 6ca8e369f41..1aa4eb56ee9 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -107,6 +107,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "groupChannel", "space", "origin", + "route", "deliveryContext", "lastChannel", "lastTo", diff --git a/src/utils/delivery-context.shared.ts b/src/utils/delivery-context.shared.ts index 4cc01332f0f..9e65ae70534 100644 --- a/src/utils/delivery-context.shared.ts +++ b/src/utils/delivery-context.shared.ts @@ -2,7 +2,9 @@ import { channelRouteCompactKey, channelRouteThreadId, channelRouteTarget, + normalizeChannelRouteRef, normalizeChannelRouteTarget, + type ChannelRouteRef, } from "../plugin-sdk/channel-route.js"; import { normalizeAccountId } from "./account-id.js"; import type { DeliveryContext, DeliveryContextSessionSource } from "./delivery-context.types.js"; @@ -37,7 +39,62 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon return normalized; } +export function normalizeDeliveryChannelRoute(route?: unknown): ChannelRouteRef | undefined { + if (!route || typeof route !== "object" || Array.isArray(route)) { + return undefined; + } + const candidate = route as ChannelRouteRef; + return normalizeChannelRouteRef({ + channel: candidate.channel, + to: candidate.target?.to, + rawTo: candidate.target?.rawTo, + chatType: candidate.target?.chatType, + accountId: candidate.accountId, + threadId: candidate.thread?.id, + threadKind: candidate.thread?.kind, + threadSource: candidate.thread?.source, + }); +} + +export function deliveryContextFromChannelRoute( + route?: ChannelRouteRef, +): DeliveryContext | undefined { + const normalized = normalizeDeliveryChannelRoute(route); + return normalizeDeliveryContext({ + channel: normalized?.channel, + to: channelRouteTarget(normalized), + accountId: normalized?.accountId, + threadId: channelRouteThreadId(normalized), + }); +} + +export function channelRouteFromDeliveryContext( + context?: DeliveryContext, +): ChannelRouteRef | undefined { + return normalizeChannelRouteTarget(normalizeDeliveryContext(context)); +} + +function mergeRouteMetadataWithDeliveryContext( + route: ChannelRouteRef | undefined, + context: DeliveryContext, +): ChannelRouteRef | undefined { + if (!route) { + return channelRouteFromDeliveryContext(context); + } + return normalizeChannelRouteRef({ + channel: route.channel ?? context.channel, + to: route.target?.to ?? context.to, + rawTo: route.target?.rawTo, + chatType: route.target?.chatType, + accountId: route.accountId ?? context.accountId, + threadId: route.thread?.id ?? context.threadId, + threadKind: route.thread?.kind, + threadSource: route.thread?.source, + }); +} + export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): { + route?: ChannelRouteRef; deliveryContext?: DeliveryContext; lastChannel?: string; lastTo?: string; @@ -46,6 +103,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo } { if (!source) { return { + route: undefined, deliveryContext: undefined, lastChannel: undefined, lastTo: undefined, @@ -54,18 +112,22 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo }; } + const normalizedRoute = normalizeDeliveryChannelRoute(source.route); + const routeContext = deliveryContextFromChannelRoute(normalizedRoute); + const legacyContext = normalizeDeliveryContext({ + channel: source.lastChannel ?? source.channel, + to: source.lastTo, + accountId: source.lastAccountId, + threadId: source.lastThreadId, + }); const merged = mergeDeliveryContext( - normalizeDeliveryContext({ - channel: source.lastChannel ?? source.channel, - to: source.lastTo, - accountId: source.lastAccountId, - threadId: source.lastThreadId, - }), - normalizeDeliveryContext(source.deliveryContext), + routeContext, + mergeDeliveryContext(legacyContext, normalizeDeliveryContext(source.deliveryContext)), ); if (!merged) { return { + route: undefined, deliveryContext: undefined, lastChannel: undefined, lastTo: undefined, @@ -75,6 +137,7 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo } return { + route: mergeRouteMetadataWithDeliveryContext(normalizedRoute, merged), deliveryContext: merged, lastChannel: merged.channel, lastTo: merged.to, @@ -90,6 +153,7 @@ export function deliveryContextFromSession( return undefined; } const source: DeliveryContextSessionSource = { + route: entry.route, channel: entry.channel ?? entry.origin?.provider, lastChannel: entry.lastChannel, lastTo: entry.lastTo, diff --git a/src/utils/delivery-context.test.ts b/src/utils/delivery-context.test.ts index 7d155f6f0d5..aee3aecf1bd 100644 --- a/src/utils/delivery-context.test.ts +++ b/src/utils/delivery-context.test.ts @@ -188,6 +188,25 @@ describe("delivery context helpers", () => { }); it("derives delivery context from a session entry", () => { + expect( + deliveryContextFromSession({ + route: { + channel: "slack", + accountId: "work", + target: { to: "channel:C123" }, + thread: { id: "177000.123" }, + }, + channel: "webchat", + lastChannel: "webchat", + lastTo: "user:old", + }), + ).toEqual({ + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }); + expect( deliveryContextFromSession({ channel: "webchat", @@ -264,4 +283,39 @@ describe("delivery context helpers", () => { expect(normalized.lastAccountId).toBeUndefined(); expect(normalized.lastThreadId).toBeUndefined(); }); + + it("normalizes route-first delivery fields and mirrors legacy fields", () => { + const normalized = normalizeSessionDeliveryFields({ + route: { + channel: "Slack", + accountId: " work ", + target: { to: " channel:C123 ", rawTo: " slack://C123 ", chatType: "channel" }, + thread: { id: " 177000.123 ", kind: "thread", source: "target" }, + }, + deliveryContext: { + channel: "discord", + to: "channel:old", + threadId: "old-thread", + }, + lastChannel: "discord", + lastTo: "channel:older", + }); + + expect(normalized.route).toEqual({ + channel: "slack", + accountId: "work", + target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" }, + thread: { id: "177000.123", kind: "thread", source: "target" }, + }); + expect(normalized.deliveryContext).toEqual({ + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }); + expect(normalized.lastChannel).toBe("slack"); + expect(normalized.lastTo).toBe("channel:C123"); + expect(normalized.lastAccountId).toBe("work"); + expect(normalized.lastThreadId).toBe("177000.123"); + }); }); diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 157d94efd5e..7af824b19a5 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -2,6 +2,8 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index. import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeMessageChannel } from "./message-channel.js"; export { + channelRouteFromDeliveryContext, + deliveryContextFromChannelRoute, deliveryContextFromSession, deliveryContextKey, mergeDeliveryContext, diff --git a/src/utils/delivery-context.types.ts b/src/utils/delivery-context.types.ts index 15fcc344cfc..a3feccaa7e2 100644 --- a/src/utils/delivery-context.types.ts +++ b/src/utils/delivery-context.types.ts @@ -1,4 +1,4 @@ -import type { ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js"; +import type { ChannelRouteRef, ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js"; export type DeliveryIntentRef = { id: string; @@ -18,6 +18,7 @@ export type DeliveryContext = Pick< }; export type DeliveryContextSessionSource = { + route?: ChannelRouteRef; channel?: string; lastChannel?: string; lastTo?: string;