mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 17:01:34 +00:00
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
// Matrix delivery trace goldens: replayable wire-level lifecycle recordings.
|
|
//
|
|
// Drives the real monitor handler (createMatrixRoomMessageHandler) end to end
|
|
// through the shared handler test harness: IN steps map onto the captured
|
|
// reply-dispatcher wiring (deliver, typing callbacks, onPartialReply,
|
|
// onBlockReplyQueued, onAssistantMessageStart), OUT events are the raw Matrix
|
|
// client calls (sendMessage/getEvent/redactEvent/setTyping/sendReadReceipt)
|
|
// observed at a recording client. The room is plain (E2EE out of scope), so
|
|
// goldens capture wire content and event-id semantics: every m.replace edit
|
|
// mints a new event id while the logical target id stays stable, and previews
|
|
// are mentions-inert until a final either edits in place or gets redacted and
|
|
// re-sent as a fresh mention-bearing event.
|
|
// Refresh goldens with OPENCLAW_TRACE_UPDATE=1 (see delivery-trace harness docs).
|
|
import {
|
|
deliveryTraceScenarios,
|
|
expectDeliveryTraceMatchesGolden,
|
|
runDeliveryTraceScenario,
|
|
type DeliveryTraceInStep,
|
|
type DeliveryTraceScenario,
|
|
type WireRecorder,
|
|
} from "openclaw/plugin-sdk/channel-contract-testing";
|
|
import {
|
|
implicitMentionKindWhen,
|
|
resolveInboundMentionDecision,
|
|
} from "openclaw/plugin-sdk/channel-mention-gating";
|
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
|
import {
|
|
chunkMarkdownTextWithMode,
|
|
resolveChunkMode,
|
|
resolveTextChunkLimit,
|
|
} from "openclaw/plugin-sdk/reply-chunking";
|
|
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
|
import { beforeAll, describe, it, vi } from "vitest";
|
|
import {
|
|
createMatrixHandlerTestHarness,
|
|
createMatrixTextMessageEvent,
|
|
} from "./matrix/monitor/handler.test-helpers.js";
|
|
import type { MatrixClient } from "./matrix/sdk.js";
|
|
import type { PluginRuntime, ReplyPayload } from "./runtime-api.js";
|
|
import { installMatrixTestRuntime } from "./test-runtime.js";
|
|
|
|
const ROOM_ID = "!room:example.org";
|
|
const BOT_USER_ID = "@openclaw:example.org";
|
|
const INBOUND_EVENT_ID = "$inbound-1";
|
|
|
|
beforeAll(() => {
|
|
// send.ts/replies.ts read text helpers and the monitor's mention gating from
|
|
// the module runtime slot; bind the real chunking/table implementations so
|
|
// rendered wire content is part of the recorded lifecycle (mirrors the
|
|
// feishu adoption's runtime stub). Media helpers stay absent: these
|
|
// scenarios are text-only and a media lookup should fail loudly.
|
|
installMatrixTestRuntime({
|
|
logging: { shouldLogVerbose: () => false },
|
|
channel: {
|
|
text: {
|
|
resolveMarkdownTableMode,
|
|
convertMarkdownTables,
|
|
resolveTextChunkLimit,
|
|
resolveChunkMode,
|
|
chunkMarkdownTextWithMode,
|
|
},
|
|
mentions: {
|
|
buildMentionRegexes: () => [],
|
|
matchesMentionPatterns: (text: string, patterns: RegExp[]) =>
|
|
patterns.some((pattern) => pattern.test(text)),
|
|
matchesMentionWithExplicit: () => false,
|
|
implicitMentionKindWhen,
|
|
resolveInboundMentionDecision,
|
|
},
|
|
} as unknown as PluginRuntime["channel"],
|
|
});
|
|
});
|
|
|
|
function createRecordingMatrixClient(recorder: WireRecorder): Partial<MatrixClient> {
|
|
// Deterministic sequential event ids replace homeserver-minted opaque ids
|
|
// (the normalizer seam from the trace spec, applied at the mock boundary):
|
|
// every event on the wire consumes one id, so goldens show edits minting new
|
|
// ids while their m.relates_to target stays the original event.
|
|
const eventContentById = new Map<string, Record<string, unknown>>();
|
|
let eventCount = 0;
|
|
const mintEventId = () => {
|
|
eventCount += 1;
|
|
return `$e${eventCount}`;
|
|
};
|
|
const client: Partial<MatrixClient> = {
|
|
getUserId: async () => BOT_USER_ID,
|
|
sendMessage: async (roomId: string, content: Record<string, unknown>) => {
|
|
const eventId = mintEventId();
|
|
// Snapshot before recording: edit flows reuse content structures, and the
|
|
// recorder serializes at compare time.
|
|
const payload = structuredClone(content);
|
|
eventContentById.set(eventId, payload);
|
|
recorder.recordWireCall({
|
|
method: "sendMessage",
|
|
target: roomId,
|
|
payload,
|
|
result: { event_id: eventId },
|
|
});
|
|
return eventId;
|
|
},
|
|
// editMessageMatrix fetches the prior event before every edit (mention
|
|
// diffing / thread guard) — an extra RTT per edit that the trace captures
|
|
// even for mentions-inert previews where the result goes unused.
|
|
getEvent: async (_roomId: string, eventId: string) => {
|
|
recorder.recordWireCall({
|
|
method: "getEvent",
|
|
target: eventId,
|
|
result: { found: eventContentById.has(eventId) },
|
|
});
|
|
return { event_id: eventId, content: eventContentById.get(eventId) ?? {} };
|
|
},
|
|
redactEvent: async (_roomId: string, eventId: string) => {
|
|
// Redactions are events too: abandoning a draft consumes an event id.
|
|
const redactionEventId = mintEventId();
|
|
recorder.recordWireCall({
|
|
method: "redactEvent",
|
|
target: eventId,
|
|
result: { event_id: redactionEventId },
|
|
});
|
|
return redactionEventId;
|
|
},
|
|
setTyping: async (roomId: string, typing: boolean, timeoutMs: number) => {
|
|
recorder.recordWireCall({
|
|
method: "setTyping",
|
|
target: roomId,
|
|
payload: { typing, timeoutMs },
|
|
});
|
|
},
|
|
sendReadReceipt: async (_roomId: string, eventId: string) => {
|
|
recorder.recordWireCall({ method: "sendReadReceipt", target: eventId });
|
|
},
|
|
};
|
|
return client;
|
|
}
|
|
|
|
type MatrixTraceDeliver = (payload: ReplyPayload, info: { kind: string }) => Promise<void>;
|
|
|
|
type MatrixTraceReplyOptions = {
|
|
onPartialReply?: (payload: { text: string }) => void;
|
|
onBlockReplyQueued?: (
|
|
payload: { text?: string },
|
|
context?: { assistantMessageIndex?: number },
|
|
) => Promise<void> | void;
|
|
onAssistantMessageStart?: () => void;
|
|
};
|
|
|
|
async function setupMatrixTrace(recorder: WireRecorder) {
|
|
const client = createRecordingMatrixClient(recorder);
|
|
|
|
let capturedDeliver: MatrixTraceDeliver | undefined;
|
|
let capturedOnReplyStart: (() => Promise<void> | void) | undefined;
|
|
let capturedOnIdle: (() => void) | undefined;
|
|
let capturedReplyOptions: MatrixTraceReplyOptions | undefined;
|
|
let resolveCaptured: (() => void) | undefined;
|
|
const captured = new Promise<void>((resolve) => {
|
|
resolveCaptured = resolve;
|
|
});
|
|
const notifyCaptured = () => {
|
|
if (capturedDeliver && capturedReplyOptions) {
|
|
resolveCaptured?.();
|
|
}
|
|
};
|
|
|
|
// The scripted steps stand in for the model run: dispatchReplyFromConfig
|
|
// stays pending until the script's final/cancel step settles it, so the
|
|
// handler's post-dispatch flow (including the finally-block draft abandon
|
|
// path) runs exactly where the real run would settle.
|
|
type DispatchResult = { queuedFinal: boolean; counts: { final: number; block: number } };
|
|
let releaseRun: ((result: DispatchResult) => void) | undefined;
|
|
const runGate = new Promise<DispatchResult>((resolve) => {
|
|
releaseRun = resolve;
|
|
});
|
|
|
|
const { handler } = createMatrixHandlerTestHarness({
|
|
streaming: "partial",
|
|
blockStreamingEnabled: true,
|
|
client,
|
|
resolveMarkdownTableMode: () => resolveMarkdownTableMode({ cfg: {}, channel: "matrix" }),
|
|
createReplyDispatcherWithTyping: (options: Record<string, unknown> | undefined) => {
|
|
capturedDeliver = options?.deliver as MatrixTraceDeliver | undefined;
|
|
capturedOnReplyStart = options?.onReplyStart as typeof capturedOnReplyStart;
|
|
capturedOnIdle = options?.onIdle as typeof capturedOnIdle;
|
|
notifyCaptured();
|
|
return {
|
|
dispatcher: { markComplete: () => {}, waitForIdle: async () => {} },
|
|
replyOptions: {},
|
|
markDispatchIdle: () => {},
|
|
markRunComplete: () => {},
|
|
};
|
|
},
|
|
dispatchReplyFromConfig: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => {
|
|
capturedReplyOptions = args?.replyOptions;
|
|
notifyCaptured();
|
|
const result = await runGate;
|
|
return { queuedFinal: result.queuedFinal, counts: { ...result.counts, tool: 0 } };
|
|
}) as never,
|
|
});
|
|
|
|
const handlerDone = handler(
|
|
ROOM_ID,
|
|
createMatrixTextMessageEvent({
|
|
eventId: INBOUND_EVENT_ID,
|
|
body: "deploy status?",
|
|
originServerTs: Date.now(),
|
|
}),
|
|
);
|
|
await Promise.race([
|
|
captured,
|
|
handlerDone.then(() => {
|
|
throw new Error("matrix handler settled before capturing dispatcher wiring");
|
|
}),
|
|
]);
|
|
// Drain the fire-and-forget inbound read receipt so it lands at a fixed
|
|
// position (before the first scripted step) in every recording.
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// Mirrors the runner's assistant message_start boundaries: the first index
|
|
// is minted at reply start and each tool round ends with the next assistant
|
|
// message beginning (handler resets its cumulative-text offsets there).
|
|
let assistantMessageIndex = 0;
|
|
const startAssistantMessage = () => {
|
|
assistantMessageIndex += 1;
|
|
capturedReplyOptions?.onAssistantMessageStart?.();
|
|
};
|
|
|
|
return async (step: DeliveryTraceInStep) => {
|
|
switch (step.kind) {
|
|
case "reply-start":
|
|
await capturedOnReplyStart?.();
|
|
startAssistantMessage();
|
|
break;
|
|
case "partial":
|
|
// Cumulative text for the current assistant message; the handler
|
|
// slices it into per-generation draft updates.
|
|
capturedReplyOptions?.onPartialReply?.({ text: step.text });
|
|
break;
|
|
case "block-final":
|
|
await capturedReplyOptions?.onBlockReplyQueued?.(
|
|
{ text: step.text },
|
|
{ assistantMessageIndex },
|
|
);
|
|
await capturedDeliver?.({ text: step.text }, { kind: "block" });
|
|
break;
|
|
case "tool-progress":
|
|
// Preview tool progress is not adopted here; a completed tool round
|
|
// means the next assistant message (generation) starts streaming.
|
|
if (step.phase === "result") {
|
|
startAssistantMessage();
|
|
}
|
|
break;
|
|
case "final":
|
|
await capturedDeliver?.(
|
|
{
|
|
...(step.text !== undefined ? { text: step.text } : {}),
|
|
...(step.mediaUrls ? { mediaUrls: step.mediaUrls } : {}),
|
|
...(step.isError ? { isError: true } : {}),
|
|
},
|
|
{ kind: "final" },
|
|
);
|
|
releaseRun?.({ queuedFinal: true, counts: { final: 1, block: 0 } });
|
|
await handlerDone;
|
|
break;
|
|
case "cancel":
|
|
// An aborted run settles the dispatch without a final payload; the
|
|
// handler's finally block flushes and redacts the unconsumed draft.
|
|
releaseRun?.({ queuedFinal: false, counts: { final: 0, block: 0 } });
|
|
await handlerDone;
|
|
break;
|
|
case "idle":
|
|
capturedOnIdle?.();
|
|
break;
|
|
case "wire-fault":
|
|
throw new Error("matrix trace scenarios do not script wire faults");
|
|
}
|
|
};
|
|
}
|
|
|
|
const GEN_ONE_PARTIAL = "Build check:";
|
|
const GEN_ONE_BLOCK = "Build check: all suites green.";
|
|
const GEN_TWO_PARTIAL = "Deploying to production now.";
|
|
const GEN_TWO_PREVIEW = "Deploying to production now. Watching health checks.";
|
|
const GEN_TWO_FINAL = "Deploying to production now. Health checks green.";
|
|
const IN_PLACE_PARTIAL = "Ship checklist complete.";
|
|
const IN_PLACE_FINAL = "Ship checklist complete. Tagging the release.";
|
|
const MENTION_PARTIAL = "Paging @oncall:example.org";
|
|
const MENTION_FINAL = "Paging @oncall:example.org for the deploy review.";
|
|
|
|
// The draft stream throttles edits at 1000ms; each generation streams one
|
|
// create plus one throttled m.replace edit before its boundary settles it.
|
|
// Matrix records generation-scoped edit-streaming shapes the shared v1
|
|
// library does not model; the scenario name only keys the golden filename.
|
|
const MATRIX_TRACE_SCENARIOS: readonly DeliveryTraceScenario[] = [
|
|
// Two generations separated by a tool round: one preview event per
|
|
// generation, cumulative slices, block finalize-in-place for generation one,
|
|
// and a final replace-edit because the final text differs from the last
|
|
// rendered preview of generation two.
|
|
{
|
|
name: "streaming-happy-multi-generation",
|
|
steps: [
|
|
{ kind: "reply-start" },
|
|
{ kind: "partial", text: GEN_ONE_PARTIAL },
|
|
{ kind: "advance", ms: 400 },
|
|
{ kind: "partial", text: GEN_ONE_BLOCK },
|
|
{ kind: "advance", ms: 700 },
|
|
{ kind: "block-final", text: GEN_ONE_BLOCK },
|
|
{ kind: "tool-progress", name: "deploy", phase: "start" },
|
|
{ kind: "tool-progress", name: "deploy", phase: "result" },
|
|
{ kind: "partial", text: GEN_TWO_PARTIAL },
|
|
{ kind: "advance", ms: 400 },
|
|
{ kind: "partial", text: GEN_TWO_PREVIEW },
|
|
{ kind: "advance", ms: 700 },
|
|
{ kind: "final", text: GEN_TWO_FINAL },
|
|
{ kind: "idle" },
|
|
],
|
|
},
|
|
// Final text equals the last rendered preview: the draft is finalized in
|
|
// place with an edit that only clears the MSC4357 live marker.
|
|
{
|
|
name: "final-in-place",
|
|
steps: [
|
|
{ kind: "reply-start" },
|
|
{ kind: "partial", text: IN_PLACE_PARTIAL },
|
|
{ kind: "advance", ms: 400 },
|
|
{ kind: "partial", text: IN_PLACE_FINAL },
|
|
{ kind: "advance", ms: 700 },
|
|
{ kind: "final", text: IN_PLACE_FINAL },
|
|
{ kind: "idle" },
|
|
],
|
|
},
|
|
// Previews are mentions-inert and an edit cannot retro-notify, so a final
|
|
// whose text would activate mentions redacts the preview and re-sends the
|
|
// final as a fresh mention-bearing event.
|
|
{
|
|
name: "final-mentions-fresh",
|
|
steps: [
|
|
{ kind: "reply-start" },
|
|
{ kind: "partial", text: MENTION_PARTIAL },
|
|
{ kind: "advance", ms: 400 },
|
|
{ kind: "partial", text: MENTION_FINAL },
|
|
{ kind: "advance", ms: 700 },
|
|
{ kind: "final", text: MENTION_FINAL },
|
|
{ kind: "idle" },
|
|
],
|
|
},
|
|
// Shared abandon shape: the handler's finally block flushes the pending
|
|
// throttled edit, then redacts the unconsumed draft.
|
|
deliveryTraceScenarios["cancel-mid-stream"],
|
|
];
|
|
|
|
describe("matrix delivery trace goldens", () => {
|
|
for (const scenario of MATRIX_TRACE_SCENARIOS) {
|
|
it(`records ${scenario.name}`, async () => {
|
|
const events = await runDeliveryTraceScenario({
|
|
scenario,
|
|
setup: setupMatrixTrace,
|
|
});
|
|
expectDeliveryTraceMatchesGolden({
|
|
goldenUrl: new URL(`./__traces__/${scenario.name}.trace.jsonl`, import.meta.url),
|
|
events,
|
|
});
|
|
});
|
|
}
|
|
});
|