fix: propagate room event tool context

This commit is contained in:
Peter Steinberger
2026-05-15 16:02:34 +01:00
parent 4b11d65ada
commit ddcfde1489
10 changed files with 188 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ import {
createSequencedTestDraftStream,
createTestDraftStream,
} from "./draft-stream.test-helpers.js";
import { notifyTelegramInboundTurnOutboundSuccess } from "./inbound-turn-delivery.js";
type DispatchReplyWithBufferedBlockDispatcherArgs = Parameters<
TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"]
@@ -1641,6 +1642,87 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(groupHistories.get(historyKey)).toHaveLength(1);
});
it("clears delivered room-event history when a newer turn supersedes dispatch", async () => {
const historyKey = "telegram:group:-100123";
const groupHistories = new Map([
[historyKey, [{ sender: "Alice", body: "lunch at two", timestamp: 1 }]],
]);
let firstStarted: (() => void) | undefined;
const firstStartGate = new Promise<void>((resolve) => {
firstStarted = resolve;
});
let releaseFirst: (() => void) | undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
let secondStarted: (() => void) | undefined;
const secondStartGate = new Promise<void>((resolve) => {
secondStarted = resolve;
});
dispatchReplyWithBufferedBlockDispatcher
.mockImplementationOnce(async () => {
firstStarted?.();
await firstGate;
return {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
sourceReplyDeliveryMode: "message_tool_only",
};
})
.mockImplementationOnce(async () => {
secondStarted?.();
return {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
sourceReplyDeliveryMode: "message_tool_only",
};
});
const createRoomContext = (messageId: number, body: string) =>
createContext({
ctxPayload: {
InboundTurnKind: "room_event",
SessionKey: "agent:main:telegram:group:-100123",
ChatType: "group",
MessageSid: String(messageId),
RawBody: body,
BodyForAgent: body,
CommandBody: body,
} as unknown as TelegramMessageContext["ctxPayload"],
msg: {
chat: { id: -100123, type: "supergroup" },
message_id: messageId,
} as unknown as TelegramMessageContext["msg"],
chatId: -100123,
isGroup: true,
historyKey,
historyLimit: 10,
groupHistories,
threadSpec: { id: undefined, scope: "none" },
});
const firstPromise = dispatchWithContext({
context: createRoomContext(99, "ambient one"),
streamMode: "partial",
});
await firstStartGate;
notifyTelegramInboundTurnOutboundSuccess({
sessionKey: "agent:main:telegram:group:-100123",
to: "telegram:-100123",
inboundTurnKind: "room_event",
});
const secondPromise = dispatchWithContext({
context: createRoomContext(100, "ambient two"),
streamMode: "partial",
});
await secondStartGate;
releaseFirst?.();
await Promise.all([firstPromise, secondPromise]);
expect(groupHistories.get(historyKey)).toHaveLength(0);
});
it("does not let room events supersede active user-request dispatch", async () => {
const historyKey = "telegram:group:-100123";
const groupHistories = new Map([[historyKey, []]]);

View File

@@ -1681,7 +1681,7 @@ export const dispatchTelegramMessage = async ({
},
});
}
if (!isRoomEvent) {
if (!isRoomEvent || deliveryState.snapshot().delivered) {
clearGroupHistory();
}
return;

View File

@@ -66,6 +66,7 @@ function createTestMcpLoopbackServerConfig(port: number) {
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-inbound-turn-kind": "${OPENCLAW_MCP_INBOUND_TURN_KIND}",
},
},
},
@@ -780,6 +781,64 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("passes current turn kind into bundle MCP loopback env", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "owner-token",
nonOwnerToken: "non-owner-token",
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
});
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "native-cli",
pluginId: "native-plugin",
bundleMcp: true,
bundleMcpMode: "codex-config-overrides",
config: {
command: "native-cli",
args: ["--print"],
output: "text",
input: "arg",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:group:chat123",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "native-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-room-event-tools",
config: createCliBackendConfig(),
currentTurnKind: "room_event",
messageChannel: "telegram",
});
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
OPENCLAW_MCP_INBOUND_TURN_KIND: "room_event",
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => {
const { dir, sessionFile } = createSessionFile();
try {

View File

@@ -226,6 +226,7 @@ export async function prepareCliRunContext(
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
OPENCLAW_MCP_INBOUND_TURN_KIND: params.currentTurnKind ?? "",
}
: undefined,
warn: (message) => cliBackendLog.warn(message),

View File

@@ -39,6 +39,7 @@ export function createMcpLoopbackServerConfig(port: number) {
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-inbound-turn-kind": "${OPENCLAW_MCP_INBOUND_TURN_KIND}",
},
},
},

View File

