mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
// Mattermost delivery trace goldens: replayable wire-level lifecycle recordings.
|
|
//
|
|
// Wires the real draft-stream path the monitor uses (createMattermostDraftStream,
|
|
// preview boundary controller, deliverMattermostReplyWithDraftPreview,
|
|
// deliverMattermostReplyPayload) against a recording MattermostClient, so OUT
|
|
// events are the raw REST calls (POST/PUT/DELETE /posts). The monitor's
|
|
// per-activity glue (partial dedupe, boundary rotation) is replicated inline in
|
|
// block-preview mode; the scripted steps stand in for the dispatcher callbacks.
|
|
// 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 {
|
|
createMessageReceiptFromOutboundResults,
|
|
listMessageReceiptPlatformIds,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
|
import {
|
|
chunkMarkdownTextWithMode,
|
|
resolveChunkMode,
|
|
resolveTextChunkLimit,
|
|
} from "openclaw/plugin-sdk/reply-chunking";
|
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
|
import { describe, it, vi } from "vitest";
|
|
import { createMattermostPost, type MattermostClient } from "./mattermost/client.js";
|
|
import {
|
|
createMattermostDraftPreviewBoundaryController,
|
|
createMattermostDraftStream,
|
|
} from "./mattermost/draft-stream.js";
|
|
import { resolveMattermostReplyRootId } from "./mattermost/monitor-context.js";
|
|
import { deliverMattermostReplyWithDraftPreview } from "./mattermost/monitor-draft-delivery.js";
|
|
import {
|
|
deliverMattermostReplyPayload,
|
|
joinMattermostVisibleContent,
|
|
} from "./mattermost/reply-delivery.js";
|
|
|
|
const CHANNEL_ID = "channel-trace";
|
|
const ROOT_ID = "root-trace";
|
|
const ACCOUNT_ID = "main";
|
|
// Matches the monitor's draft stream wiring (throttleMs: 1200).
|
|
const DRAFT_THROTTLE_MS = 1200;
|
|
|
|
const cfg = {} as OpenClawConfig;
|
|
const tableMode = resolveMarkdownTableMode({ cfg, channel: "mattermost" });
|
|
const chunkMode = resolveChunkMode(cfg, "mattermost", ACCOUNT_ID);
|
|
const textLimit = resolveTextChunkLimit(cfg, "mattermost", ACCOUNT_ID, { fallbackLimit: 4000 });
|
|
|
|
// deliverMattermostReplyPayload only touches channel.text helpers; bind the
|
|
// real implementations the plugin runtime would provide.
|
|
const core = {
|
|
channel: {
|
|
text: { convertMarkdownTables, resolveChunkMode, chunkMarkdownTextWithMode },
|
|
},
|
|
} as unknown as PluginRuntime;
|
|
|
|
function createRecordingMattermostClient(recorder: WireRecorder): MattermostClient {
|
|
let postCount = 0;
|
|
const requestImpl = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
const method = init?.method ?? "GET";
|
|
const payload =
|
|
typeof init?.body === "string"
|
|
? (JSON.parse(init.body) as Record<string, unknown>)
|
|
: undefined;
|
|
if (method === "POST" && path === "/posts") {
|
|
postCount += 1;
|
|
const result = { id: `post-${postCount}` };
|
|
recorder.recordWireCall({
|
|
method: "POST /posts",
|
|
target: typeof payload?.channel_id === "string" ? payload.channel_id : "",
|
|
payload,
|
|
result,
|
|
});
|
|
return result as T;
|
|
}
|
|
if (path.startsWith("/posts/")) {
|
|
const postId = path.slice("/posts/".length);
|
|
if (method === "DELETE") {
|
|
recorder.recordWireCall({
|
|
method: `DELETE ${path}`,
|
|
target: postId,
|
|
result: { status: "OK" },
|
|
});
|
|
return { status: "OK" } as T;
|
|
}
|
|
const result = { id: postId };
|
|
recorder.recordWireCall({ method: `${method} ${path}`, target: postId, payload, result });
|
|
return result as T;
|
|
}
|
|
throw new Error(`Unexpected Mattermost request: ${method} ${path}`);
|
|
};
|
|
return {
|
|
baseUrl: "https://chat.example.com",
|
|
apiBaseUrl: "https://chat.example.com/api/v4",
|
|
token: "test-token",
|
|
request: vi.fn(requestImpl) as MattermostClient["request"],
|
|
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
|
|
};
|
|
}
|
|
|
|
function setupMattermostTrace(recorder: WireRecorder) {
|
|
const client = createRecordingMattermostClient(recorder);
|
|
const draftStream = createMattermostDraftStream({
|
|
client,
|
|
channelId: CHANNEL_ID,
|
|
rootId: ROOT_ID,
|
|
throttleMs: DRAFT_THROTTLE_MS,
|
|
chunkText: (value) =>
|
|
chunkMarkdownTextWithMode(convertMarkdownTables(value, tableMode), textLimit, chunkMode),
|
|
});
|
|
const previewBoundary = createMattermostDraftPreviewBoundaryController({
|
|
enabled: true,
|
|
forceNewMessage: async () => {
|
|
await draftStream.forceNewMessage();
|
|
},
|
|
});
|
|
const previewState = { finalizedViaPreviewPost: false };
|
|
let lastPartialText = "";
|
|
|
|
// Replicas of the monitor's inline final-text resolution glue
|
|
// (extensions/mattermost/src/mattermost/monitor.ts deliver wiring).
|
|
const resolvePreviewFinalText = (text?: string) => {
|
|
const resolution = draftStream.resolveFinalText(typeof text === "string" ? text : "");
|
|
const confirmedDelivery =
|
|
resolution.publishedParts.length > 0
|
|
? (() => {
|
|
const receipt = createMessageReceiptFromOutboundResults({
|
|
results: resolution.publishedParts.map((part) => ({
|
|
channel: "mattermost",
|
|
messageId: part.messageId,
|
|
channelId: CHANNEL_ID,
|
|
})),
|
|
kind: "preview",
|
|
replyToId: ROOT_ID,
|
|
});
|
|
return {
|
|
outcome: "text" as const,
|
|
messageIds: listMessageReceiptPlatformIds(receipt),
|
|
receipt,
|
|
visibleReplySent: true,
|
|
content: joinMattermostVisibleContent(
|
|
resolution.publishedParts.map((part) => part.content),
|
|
),
|
|
};
|
|
})()
|
|
: undefined;
|
|
const deliveryText = resolution.kind === "already-delivered" ? "" : resolution.text;
|
|
const formatted = convertMarkdownTables(deliveryText, tableMode);
|
|
const chunks = chunkMarkdownTextWithMode(formatted, textLimit, chunkMode);
|
|
if (!chunks.length && formatted) {
|
|
chunks.push(formatted);
|
|
}
|
|
if (chunks.length !== 1) {
|
|
return {
|
|
deliveryText,
|
|
confirmedDelivery,
|
|
alreadyDelivered: resolution.kind === "already-delivered",
|
|
};
|
|
}
|
|
const trimmed = chunks[0]?.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
deliveryText,
|
|
confirmedDelivery,
|
|
alreadyDelivered: resolution.kind === "already-delivered",
|
|
};
|
|
}
|
|
if (
|
|
lastPartialText &&
|
|
lastPartialText.startsWith(trimmed) &&
|
|
trimmed.length < lastPartialText.length
|
|
) {
|
|
return { deliveryText, confirmedDelivery, alreadyDelivered: false };
|
|
}
|
|
return { editText: trimmed, deliveryText, confirmedDelivery, alreadyDelivered: false };
|
|
};
|
|
|
|
const deliverPayload = async (payloadToDeliver: ReplyPayload) => {
|
|
const finalTextResolution =
|
|
!payloadToDeliver.isError && typeof payloadToDeliver.text === "string"
|
|
? draftStream.resolveFinalText(payloadToDeliver.text)
|
|
: undefined;
|
|
const resolvedPayload = finalTextResolution
|
|
? {
|
|
...payloadToDeliver,
|
|
text: finalTextResolution.kind === "already-delivered" ? "" : finalTextResolution.text,
|
|
}
|
|
: payloadToDeliver;
|
|
return await deliverMattermostReplyPayload({
|
|
core,
|
|
cfg,
|
|
payload: resolvedPayload,
|
|
to: `channel:${CHANNEL_ID}`,
|
|
accountId: ACCOUNT_ID,
|
|
agentId: "agent",
|
|
replyToId: resolveMattermostReplyRootId({
|
|
kind: "channel",
|
|
threadRootId: ROOT_ID,
|
|
replyToId: payloadToDeliver.replyToId,
|
|
}),
|
|
textLimit,
|
|
tableMode,
|
|
sendMessage: async (_to, text, opts) => {
|
|
const post = await createMattermostPost(client, {
|
|
channelId: CHANNEL_ID,
|
|
message: text,
|
|
rootId: opts.replyToId,
|
|
});
|
|
return {
|
|
messageId: post.id,
|
|
channelId: CHANNEL_ID,
|
|
receipt: createMessageReceiptFromOutboundResults({
|
|
results: [{ channel: "mattermost", messageId: post.id, channelId: CHANNEL_ID }],
|
|
kind: "text",
|
|
...(opts.replyToId ? { replyToId: opts.replyToId } : {}),
|
|
}),
|
|
content: post.message ?? text,
|
|
};
|
|
},
|
|
});
|
|
};
|
|
|
|
return async (step: DeliveryTraceInStep) => {
|
|
switch (step.kind) {
|
|
case "reply-start":
|
|
case "tool-progress":
|
|
case "cancel":
|
|
// Typing travels over the websocket (not the REST client), tool
|
|
// progress is not adopted here, and an aborted run stops emitting
|
|
// payloads; closeout happens on idle.
|
|
break;
|
|
case "partial": {
|
|
const cleaned = step.text.trim();
|
|
if (!cleaned || cleaned === lastPartialText) {
|
|
break;
|
|
}
|
|
if (
|
|
lastPartialText &&
|
|
lastPartialText.startsWith(cleaned) &&
|
|
cleaned.length < lastPartialText.length
|
|
) {
|
|
break;
|
|
}
|
|
lastPartialText = cleaned;
|
|
draftStream.updateAssistantText(cleaned);
|
|
previewBoundary.noteUpdate();
|
|
break;
|
|
}
|
|
case "block-final":
|
|
// Block boundary = assistant message boundary: partial snapshots reset
|
|
// and the block-mode preview rotates to a fresh post.
|
|
lastPartialText = "";
|
|
await previewBoundary.noteBoundary();
|
|
break;
|
|
case "final":
|
|
// Final resolution may edit the confirmed preview post in place; join
|
|
// outstanding boundary work first, like the monitor deliver wiring.
|
|
await draftStream.settleBoundaries();
|
|
await deliverMattermostReplyWithDraftPreview({
|
|
payload: {
|
|
...(step.text !== undefined ? { text: step.text } : {}),
|
|
...(step.mediaUrls ? { mediaUrls: step.mediaUrls } : {}),
|
|
...(step.isError ? { isError: true } : {}),
|
|
},
|
|
info: { kind: "final" },
|
|
kind: "channel",
|
|
client,
|
|
draftStream,
|
|
effectiveReplyToId: ROOT_ID,
|
|
resolvePreviewFinalText,
|
|
previewState,
|
|
logVerboseMessage: () => {},
|
|
deliverPayload,
|
|
});
|
|
break;
|
|
case "idle":
|
|
// Mirrors the monitor's finally block: stop flushes the last pending
|
|
// preview text and keeps the post.
|
|
await draftStream.stop();
|
|
break;
|
|
case "wire-fault":
|
|
throw new Error("mattermost trace scenarios do not script wire faults");
|
|
}
|
|
};
|
|
}
|
|
|
|
const MATTERMOST_TRACE_SCENARIOS: readonly DeliveryTraceScenario[] = [
|
|
deliveryTraceScenarios["streaming-happy"],
|
|
deliveryTraceScenarios["final-only"],
|
|
deliveryTraceScenarios["cancel-mid-stream"],
|
|
{
|
|
name: "final-then-error",
|
|
steps: [
|
|
{ kind: "reply-start" },
|
|
{ kind: "partial", text: "Successful assistant" },
|
|
{ kind: "advance", ms: DRAFT_THROTTLE_MS },
|
|
{ kind: "final", text: "Successful assistant final" },
|
|
{ kind: "final", text: "Tool error warning", isError: true },
|
|
{ kind: "idle" },
|
|
],
|
|
},
|
|
];
|
|
|
|
describe("mattermost delivery trace goldens", () => {
|
|
for (const scenario of MATTERMOST_TRACE_SCENARIOS) {
|
|
it(`records ${scenario.name}`, async () => {
|
|
const events = await runDeliveryTraceScenario({
|
|
scenario,
|
|
setup: setupMattermostTrace,
|
|
});
|
|
expectDeliveryTraceMatchesGolden({
|
|
goldenUrl: new URL(`./__traces__/${scenario.name}.trace.jsonl`, import.meta.url),
|
|
events,
|
|
});
|
|
});
|
|
}
|
|
});
|