Compare commits

...

10 Commits

6 changed files with 72 additions and 3 deletions

View File

@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
- Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888. - Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
- iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc. - iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
- Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
- Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker. - Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai. - Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
- Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin. - Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.

View File

@ -399,6 +399,58 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
}); });
it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => {
setNoAbort();
mocks.routeReply.mockClear();
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "webchat",
Surface: "webchat",
OriginatingChannel: "imessage",
OriginatingTo: "imessage:+15550001111",
});
const replyResolver = async (
_ctx: MsgContext,
_opts?: GetReplyOptions,
_cfg?: OpenClawConfig,
) => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(mocks.routeReply).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => {
setNoAbort();
mocks.routeReply.mockClear();
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "webchat",
Surface: "webchat",
OriginatingChannel: "imessage",
OriginatingTo: "imessage:+15550001111",
ExplicitDeliverRoute: true,
});
const replyResolver = async (
_ctx: MsgContext,
_opts?: GetReplyOptions,
_cfg?: OpenClawConfig,
) => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
channel: "imessage",
to: "imessage:+15550001111",
}),
);
});
it("routes media-only tool results when summaries are suppressed", async () => { it("routes media-only tool results when summaries are suppressed", async () => {
setNoAbort(); setNoAbort();
mocks.routeReply.mockClear(); mocks.routeReply.mockClear();

View File

@ -215,8 +215,15 @@ export async function dispatchReplyFromConfig(params: {
const surfaceChannel = normalizeMessageChannel(ctx.Surface); const surfaceChannel = normalizeMessageChannel(ctx.Surface);
// Prefer provider channel because surface may carry origin metadata in relayed flows. // Prefer provider channel because surface may carry origin metadata in relayed flows.
const currentSurface = providerChannel ?? surfaceChannel; const currentSurface = providerChannel ?? surfaceChannel;
const isInternalWebchatTurn =
currentSurface === INTERNAL_MESSAGE_CHANNEL &&
(surfaceChannel === INTERNAL_MESSAGE_CHANNEL || !surfaceChannel) &&
ctx.ExplicitDeliverRoute !== true;
const shouldRouteToOriginating = Boolean( const shouldRouteToOriginating = Boolean(
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface, !isInternalWebchatTurn &&
isRoutableChannel(originatingChannel) &&
originatingTo &&
originatingChannel !== currentSurface,
); );
const shouldSuppressTyping = const shouldSuppressTyping =
shouldRouteToOriginating || originatingChannel === INTERNAL_MESSAGE_CHANNEL; shouldRouteToOriginating || originatingChannel === INTERNAL_MESSAGE_CHANNEL;

View File

@ -159,6 +159,11 @@ export type MsgContext = {
* The chat/channel/user ID where the reply should be sent. * The chat/channel/user ID where the reply should be sent.
*/ */
OriginatingTo?: string; OriginatingTo?: string;
/**
* True when the current turn intentionally requested external delivery to
* OriginatingChannel/OriginatingTo, rather than inheriting stale session route metadata.
*/
ExplicitDeliverRoute?: boolean;
/** /**
* Provider-specific parent conversation id for threaded contexts. * Provider-specific parent conversation id for threaded contexts.
* For Discord threads, this is the parent channel id. * For Discord threads, this is the parent channel id.

View File

@ -393,6 +393,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect.objectContaining({ expect.objectContaining({
OriginatingChannel: "telegram", OriginatingChannel: "telegram",
OriginatingTo: "telegram:6812765697", OriginatingTo: "telegram:6812765697",
ExplicitDeliverRoute: true,
AccountId: "default", AccountId: "default",
MessageThreadId: 42, MessageThreadId: 42,
}), }),
@ -566,6 +567,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect.objectContaining({ expect.objectContaining({
OriginatingChannel: "webchat", OriginatingChannel: "webchat",
OriginatingTo: undefined, OriginatingTo: undefined,
ExplicitDeliverRoute: false,
AccountId: undefined, AccountId: undefined,
}), }),
); );

View File

@ -906,13 +906,14 @@ export const chatHandlers: GatewayRequestHandlers = {
(isChannelScopedSession || hasLegacyChannelPeerShape)) || (isChannelScopedSession || hasLegacyChannelPeerShape)) ||
(isConfiguredMainSessionScope && client?.connect !== undefined && !isFromWebchatClient)), (isConfiguredMainSessionScope && client?.connect !== undefined && !isFromWebchatClient)),
); );
const hasDeliverableRoute = const hasDeliverableRoute = Boolean(
shouldDeliverExternally && shouldDeliverExternally &&
canInheritDeliverableRoute && canInheritDeliverableRoute &&
routeChannelCandidate && routeChannelCandidate &&
routeChannelCandidate !== INTERNAL_MESSAGE_CHANNEL && routeChannelCandidate !== INTERNAL_MESSAGE_CHANNEL &&
typeof routeToCandidate === "string" && typeof routeToCandidate === "string" &&
routeToCandidate.trim().length > 0; routeToCandidate.trim().length > 0,
);
const originatingChannel = hasDeliverableRoute const originatingChannel = hasDeliverableRoute
? routeChannelCandidate ? routeChannelCandidate
: INTERNAL_MESSAGE_CHANNEL; : INTERNAL_MESSAGE_CHANNEL;
@ -935,6 +936,7 @@ export const chatHandlers: GatewayRequestHandlers = {
Surface: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL,
OriginatingChannel: originatingChannel, OriginatingChannel: originatingChannel,
OriginatingTo: originatingTo, OriginatingTo: originatingTo,
ExplicitDeliverRoute: hasDeliverableRoute,
AccountId: accountId, AccountId: accountId,
MessageThreadId: messageThreadId, MessageThreadId: messageThreadId,
ChatType: "direct", ChatType: "direct",