Files
openclaw/scripts/proof-73706-message-sending-session-key.ts
Edward Abrams 05db911775 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>
2026-05-28 00:43:27 +01:00

194 lines
6.8 KiB
TypeScript

/**
* Real-runtime behavior proof for #73706.
*
* This script does NOT use vitest mocks. It wires up the production
* `deliverOutboundPayloads` path against:
* - a real `PluginRegistry` populated with one real channel plugin and
* two real plugin hooks (`message_sending`, `message_sent`)
* - the real `getGlobalHookRunner()` / `initializeGlobalHookRunner()`
* singleton path (no fake hook runner)
* - the real `setActivePluginRegistry` channel resolution path (no
* fake channel adapter)
*
* It then exercises three scenarios:
*
* 1. Direct outbound delivery with `session.key` set: confirms the
* `message_sending` and `message_sent` hook contexts both receive
* the canonical `sessionKey`.
*
* 2. Direct outbound delivery with NO session: confirms `sessionKey`
* is absent from both hook contexts (the "narrowed" docs branch).
*
* 3. Native-redirect simulation: outbound delivery whose `session.key`
* is set to the redirect TARGET session (i.e., what the agent
* runtime resolves as `params.sessionKey` when
* `CommandTargetSessionKey` is set and `CommandSource === "native"`,
* and what `dispatch-from-config.ts` now passes through to
* `routeReply`). Confirms `message_sending` / `message_sent`
* observe the redirect-target session, NOT the inbound session.
* This is the runtime invariant Clawsweeper asked us to pin
* with a regression test.
*
* Run with:
* pnpm tsx scripts/proof-73706-message-sending-session-key.ts
*/
import { deliverOutboundPayloads } from "../src/infra/outbound/deliver.js";
import type {
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
} from "../src/plugins/hook-message.types.js";
import { initializeGlobalHookRunner } from "../src/plugins/hook-runner-global.js";
import { addTestHook, createMockPluginRegistry } from "../src/plugins/hooks.test-helpers.js";
import type { PluginRegistry } from "../src/plugins/registry.js";
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../src/test-utils/channel-plugins.js";
type CapturedContext = {
hook: "message_sending" | "message_sent";
ctx: PluginHookMessageContext;
event: PluginHookMessageReceivedEvent;
};
function buildRegistry(captured: CapturedContext[], channelId: "matrix"): PluginRegistry {
// Real outbound channel plugin: returns a synthetic delivery result
// without touching any network. This drives `deliverOutboundPayloads`
// through its real channel-resolution + sendText path.
const sendText = async () => ({
channel: channelId,
messageId: `mx-${Date.now()}`,
roomId: "!room:example",
});
const channelRegistry = createTestRegistry([
{
pluginId: channelId,
source: "proof",
plugin: createOutboundTestPlugin({
id: channelId,
outbound: { deliveryMode: "direct", sendText },
}),
},
]);
// Real hook handlers: capture exactly what delivery hands to plugins.
const hookRegistry = createMockPluginRegistry([]);
addTestHook({
registry: hookRegistry,
pluginId: "proof-message-sending",
hookName: "message_sending",
handler: async (event: unknown, ctx: unknown) => {
captured.push({
hook: "message_sending",
ctx: ctx as PluginHookMessageContext,
event: event as PluginHookMessageReceivedEvent,
});
// Returning undefined means "do not modify or cancel".
return undefined;
},
});
addTestHook({
registry: hookRegistry,
pluginId: "proof-message-sent",
hookName: "message_sent",
handler: async (event: unknown, ctx: unknown) => {
captured.push({
hook: "message_sent",
ctx: ctx as PluginHookMessageContext,
event: event as PluginHookMessageReceivedEvent,
});
},
});
return {
...channelRegistry,
hooks: hookRegistry.hooks,
typedHooks: hookRegistry.typedHooks,
plugins: hookRegistry.plugins,
};
}
async function runScenario(
label: string,
opts: { sessionKey?: string },
): Promise<CapturedContext[]> {
const captured: CapturedContext[] = [];
const registry = buildRegistry(captured, "matrix");
setActivePluginRegistry(registry);
initializeGlobalHookRunner(registry);
const result = await deliverOutboundPayloads({
cfg: {},
channel: "matrix",
to: "!room:example",
payloads: [{ text: `proof: ${label}` }],
skipQueue: true,
...(opts.sessionKey ? { session: { key: opts.sessionKey } } : {}),
});
console.log(`\n=== Scenario: ${label} ===`);
console.log(`deliverOutboundPayloads result:`, JSON.stringify(result));
for (const entry of captured) {
console.log(
`[${entry.hook}] ctx.sessionKey = ${
entry.ctx.sessionKey === undefined ? "(undefined)" : JSON.stringify(entry.ctx.sessionKey)
}`,
);
console.log(`[${entry.hook}] full ctx = ${JSON.stringify(entry.ctx)}`);
}
return captured;
}
async function main() {
console.log("[proof-73706] Real-runtime behavior proof for outbound session-key threading.");
console.log(
"[proof-73706] Production code paths: deliverOutboundPayloads + getGlobalHookRunner.",
);
const scenario1 = await runScenario(
"outbound delivery WITH session.key (canonical key from agent runtime)",
{ sessionKey: "agent:tank:slack:channel:CHAN1" },
);
const scenario2 = await runScenario(
"outbound delivery WITHOUT session (narrowed docs branch)",
{},
);
const scenario3 = await runScenario(
"native-redirect: session.key = CommandTargetSessionKey (what dispatch-from-config.ts now passes)",
{ sessionKey: "agent:tank:telegram:direct:999" },
);
// Assertions — make the proof self-checking so the captured output is
// not silently green when the runtime regresses.
const expectFromHook = (
captured: CapturedContext[],
hook: "message_sending" | "message_sent",
expected: string | undefined,
): void => {
const entry = captured.find((c) => c.hook === hook);
if (!entry) {
throw new Error(`[proof-73706] No ${hook} hook fired.`);
}
if (entry.ctx.sessionKey !== expected) {
throw new Error(
`[proof-73706] ${hook} sessionKey mismatch: expected ${JSON.stringify(expected)} got ${JSON.stringify(entry.ctx.sessionKey)}`,
);
}
};
expectFromHook(scenario1, "message_sending", "agent:tank:slack:channel:CHAN1");
expectFromHook(scenario1, "message_sent", "agent:tank:slack:channel:CHAN1");
expectFromHook(scenario2, "message_sending", undefined);
expectFromHook(scenario2, "message_sent", undefined);
expectFromHook(scenario3, "message_sending", "agent:tank:telegram:direct:999");
expectFromHook(scenario3, "message_sent", "agent:tank:telegram:direct:999");
console.log("\n[proof-73706] All runtime assertions passed.");
}
main().catch((err) => {
console.error("[proof-73706] FAILED:", err);
process.exitCode = 1;
});