Skip to content

Commit 05db911

Browse files
authored
fix(outbound): thread session keys into outbound hooks (#73706)
Thread the canonical outbound session key into plugin message_sending and message_sent hook contexts, and align native command redirect routed delivery with the agent runtime session key. This lets plugins correlate agent_end with outbound delivery hooks without seeing missing or divergent session keys. Verification: - gh pr checks 73706 --repo openclaw/openclaw --watch=false - Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/26526635074/job/78131933497 Thanks @zeroaltitude. Co-authored-by: Edward Abrams <zeroaltitude@gmail.com>
1 parent c9151ba commit 05db911

7 files changed

Lines changed: 563 additions & 3 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Real-runtime behavior proof for #73706.
3+
*
4+
* This script does NOT use vitest mocks. It wires up the production
5+
* `deliverOutboundPayloads` path against:
6+
* - a real `PluginRegistry` populated with one real channel plugin and
7+
* two real plugin hooks (`message_sending`, `message_sent`)
8+
* - the real `getGlobalHookRunner()` / `initializeGlobalHookRunner()`
9+
* singleton path (no fake hook runner)
10+
* - the real `setActivePluginRegistry` channel resolution path (no
11+
* fake channel adapter)
12+
*
13+
* It then exercises three scenarios:
14+
*
15+
* 1. Direct outbound delivery with `session.key` set: confirms the
16+
* `message_sending` and `message_sent` hook contexts both receive
17+
* the canonical `sessionKey`.
18+
*
19+
* 2. Direct outbound delivery with NO session: confirms `sessionKey`
20+
* is absent from both hook contexts (the "narrowed" docs branch).
21+
*
22+
* 3. Native-redirect simulation: outbound delivery whose `session.key`
23+
* is set to the redirect TARGET session (i.e., what the agent
24+
* runtime resolves as `params.sessionKey` when
25+
* `CommandTargetSessionKey` is set and `CommandSource === "native"`,
26+
* and what `dispatch-from-config.ts` now passes through to
27+
* `routeReply`). Confirms `message_sending` / `message_sent`
28+
* observe the redirect-target session, NOT the inbound session.
29+
* This is the runtime invariant Clawsweeper asked us to pin
30+
* with a regression test.
31+
*
32+
* Run with:
33+
* pnpm tsx scripts/proof-73706-message-sending-session-key.ts
34+
*/
35+
36+
import { deliverOutboundPayloads } from "../src/infra/outbound/deliver.js";
37+
import type {
38+
PluginHookMessageContext,
39+
PluginHookMessageReceivedEvent,
40+
} from "../src/plugins/hook-message.types.js";
41+
import { initializeGlobalHookRunner } from "../src/plugins/hook-runner-global.js";
42+
import { addTestHook, createMockPluginRegistry } from "../src/plugins/hooks.test-helpers.js";
43+
import type { PluginRegistry } from "../src/plugins/registry.js";
44+
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
45+
import { createOutboundTestPlugin, createTestRegistry } from "../src/test-utils/channel-plugins.js";
46+
47+
type CapturedContext = {
48+
hook: "message_sending" | "message_sent";
49+
ctx: PluginHookMessageContext;
50+
event: PluginHookMessageReceivedEvent;
51+
};
52+
53+
function buildRegistry(captured: CapturedContext[], channelId: "matrix"): PluginRegistry {
54+
// Real outbound channel plugin: returns a synthetic delivery result
55+
// without touching any network. This drives `deliverOutboundPayloads`
56+
// through its real channel-resolution + sendText path.
57+
const sendText = async () => ({
58+
channel: channelId,
59+
messageId: `mx-${Date.now()}`,
60+
roomId: "!room:example",
61+
});
62+
63+
const channelRegistry = createTestRegistry([
64+
{
65+
pluginId: channelId,
66+
source: "proof",
67+
plugin: createOutboundTestPlugin({
68+
id: channelId,
69+
outbound: { deliveryMode: "direct", sendText },
70+
}),
71+
},
72+
]);
73+
74+
// Real hook handlers: capture exactly what delivery hands to plugins.
75+
const hookRegistry = createMockPluginRegistry([]);
76+
addTestHook({
77+
registry: hookRegistry,
78+
pluginId: "proof-message-sending",
79+
hookName: "message_sending",
80+
handler: async (event: unknown, ctx: unknown) => {
81+
captured.push({
82+
hook: "message_sending",
83+
ctx: ctx as PluginHookMessageContext,
84+
event: event as PluginHookMessageReceivedEvent,
85+
});
86+
// Returning undefined means "do not modify or cancel".
87+
return undefined;
88+
},
89+
});
90+
addTestHook({
91+
registry: hookRegistry,
92+
pluginId: "proof-message-sent",
93+
hookName: "message_sent",
94+
handler: async (event: unknown, ctx: unknown) => {
95+
captured.push({
96+
hook: "message_sent",
97+
ctx: ctx as PluginHookMessageContext,
98+
event: event as PluginHookMessageReceivedEvent,
99+
});
100+
},
101+
});
102+
103+
return {
104+
...channelRegistry,
105+
hooks: hookRegistry.hooks,
106+
typedHooks: hookRegistry.typedHooks,
107+
plugins: hookRegistry.plugins,
108+
};
109+
}
110+
111+
async function runScenario(
112+
label: string,
113+
opts: { sessionKey?: string },
114+
): Promise<CapturedContext[]> {
115+
const captured: CapturedContext[] = [];
116+
const registry = buildRegistry(captured, "matrix");
117+
setActivePluginRegistry(registry);
118+
initializeGlobalHookRunner(registry);
119+
120+
const result = await deliverOutboundPayloads({
121+
cfg: {},
122+
channel: "matrix",
123+
to: "!room:example",
124+
payloads: [{ text: `proof: ${label}` }],
125+
skipQueue: true,
126+
...(opts.sessionKey ? { session: { key: opts.sessionKey } } : {}),
127+
});
128+
129+
console.log(`\n=== Scenario: ${label} ===`);
130+
console.log(`deliverOutboundPayloads result:`, JSON.stringify(result));
131+
for (const entry of captured) {
132+
console.log(
133+
`[${entry.hook}] ctx.sessionKey = ${
134+
entry.ctx.sessionKey === undefined ? "(undefined)" : JSON.stringify(entry.ctx.sessionKey)
135+
}`,
136+
);
137+
console.log(`[${entry.hook}] full ctx = ${JSON.stringify(entry.ctx)}`);
138+
}
139+
140+
return captured;
141+
}
142+
143+
async function main() {
144+
console.log("[proof-73706] Real-runtime behavior proof for outbound session-key threading.");
145+
console.log(
146+
"[proof-73706] Production code paths: deliverOutboundPayloads + getGlobalHookRunner.",
147+
);
148+
149+
const scenario1 = await runScenario(
150+
"outbound delivery WITH session.key (canonical key from agent runtime)",
151+
{ sessionKey: "agent:tank:slack:channel:CHAN1" },
152+
);
153+
const scenario2 = await runScenario(
154+
"outbound delivery WITHOUT session (narrowed docs branch)",
155+
{},
156+
);
157+
const scenario3 = await runScenario(
158+
"native-redirect: session.key = CommandTargetSessionKey (what dispatch-from-config.ts now passes)",
159+
{ sessionKey: "agent:tank:telegram:direct:999" },
160+
);
161+
162+
// Assertions — make the proof self-checking so the captured output is
163+
// not silently green when the runtime regresses.
164+
const expectFromHook = (
165+
captured: CapturedContext[],
166+
hook: "message_sending" | "message_sent",
167+
expected: string | undefined,
168+
): void => {
169+
const entry = captured.find((c) => c.hook === hook);
170+
if (!entry) {
171+
throw new Error(`[proof-73706] No ${hook} hook fired.`);
172+
}
173+
if (entry.ctx.sessionKey !== expected) {
174+
throw new Error(
175+
`[proof-73706] ${hook} sessionKey mismatch: expected ${JSON.stringify(expected)} got ${JSON.stringify(entry.ctx.sessionKey)}`,
176+
);
177+
}
178+
};
179+
180+
expectFromHook(scenario1, "message_sending", "agent:tank:slack:channel:CHAN1");
181+
expectFromHook(scenario1, "message_sent", "agent:tank:slack:channel:CHAN1");
182+
expectFromHook(scenario2, "message_sending", undefined);
183+
expectFromHook(scenario2, "message_sent", undefined);
184+
expectFromHook(scenario3, "message_sending", "agent:tank:telegram:direct:999");
185+
expectFromHook(scenario3, "message_sent", "agent:tank:telegram:direct:999");
186+
187+
console.log("\n[proof-73706] All runtime assertions passed.");
188+
}
189+
190+
main().catch((err) => {
191+
console.error("[proof-73706] FAILED:", err);
192+
process.exitCode = 1;
193+
});

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4632,6 +4632,83 @@ describe("dispatchReplyFromConfig", () => {
46324632
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
46334633
});
46344634

4635+
it("routes native-command-redirect replies using the redirect target sessionKey for outbound delivery", async () => {
4636+
// Regression test for the native redirect session-key contract:
4637+
// when a native command targets a different session via
4638+
// `CommandTargetSessionKey`, the agent runtime resolves its
4639+
// `params.sessionKey` as `CommandTargetSessionKey ?? SessionKey`
4640+
// (see `get-reply.ts`). Routed reply delivery must mirror that so
4641+
// `agent_end` (fired with the runtime sessionKey) and the outbound
4642+
// `message_sending` hook (fired with `OutboundSessionContext.key`)
4643+
// see the same canonical key. Without this alignment, plugins
4644+
// correlating per-turn state across `agent_end` and `message_sending`
4645+
// would receive divergent keys on every native redirect.
4646+
setNoAbort();
4647+
mocks.routeReply.mockClear();
4648+
const cfg = emptyConfig;
4649+
const dispatcher = createDispatcher();
4650+
const ctx = buildTestCtx({
4651+
Provider: "slack",
4652+
Surface: "slack",
4653+
AccountId: "acc-1",
4654+
OriginatingChannel: "telegram",
4655+
OriginatingTo: "telegram:999",
4656+
CommandSource: "native",
4657+
SessionKey: "agent:main:slack:channel:CHAN1",
4658+
CommandTargetSessionKey: "agent:main:telegram:direct:999",
4659+
});
4660+
4661+
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
4662+
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
4663+
4664+
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
4665+
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
4666+
expect(mocks.routeReply).toHaveBeenCalledWith(
4667+
expect.objectContaining({
4668+
channel: "telegram",
4669+
to: "telegram:999",
4670+
sessionKey: "agent:main:telegram:direct:999",
4671+
policySessionKey: "agent:main:telegram:direct:999",
4672+
}),
4673+
);
4674+
});
4675+
4676+
it("routes non-native (text) command replies using the inbound sessionKey for outbound delivery", async () => {
4677+
// Companion regression test: for non-native commands the routed
4678+
// reply must keep the inbound `SessionKey` as both the canonical
4679+
// session key and the policy key, even if `CommandTargetSessionKey`
4680+
// happens to be set on the context. This guards against accidental
4681+
// generalization of the native-redirect branch.
4682+
setNoAbort();
4683+
mocks.routeReply.mockClear();
4684+
const cfg = emptyConfig;
4685+
const dispatcher = createDispatcher();
4686+
const ctx = buildTestCtx({
4687+
Provider: "slack",
4688+
Surface: "slack",
4689+
AccountId: "acc-1",
4690+
OriginatingChannel: "telegram",
4691+
OriginatingTo: "telegram:999",
4692+
CommandSource: "text",
4693+
SessionKey: "agent:main:slack:channel:CHAN1",
4694+
CommandTargetSessionKey: "agent:main:telegram:direct:999",
4695+
});
4696+
4697+
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
4698+
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
4699+
4700+
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
4701+
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
4702+
expect(mocks.routeReply).toHaveBeenCalledWith(
4703+
expect.objectContaining({
4704+
channel: "telegram",
4705+
to: "telegram:999",
4706+
sessionKey: "agent:main:slack:channel:CHAN1",
4707+
policySessionKey: "agent:main:slack:channel:CHAN1",
4708+
}),
4709+
);
4710+
});
4711+
46354712
it("emits diagnostics when enabled", async () => {
46364713
setNoAbort();
46374714
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1304,11 +1304,18 @@ export async function dispatchReplyFromConfig(
13041304
return null;
13051305
}
13061306
markInboundDedupeReplayUnsafe();
1307+
// Outbound session.key must match the session key used by the agent
1308+
// runtime that produced this payload, so agent_end and message delivery
1309+
// hooks expose the same canonical key for native command redirects.
1310+
const agentRuntimeSessionKey =
1311+
ctx.CommandSource === "native"
1312+
? (resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey)
1313+
: ctx.SessionKey;
13071314
return await routeReplyRuntime.routeReply({
13081315
payload,
13091316
channel: routeReplyChannel,
13101317
to: routeReplyTo,
1311-
sessionKey: ctx.SessionKey,
1318+
sessionKey: agentRuntimeSessionKey,
13121319
policySessionKey: resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey,
13131320
policyConversationType: resolveRoutedPolicyConversationType(ctx),
13141321
accountId: replyRoute.accountId,

0 commit comments

Comments
 (0)