mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 05:49:50 +00:00
refactor(channels): unify session route projection
This commit is contained in:
@@ -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
161
docs/refactor/channels.md
Normal 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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
191
src/channels/route-projection.test.ts
Normal file
191
src/channels/route-projection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
154
src/channels/route-projection.ts
Normal file
154
src/channels/route-projection.ts
Normal 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) ?? "")
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -107,6 +107,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"groupChannel",
|
||||
"space",
|
||||
"origin",
|
||||
"route",
|
||||
"deliveryContext",
|
||||
"lastChannel",
|
||||
"lastTo",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user