@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { InboundTurnKind } from "../channels/turn/kind.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -29,6 +30,7 @@ type McpRequestContext = {
sessionKey: string;
messageProvider: string | undefined;
accountId: string | undefined;
inboundTurnKind: InboundTurnKind | undefined;
senderIsOwner: boolean;
};
@@ -37,6 +39,11 @@ function resolveScopedSessionKey(cfg: OpenClawConfig, rawSessionKey: string | un
return !trimmed || trimmed === "main" ? resolveMainSessionKey(cfg) : trimmed;
}
function normalizeMcpInboundTurnKind(value: string | undefined): InboundTurnKind | undefined {
const trimmed = normalizeOptionalString(value);
return trimmed === "room_event" || trimmed === "user_request" ? trimmed : undefined;
}
function rejectsBrowserLoopbackRequest(req: IncomingMessage): boolean {
const origin = getHeader(req, "origin");
if (!origin) {
@@ -173,6 +180,7 @@ export function resolveMcpRequestContext(
messageProvider:
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined,
accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")),
inboundTurnKind: normalizeMcpInboundTurnKind(getHeader(req, "x-openclaw-inbound-turn-kind")),
senderIsOwner: auth.senderIsOwner,
};
}

View File

@@ -1,4 +1,5 @@
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
import type { InboundTurnKind } from "../channels/turn/kind.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
buildMcpToolSchema,
@@ -26,12 +27,14 @@ export class McpLoopbackToolCache {
sessionKey: string;
messageProvider: string | undefined;
accountId: string | undefined;
inboundTurnKind: InboundTurnKind | undefined;
senderIsOwner: boolean | undefined;
}): CachedScopedTools {
const cacheKey = [
params.sessionKey,
params.messageProvider ?? "",
params.accountId ?? "",
params.inboundTurnKind ?? "",
params.senderIsOwner === true ? "owner" : "non-owner",
].join("\u0000");
const now = Date.now();
@@ -45,6 +48,7 @@ export class McpLoopbackToolCache {
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
accountId: params.accountId,
inboundTurnKind: params.inboundTurnKind,
senderIsOwner: params.senderIsOwner,
surface: "loopback",
excludeToolNames: NATIVE_TOOL_EXCLUDE,

View File

@@ -154,7 +154,7 @@ afterEach(async () => {
});
describe("mcp loopback server", () => {
it("passes session, account, and message channel headers into shared tool resolution", async () => {
it("passes session, account, message channel, and inbound turn headers into shared tool resolution", async () => {
const port = await getFreePortBlockWithPermissionFallback({
offsets: [0],
fallbackBase: 53_000,
@@ -170,6 +170,7 @@ describe("mcp loopback server", () => {
"x-session-key": "agent:main:telegram:group:chat123",
"x-openclaw-account-id": "work",
"x-openclaw-message-channel": "telegram",
"x-openclaw-inbound-turn-kind": "room_event",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
@@ -179,6 +180,7 @@ describe("mcp loopback server", () => {
expect(call.sessionKey).toBe("agent:main:telegram:group:chat123");
expect(call.accountId).toBe("work");
expect(call.messageProvider).toBe("telegram");
expect(call.inboundTurnKind).toBe("room_event");
expect(call.senderIsOwner).toBe(false);
expect(call.surface).toBe("loopback");
expect(Array.from(call.excludeToolNames ?? [])).toEqual([
@@ -191,6 +193,30 @@ describe("mcp loopback server", () => {
]);
});
it("keeps loopback tool cache entries separate by inbound turn kind", async () => {
server = await startMcpLoopbackServer(0);
const runtime = getActiveMcpLoopbackRuntime();
const sendToolsList = async (inboundTurnKind: string) =>
await sendRaw({
port: server?.port ?? 0,
token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined,
headers: {
"content-type": "application/json",
"x-session-key": "agent:main:telegram:group:chat123",
"x-openclaw-message-channel": "telegram",
"x-openclaw-inbound-turn-kind": inboundTurnKind,
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
expect((await sendToolsList("user_request")).status).toBe(200);
expect((await sendToolsList("room_event")).status).toBe(200);
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(2);
expect(getScopedToolsCall(0).inboundTurnKind).toBe("user_request");
expect(getScopedToolsCall(1).inboundTurnKind).toBe("room_event");
});
it("adds empty properties for object schemas that omit properties", async () => {
resolveGatewayScopedToolsMock.mockReturnValue({
agentId: "main",

View File

@@ -110,6 +110,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
sessionKey: requestContext.sessionKey,
messageProvider: requestContext.messageProvider,
accountId: requestContext.accountId,
inboundTurnKind: requestContext.inboundTurnKind,
senderIsOwner: requestContext.senderIsOwner,
});
@@ -118,6 +119,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
batchSize: messages.length,
methods: messages.map((message) => message.method),
sessionKey: requestContext.sessionKey,
inboundTurnKind: requestContext.inboundTurnKind,
senderIsOwner: requestContext.senderIsOwner,
toolCount: scopedTools.toolSchema.length,
cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"),

View File

@@ -23,6 +23,7 @@ import {
resolveToolProfilePolicy,
} from "../agents/tool-policy.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { InboundTurnKind } from "../channels/turn/kind.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
@@ -35,6 +36,7 @@ export function resolveGatewayScopedTools(params: {
sessionKey: string;
messageProvider?: string;
accountId?: string;
inboundTurnKind?: InboundTurnKind;
agentTo?: string;
agentThreadId?: string;
allowGatewaySubagentBinding?: boolean;
@@ -133,6 +135,7 @@ export function resolveGatewayScopedTools(params: {
agentSessionKey: params.sessionKey,
agentChannel: params.messageProvider ?? undefined,
agentAccountId: params.accountId,
inboundTurnKind: params.inboundTurnKind,
agentTo: params.agentTo,
agentThreadId: params.agentThreadId,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,