refactor(channels): unify session route projection

This commit is contained in:
Peter Steinberger
2026-05-18 15:02:38 +01:00
parent 85a3d5312f
commit 8477a67faf
23 changed files with 784 additions and 36 deletions

View File

@@ -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.

161
docs/refactor/channels.md Normal file
View File

@@ -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:<conversationId>` 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.

View File

@@ -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,
};
}

View File

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

View File

@@ -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<string, unknown>)
: undefined;
const deliveryContext = deliveryContextFromSession(entry);
const deliveryChannel = readStringValue(deliveryContext?.channel);
const deliveryTo = readStringValue(deliveryContext?.to);
const deliveryAccountId = readStringValue(deliveryContext?.accountId);

View File

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

View File

@@ -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) ?? "")
);
}

View File

@@ -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,

View File

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

View File

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

View File

@@ -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,

View File

@@ -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" },
});
});
});

View File

@@ -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<SessionEntry> = {
route: normalized.route,
deliveryContext: normalized.deliveryContext,
lastChannel: normalized.lastChannel,
lastTo: normalized.lastTo,

View File

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

View File

@@ -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,

View File

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

View File

@@ -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");
});

View File

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

View File

@@ -107,6 +107,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"groupChannel",
"space",
"origin",
"route",
"deliveryContext",
"lastChannel",
"lastTo",

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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,

View File

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