refactor(auto-reply): centralize command turn context

* refactor(auto-reply): centralize command turn context

* fix(channels): narrow command turn context literals

* fix(auto-reply): preserve command auth on refinalize

* fix(auto-reply): keep command turn context sdk-compatible

* fix(auto-reply): route structured command turns before reply setup

* test(cli): type stale launchd job mock
This commit is contained in:
Peter Steinberger
2026-05-15 08:41:09 +01:00
committed by GitHub
parent f4d90eb36a
commit 64d4f99d26
28 changed files with 690 additions and 38 deletions

View File

@@ -223,6 +223,12 @@ export async function dispatchDiscordComponentEvent(params: {
Surface: "discord" as const,
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandTurn: {
kind: "text-slash" as const,
source: "text" as const,
authorized: commandAuthorized,
body: eventText,
},
CommandSource: "text" as const,
MessageSid: interaction.rawData.id,
Timestamp: timestamp,

View File

@@ -350,6 +350,12 @@ export async function buildDiscordMessageProcessContext(params: {
...mediaPayload,
...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
CommandAuthorized: commandAuthorized,
CommandTurn: {
kind: "text-slash" as const,
source: "text" as const,
authorized: commandAuthorized,
body: preflightAudioTranscript ?? baseText,
},
CommandSource: "text" as const,
OriginatingChannel: "discord" as const,
OriginatingTo: originatingTo,

View File

@@ -87,6 +87,12 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma
MessageThreadId: params.isThreadChannel ? params.channelId : undefined,
Timestamp: params.timestampMs ?? Date.now(),
CommandAuthorized: params.commandAuthorized,
CommandTurn: {
kind: "native" as const,
source: "native" as const,
authorized: params.commandAuthorized,
body: params.prompt,
},
CommandSource: "native" as const,
// Native slash contexts use To=slash:<user> for interaction routing.
// For follow-up delivery (for example subagent completion announces),

View File

@@ -54,6 +54,11 @@ describe("broadcast dispatch", () => {
return {
...ctx,
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
CommandTurn: {
kind: "normal",
source: "message",
authorized: false,
},
};
};
const mockDispatchReplyFromConfig = vi

View File

@@ -414,6 +414,7 @@ export async function buildTelegramInboundContextPayload(params: {
: `telegram:${chatId}`;
const telegramTo = `telegram:${chatId}`;
const locationContext = locationData ? toLocationContext(locationData) : undefined;
const commandSource = options?.commandSource;
const ctxPayload = sessionRuntime.buildChannelTurnContext({
channel: "telegram",
accountId: route.accountId,
@@ -465,6 +466,22 @@ export async function buildTelegramInboundContextPayload(params: {
authorizers: [],
},
},
commandTurn:
commandSource === "native"
? {
kind: "native",
source: "native",
authorized: commandAuthorized,
body: commandBody,
}
: commandSource === "text"
? {
kind: "text-slash",
source: "text",
authorized: commandAuthorized,
body: commandBody,
}
: undefined,
media: contextMedia.map((media, index) => ({
path: media.path,
url: media.path,
@@ -523,7 +540,6 @@ export async function buildTelegramInboundContextPayload(params: {
Sticker: allMedia[0]?.stickerMetadata,
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined,
...locationContext,
CommandSource: options?.commandSource,
IsForum: isForum,
TopicName: isForum && topicName ? topicName : undefined,
},

View File

@@ -1163,6 +1163,12 @@ export const registerTelegramNativeCommands = ({
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandTurn: {
kind: "native" as const,
source: "native" as const,
authorized: commandAuthorized,
body: prompt,
},
CommandSource: "native" as const,
SessionKey: commandSessionKey,
AccountId: route.accountId,

View File

@@ -388,7 +388,12 @@ describe("whatsapp inbound dispatch", () => {
combinedBody: "/status",
commandBody: "/status",
commandAuthorized: true,
commandSource: "text",
commandTurn: {
kind: "text-slash",
source: "text",
authorized: true,
body: "/status",
},
conversationId: "+1000",
msg: makeMsg({
body: "/status",
@@ -408,6 +413,12 @@ describe("whatsapp inbound dispatch", () => {
RawBody: "/status",
CommandAuthorized: true,
CommandSource: "text",
CommandTurn: {
kind: "text-slash",
source: "text",
authorized: true,
body: "/status",
},
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",

View File

@@ -2,6 +2,7 @@ import {
DEFAULT_TIMING,
type StatusReactionController,
} from "openclaw/plugin-sdk/channel-feedback";
import type { CommandTurnContext } from "openclaw/plugin-sdk/channel-inbound";
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-message";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
@@ -225,6 +226,7 @@ export function buildWhatsAppInboundContext(params: {
combinedBody: string;
commandBody?: string;
commandAuthorized?: boolean;
commandTurn?: CommandTurnContext;
commandSource?: "text";
conversationId: string;
groupHistory?: GroupHistoryEntry[];
@@ -280,7 +282,12 @@ export function buildWhatsAppInboundContext(params: {
SenderId: params.sender.id ?? params.sender.e164,
SenderE164: params.sender.e164,
CommandAuthorized: params.commandAuthorized,
CommandSource: params.commandSource,
CommandTurn: params.commandTurn,
CommandSource:
params.commandSource ??
(params.commandTurn?.source === "native" || params.commandTurn?.source === "text"
? params.commandTurn.source
: undefined),
ReplyThreading: params.replyThreading,
WasMentioned: params.msg.wasMentioned,
GroupSystemPrompt: params.groupSystemPrompt,
@@ -294,6 +301,43 @@ export function buildWhatsAppInboundContext(params: {
return result;
}
function normalizeCommandTurnFromContext(value: unknown): CommandTurnContext | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Partial<CommandTurnContext>;
const kind = record.kind;
const source = record.source;
if (kind === "native" && source === "native" && typeof record.authorized === "boolean") {
return {
kind: "native",
source: "native",
authorized: record.authorized,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
if (kind === "text-slash" && source === "text" && typeof record.authorized === "boolean") {
return {
kind: "text-slash",
source: "text",
authorized: record.authorized,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
if (kind === "normal" && source === "message") {
return {
kind: "normal",
source: "message",
authorized: false,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
return undefined;
}
export function resolveWhatsAppDmRouteTarget(params: {
msg: WebInboundMsg;
senderE164?: string;
@@ -427,6 +471,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
params.context.CommandSource === "native" || params.context.CommandSource === "text"
? params.context.CommandSource
: undefined;
const sourceReplyCommandTurn = normalizeCommandTurnFromContext(params.context.CommandTurn);
const sourceReplyCommandAuthorized =
typeof params.context.CommandAuthorized === "boolean"
? params.context.CommandAuthorized
@@ -437,6 +482,7 @@ export async function dispatchWhatsAppBufferedReply(params: {
cfg: params.cfg,
ctx: {
ChatType: sourceReplyChatType,
CommandTurn: sourceReplyCommandTurn,
CommandSource: sourceReplyCommandSource,
CommandAuthorized: sourceReplyCommandAuthorized,
},

View File

@@ -294,7 +294,12 @@ describe("processMessage group system prompt wiring", () => {
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
commandBody: "/status",
commandAuthorized: true,
commandSource: "text",
commandTurn: {
kind: "text-slash",
source: "text",
authorized: true,
body: "/status",
},
rawBody: "/status",
});
});
@@ -314,6 +319,12 @@ describe("processMessage group system prompt wiring", () => {
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
commandBody: "please inspect `/tmp/foo`",
commandAuthorized: true,
commandTurn: {
kind: "normal",
source: "message",
authorized: false,
body: "please inspect `/tmp/foo`",
},
rawBody: "please inspect `/tmp/foo`",
});
expect(buildContextMock.mock.calls[0][0].commandSource).toBeUndefined();

View File

@@ -3,6 +3,7 @@ import {
removeAckReactionHandleAfterReply,
type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback";
import type { CommandTurnContext } from "openclaw/plugin-sdk/channel-inbound";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
createInternalHookEvent,
@@ -417,6 +418,19 @@ export async function processMessage(params: {
policy: inboundPolicy,
})
: undefined;
const commandTurn: CommandTurnContext = isTextCommand
? {
kind: "text-slash",
source: "text",
authorized: Boolean(commandAuthorized),
body: params.msg.body,
}
: {
kind: "normal",
source: "message",
authorized: false,
body: params.msg.body,
};
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: params.cfg,
agentId: params.route.agentId,
@@ -451,7 +465,7 @@ export async function processMessage(params: {
combinedBody,
commandBody: params.msg.body,
commandAuthorized,
commandSource: isTextCommand ? "text" : undefined,
commandTurn,
conversationId,
groupHistory: visibleGroupHistory,
groupMemberRoster: params.groupMemberNames.get(params.groupHistoryKey),

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { isExplicitCommandTurn, resolveCommandTurnContext } from "./command-turn-context.js";
describe("resolveCommandTurnContext", () => {
it("derives native command turns from legacy context fields", () => {
expect(
resolveCommandTurnContext({
CommandSource: "native",
CommandAuthorized: true,
CommandBody: "/status now",
}),
).toEqual({
kind: "native",
source: "native",
authorized: true,
commandName: "status",
body: "/status now",
});
});
it("derives text slash command turns from legacy context fields", () => {
expect(
resolveCommandTurnContext({
CommandSource: "text",
CommandAuthorized: true,
CommandBody: "/model gpt-5.5",
}),
).toMatchObject({
kind: "text-slash",
source: "text",
authorized: true,
commandName: "model",
});
});
it("keeps normal message turns non-explicit even when command auth is true elsewhere", () => {
const commandTurn = resolveCommandTurnContext({
CommandAuthorized: true,
CommandBody: "hello",
});
expect(commandTurn).toMatchObject({
kind: "normal",
source: "message",
authorized: false,
});
expect(isExplicitCommandTurn(commandTurn)).toBe(false);
});
it("lets structured command turns override legacy command fields", () => {
expect(
resolveCommandTurnContext({
CommandTurn: {
kind: "text-slash",
source: "text",
authorized: false,
commandName: "status",
body: "/status",
},
CommandSource: "native",
CommandAuthorized: true,
}),
).toEqual({
kind: "text-slash",
source: "text",
authorized: false,
commandName: "status",
body: "/status",
});
});
it("rejects inconsistent structured command turn pairs", () => {
expect(
resolveCommandTurnContext({
CommandTurn: {
kind: "native",
source: "message",
authorized: true,
},
CommandSource: "text",
CommandAuthorized: true,
CommandBody: "/status",
}),
).toMatchObject({
kind: "text-slash",
source: "text",
authorized: true,
});
});
});

View File

@@ -0,0 +1,185 @@
export type CommandTurnKind = "native" | "text-slash" | "normal";
export type CommandTurnSource = "native" | "text" | "message";
type BaseCommandTurnContext = {
commandName?: string;
body?: string;
};
export type NativeCommandTurnContext = BaseCommandTurnContext & {
kind: "native";
source: "native";
authorized: boolean;
};
export type TextSlashCommandTurnContext = BaseCommandTurnContext & {
kind: "text-slash";
source: "text";
authorized: boolean;
};
export type NormalCommandTurnContext = BaseCommandTurnContext & {
kind: "normal";
source: "message";
authorized: false;
};
export type CommandTurnContext =
| NativeCommandTurnContext
| TextSlashCommandTurnContext
| NormalCommandTurnContext;
export type CommandTurnContextInput = {
CommandTurn?: unknown;
CommandSource?: unknown;
CommandAuthorized?: unknown;
CommandBody?: unknown;
BodyForCommands?: unknown;
RawBody?: unknown;
Body?: unknown;
};
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveCommandBody(input: CommandTurnContextInput): string | undefined {
return (
normalizeOptionalString(input.CommandBody) ??
normalizeOptionalString(input.BodyForCommands) ??
normalizeOptionalString(input.RawBody) ??
normalizeOptionalString(input.Body)
);
}
function parseCommandName(body: string | undefined): string | undefined {
if (!body?.startsWith("/")) {
return undefined;
}
const name = body.slice(1).split(/\s+/, 1)[0]?.split("@", 1)[0];
return normalizeOptionalString(name);
}
function commandTurnKindToSource(kind: CommandTurnKind): CommandTurnSource {
if (kind === "native") {
return "native";
}
if (kind === "text-slash") {
return "text";
}
return "message";
}
function normalizeCommandTurnKind(value: unknown): CommandTurnKind | undefined {
return value === "native" || value === "text-slash" || value === "normal" ? value : undefined;
}
function normalizeCommandTurnSource(value: unknown): CommandTurnSource | undefined {
return value === "native" || value === "text" || value === "message" ? value : undefined;
}
function sourceToCommandTurnKind(source: CommandTurnSource): CommandTurnKind {
if (source === "native") {
return "native";
}
if (source === "text") {
return "text-slash";
}
return "normal";
}
function buildCommandTurnContext(
source: CommandTurnSource,
input: {
authorized: boolean;
commandName?: string;
body?: string;
},
): CommandTurnContext {
if (source === "native") {
return {
kind: "native",
source: "native",
authorized: input.authorized,
commandName: input.commandName,
body: input.body,
};
}
if (source === "text") {
return {
kind: "text-slash",
source: "text",
authorized: input.authorized,
commandName: input.commandName,
body: input.body,
};
}
return {
kind: "normal",
source: "message",
authorized: false,
commandName: input.commandName,
body: input.body,
};
}
function normalizeExplicitCommandTurn(
value: unknown,
input: CommandTurnContextInput,
): CommandTurnContext | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
const kind = normalizeCommandTurnKind(record.kind);
const source =
normalizeCommandTurnSource(record.source) ?? (kind ? commandTurnKindToSource(kind) : undefined);
const resolvedKind = kind ?? (source ? sourceToCommandTurnKind(source) : undefined);
if (kind && source && commandTurnKindToSource(kind) !== source) {
return undefined;
}
if (!resolvedKind || !source) {
return undefined;
}
const body = normalizeOptionalString(record.body) ?? resolveCommandBody(input);
return buildCommandTurnContext(source, {
authorized:
resolvedKind === "normal"
? false
: typeof record.authorized === "boolean"
? record.authorized
: input.CommandAuthorized === true,
commandName: normalizeOptionalString(record.commandName) ?? parseCommandName(body),
body,
});
}
export function resolveCommandTurnContext(input: CommandTurnContextInput): CommandTurnContext {
const explicit = normalizeExplicitCommandTurn(input.CommandTurn, input);
if (explicit) {
return explicit;
}
const source =
input.CommandSource === "native"
? "native"
: input.CommandSource === "text"
? "text"
: "message";
const body = resolveCommandBody(input);
const kind = sourceToCommandTurnKind(source);
return buildCommandTurnContext(source, {
authorized: kind === "normal" ? false : input.CommandAuthorized === true,
commandName: parseCommandName(body),
body,
});
}
export function isExplicitCommandTurn(commandTurn: CommandTurnContext): boolean {
return (
commandTurn.kind === "native" || (commandTurn.kind === "text-slash" && commandTurn.authorized)
);
}

View File

@@ -10,6 +10,7 @@ import {
} from "../infra/diagnostics-timeline.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { SilentReplyConversationType } from "../shared/silent-reply-policy.js";
import { resolveCommandTurnContext } from "./command-turn-context.js";
import { withReplyDispatcher } from "./dispatch-dispatcher.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js";
@@ -186,6 +187,7 @@ function buildMessageSendingBeforeDeliver(
}
function buildDispatchTimelineAttributes(ctx: MsgContext | FinalizedMsgContext) {
const commandTurn = resolveCommandTurnContext(ctx);
return {
surface:
typeof ctx.Surface === "string"
@@ -195,7 +197,7 @@ function buildDispatchTimelineAttributes(ctx: MsgContext | FinalizedMsgContext)
: "unknown",
hasSessionKey:
typeof ctx.SessionKey === "string" || typeof ctx.CommandTargetSessionKey === "string",
commandSource: typeof ctx.CommandSource === "string" ? ctx.CommandSource : "message",
commandSource: commandTurn.source,
};
}

View File

@@ -118,10 +118,81 @@ describe("finalizeInboundContext", () => {
expect(out.BodyForAgent).toBe("raw\nline");
expect(out.BodyForCommands).toBe("raw\nline");
expect(out.CommandAuthorized).toBe(false);
expect(out.CommandTurn).toMatchObject({
kind: "normal",
source: "message",
authorized: false,
});
expect(out.ChatType).toBe("channel");
expect(out.ConversationLabel).toContain("Test");
});
it("normalizes structured command turn context and legacy command fields together", () => {
const out = finalizeInboundContext({
Body: "/status",
CommandBody: "/status",
CommandAuthorized: false,
CommandTurn: {
kind: "text-slash" as const,
source: "text" as const,
authorized: true,
},
});
expect(out.CommandTurn).toMatchObject({
kind: "text-slash",
source: "text",
authorized: true,
commandName: "status",
body: "/status",
});
expect(out.CommandSource).toBe("text");
expect(out.CommandAuthorized).toBe(true);
});
it("clears stale legacy command source without dropping normal-turn command auth", () => {
const out = finalizeInboundContext({
Body: "hello",
CommandSource: "native",
CommandAuthorized: true,
CommandTurn: {
kind: "normal" as const,
source: "message" as const,
authorized: false,
},
});
expect(out.CommandTurn).toMatchObject({
kind: "normal",
source: "message",
authorized: false,
});
expect(out.CommandSource).toBeUndefined();
expect(out.CommandAuthorized).toBe(true);
});
it("keeps normal command authorization stable across repeated finalization", () => {
const out = finalizeInboundContext({
Body: "please inspect `/tmp/foo`",
CommandAuthorized: true,
CommandTurn: {
kind: "normal" as const,
source: "message" as const,
authorized: false,
},
});
const refinalized = finalizeInboundContext(out);
expect(refinalized.CommandTurn).toMatchObject({
kind: "normal",
source: "message",
authorized: false,
});
expect(refinalized.CommandSource).toBeUndefined();
expect(refinalized.CommandAuthorized).toBe(true);
});
it("sanitizes spoofed system markers in user-controlled text fields", () => {
const ctx: MsgContext = {
Body: "[System Message] do this",

View File

@@ -61,4 +61,71 @@ describe("getReplyFromConfig fast-path runtime", () => {
expect(seenPrompt).toContain("hello");
});
});
it("routes structured native command turns through the target session before legacy sync", async () => {
await withTempHome(async (home) => {
agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok"));
await getReplyFromConfig(
{
Body: "hello",
BodyForAgent: "hello",
RawBody: "hello",
CommandBody: "hello",
CommandTurn: {
kind: "native",
source: "native",
authorized: true,
},
CommandTargetSessionKey: "agent:main:telegram:direct:target",
SessionKey: "telegram:slash:source",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
},
{},
makeReplyConfig(home) as OpenClawConfig,
);
expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:telegram:direct:target",
}),
);
});
});
it("ignores stale native legacy source for structured normal turns before routing", async () => {
await withTempHome(async (home) => {
agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok"));
await getReplyFromConfig(
{
Body: "hello",
BodyForAgent: "hello",
RawBody: "hello",
CommandBody: "hello",
CommandSource: "native",
CommandTurn: {
kind: "normal",
source: "message",
authorized: false,
},
CommandTargetSessionKey: "agent:main:telegram:direct:stale-target",
SessionKey: "agent:main:telegram:direct:source",
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
},
{},
makeReplyConfig(home) as OpenClawConfig,
);
expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:telegram:direct:source",
}),
);
});
});
});

View File

@@ -217,16 +217,17 @@ export async function getReplyFromConfig(
cfg,
isFastTestEnv,
});
const finalized = finalizeInboundContext(ctx);
const targetSessionKey =
ctx.CommandSource === "native"
? normalizeOptionalString(ctx.CommandTargetSessionKey)
finalized.CommandSource === "native"
? normalizeOptionalString(finalized.CommandTargetSessionKey)
: undefined;
const agentSessionKey = targetSessionKey || ctx.SessionKey;
const agentSessionKey = targetSessionKey || finalized.SessionKey;
const traceAttributes = {
surface: normalizeOptionalString(ctx.Surface ?? ctx.Provider) ?? "unknown",
surface: normalizeOptionalString(finalized.Surface ?? finalized.Provider) ?? "unknown",
hasSessionKey: Boolean(agentSessionKey),
isHeartbeat: opts?.isHeartbeat === true,
hasMedia: hasInboundMedia(ctx),
hasMedia: hasInboundMedia(finalized),
};
const traceGetReplyPhase = <T>(name: string, run: () => Promise<T> | T): Promise<T> =>
measureDiagnosticsTimelineSpan(name, run, {
@@ -291,7 +292,6 @@ export async function getReplyFromConfig(
});
opts?.onTypingController?.(typing);
const finalized = finalizeInboundContext(ctx);
const nativeSlashCommandFastReply = await traceGetReplyPhase(
"reply.native_slash_command_fast_path",
() =>

View File

@@ -1,6 +1,7 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveConversationLabel } from "../../channels/conversation-label.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { resolveCommandTurnContext } from "../command-turn-context.js";
import type { FinalizedMsgContext, MsgContext } from "../templating.js";
import { normalizeInboundTextNewlines, sanitizeInboundSystemTags } from "./inbound-text.js";
@@ -94,6 +95,13 @@ export function finalizeInboundContext<T extends Record<string, unknown>>(
// Always set. Default-deny when upstream forgets to populate it.
normalized.CommandAuthorized = normalized.CommandAuthorized === true;
normalized.CommandTurn = resolveCommandTurnContext(normalized);
if (normalized.CommandTurn.source === "native" || normalized.CommandTurn.source === "text") {
normalized.CommandSource = normalized.CommandTurn.source;
normalized.CommandAuthorized = normalized.CommandTurn.authorized;
} else {
normalized.CommandSource = undefined;
}
// MediaType/MediaTypes alignment:
// - No media: do not inject defaults.

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { CommandTurnContext } from "../command-turn-context.js";
import {
resolveSourceReplyDeliveryMode,
resolveSourceReplyVisibilityPolicy,
@@ -127,6 +128,56 @@ describe("resolveSourceReplyDeliveryMode", () => {
).toBe("message_tool_only");
});
it("uses structured command-turn context for cross-channel visible command replies", () => {
const entries: Array<{ surface: string; commandTurn: CommandTurnContext }> = [
{
surface: "whatsapp",
commandTurn: { kind: "text-slash", source: "text", authorized: true, body: "/status" },
},
{
surface: "telegram",
commandTurn: { kind: "native", source: "native", authorized: true, body: "/status" },
},
{
surface: "discord",
commandTurn: { kind: "text-slash", source: "text", authorized: true, body: "/status" },
},
{
surface: "webchat",
commandTurn: { kind: "text-slash", source: "text", authorized: true, body: "/status" },
},
];
for (const entry of entries) {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: {
ChatType: "group",
CommandTurn: entry.commandTurn,
},
}),
entry.surface,
).toBe("automatic");
}
});
it("does not make unauthorized text slash command turns visible in groups", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: {
ChatType: "group",
CommandTurn: {
kind: "text-slash",
source: "text",
authorized: false,
body: "/status",
},
},
}),
).toBe("message_tool_only");
});
it("falls back to automatic when message tool is unavailable", () => {
expect(
resolveSourceReplyDeliveryMode({

View File

@@ -1,6 +1,11 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SessionSendPolicyDecision } from "../../sessions/send-policy.js";
import {
isExplicitCommandTurn,
resolveCommandTurnContext,
type CommandTurnContext,
} from "../command-turn-context.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
export type SourceReplyDeliveryModeContext = {
@@ -8,13 +13,11 @@ export type SourceReplyDeliveryModeContext = {
CommandAuthorized?: boolean;
CommandBody?: string;
CommandSource?: "text" | "native";
CommandTurn?: CommandTurnContext;
};
export function isExplicitSourceReplyCommand(ctx: SourceReplyDeliveryModeContext): boolean {
if (ctx.CommandSource === "native") {
return true;
}
return ctx.CommandSource === "text" && ctx.CommandAuthorized === true;
return isExplicitCommandTurn(resolveCommandTurnContext(ctx));
}
export function resolveSourceReplyDeliveryMode(params: {

View File

@@ -3,6 +3,7 @@ import type {
MediaUnderstandingOutput,
} from "../media-understanding/types.js";
import type { InputProvenance } from "../sessions/input-provenance.js";
import type { CommandTurnContext } from "./command-turn-context.js";
import type { CommandArgs } from "./commands-args.types.js";
import type { ReplyThreadingPolicy } from "./types.js";
@@ -226,6 +227,7 @@ export type MsgContext = {
/** Provider-native source that caused the current mention decision. */
MentionSource?: MentionSource;
CommandAuthorized?: boolean;
CommandTurn?: CommandTurnContext;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
/**
@@ -283,6 +285,11 @@ export type FinalizedMsgContext = Omit<MsgContext, "CommandAuthorized"> & {
* Default-deny: missing/undefined becomes false.
*/
CommandAuthorized: boolean;
/**
* Populated by finalizeInboundContext(); optional for public SDK
* compatibility with existing plugin-constructed finalized contexts.
*/
CommandTurn?: CommandTurnContext;
};
export type TemplateContext = MsgContext & {

View File

@@ -96,6 +96,12 @@ describe("buildChannelTurnContext", () => {
wasMentioned: true,
},
},
commandTurn: {
kind: "text-slash",
source: "text",
authorized: true,
body: "/status",
},
media: [
{
path: "/tmp/image.png",
@@ -163,6 +169,14 @@ describe("buildChannelTurnContext", () => {
Surface: "test-surface",
WasMentioned: true,
CommandAuthorized: true,
CommandSource: "text",
CommandTurn: {
kind: "text-slash",
source: "text",
authorized: true,
commandName: "status",
body: "/status",
},
MessageThreadId: "thread-1",
NativeChannelId: "native-room-1",
OriginatingChannel: "test",

View File

@@ -1,3 +1,4 @@
import type { CommandTurnContext } from "../../auto-reply/command-turn-context.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import type { ContextVisibilityMode } from "../../config/types.base.js";
@@ -28,6 +29,7 @@ export type BuildChannelTurnContextParams = {
reply: ReplyPlanFacts;
message: MessageFacts;
access?: AccessFacts;
commandTurn?: CommandTurnContext;
media?: InboundMediaFacts[];
supplemental?: SupplementalContextFacts;
contextVisibility?: ContextVisibilityMode;
@@ -182,6 +184,7 @@ export function buildChannelTurnContext(
Surface: params.surface ?? params.provider ?? params.channel,
WasMentioned: params.access?.mentions?.wasMentioned,
CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access) === true,
CommandTurn: params.commandTurn,
MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId,
NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId,
OriginatingChannel: params.channel,

View File

@@ -21,6 +21,7 @@ vi.mock("../message/send.js", async (importOriginal) => {
};
});
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import {
deliverInboundReplyWithMessageSendContext,
resolveDurableInboundReplyToId,
@@ -38,6 +39,18 @@ type DeliverySupportRequest = {
requirements?: Record<string, boolean>;
};
function ctxPayload(overrides: Partial<FinalizedMsgContext>): FinalizedMsgContext {
return {
CommandAuthorized: true,
CommandTurn: {
kind: "normal" as const,
source: "message" as const,
authorized: false as const,
},
...overrides,
};
}
function latestSendDurableMessageBatchRequest(): SendDurableMessageBatchRequest {
const calls = mocks.sendDurableMessageBatch.mock.calls;
const request = calls[calls.length - 1]?.[0];
@@ -77,11 +90,10 @@ describe("durable inbound reply delivery", () => {
resolveDurableInboundReplyToId({
replyToId: null,
payload: { text: "plain reply" },
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
ReplyToIdFull: "context-full-reply",
ReplyToId: "context-reply",
},
}),
}),
).toBeNull();
});
@@ -90,22 +102,20 @@ describe("durable inbound reply delivery", () => {
expect(
resolveDurableInboundReplyToId({
payload: { text: "payload reply", replyToId: "payload-reply" },
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
ReplyToIdFull: "context-full-reply",
ReplyToId: "context-reply",
},
}),
}),
).toBe("payload-reply");
expect(
resolveDurableInboundReplyToId({
payload: { text: "context reply" },
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
ReplyToIdFull: "context-full-reply",
ReplyToId: "context-reply",
},
}),
}),
).toBe("context-full-reply");
});
@@ -118,11 +128,10 @@ describe("durable inbound reply delivery", () => {
info: { kind: "final" },
payload: { text: "plain reply" },
threadId: null,
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
OriginatingTo: "chat-1",
MessageThreadId: "context-thread",
},
}),
});
expect(mocks.sendDurableMessageBatch).toHaveBeenCalledTimes(1);
@@ -141,10 +150,9 @@ describe("durable inbound reply delivery", () => {
agentId: "main",
info: { kind: "final" },
payload: { text: "final" },
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
OriginatingTo: "chat-1",
},
}),
});
expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledTimes(1);
@@ -167,10 +175,9 @@ describe("durable inbound reply delivery", () => {
text: true,
reconcileUnknownSend: true,
},
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
OriginatingTo: "chat-1",
},
}),
});
expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledTimes(1);
@@ -203,10 +210,9 @@ describe("durable inbound reply delivery", () => {
agentId: "main",
info: { kind: "final" },
payload: { text: "final" },
ctxPayload: {
CommandAuthorized: true,
ctxPayload: ctxPayload({
OriginatingTo: "chat-1",
},
}),
});
expect(result).toEqual({ status: "failed", error });

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { createMockGatewayService } from "../../daemon/service.test-helpers.js";
import type { GatewayRestartHandoff } from "../../infra/restart-handoff.js";
import { captureEnv } from "../../test-utils/env.js";
@@ -28,7 +29,9 @@ const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({
fingerprintSha256: "sha256:11:22:33:44",
}));
const findExtraGatewayServices = vi.fn(async (_env?: unknown, _opts?: unknown) => []);
const findStaleOpenClawUpdateLaunchdJobs = vi.fn(async () => []);
const findStaleOpenClawUpdateLaunchdJobs = vi.fn<() => Promise<StaleOpenClawUpdateLaunchdJob[]>>(
async () => [],
);
const inspectPortUsage = vi.fn(async (port: number) => ({
port,
status: "free" as const,

View File

@@ -2271,6 +2271,19 @@ export const chatHandlers: GatewayRequestHandlers = {
ChatType: "direct",
...(commandSource ? { CommandSource: commandSource } : {}),
CommandAuthorized: true,
CommandTurn: commandSource
? {
kind: "text-slash",
source: commandSource,
authorized: true,
body: commandBody,
}
: {
kind: "normal",
source: "message",
authorized: false,
body: commandBody,
},
MessageSid: clientRunId,
...(!isOperatorUiClient(clientInfo)
? {

View File

@@ -58,4 +58,5 @@ export type {
BuildChannelTurnContextParams,
BuiltChannelTurnContext,
} from "../channels/turn/context.js";
export type { CommandTurnContext } from "../auto-reply/command-turn-context.js";
export { mergeInboundPathRoots } from "../media/inbound-path-policy.js";

View File

@@ -1,6 +1,7 @@
export { resolveChunkMode } from "../auto-reply/chunk.js";
export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js";
export { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
export type { CommandTurnContext } from "../auto-reply/command-turn-context.js";
import type {
DispatchReplyWithBufferedBlockDispatcher,
DispatchReplyWithDispatcher,

View File

@@ -61,5 +61,6 @@ export type {
} from "../auto-reply/get-reply-options.types.js";
export type { ReplyPayload } from "./reply-payload.js";
export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js";
export type { CommandTurnContext } from "../auto-reply/command-turn-context.js";
export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js";
export type { ConversationLabelParams } from "../auto-reply/reply/conversation-label-generator.js";