Skip to content

Commit 444d7df

Browse files
authored
test(telegram): delivery trace goldens for the edit-preview lifecycle (#104581)
1 parent e686693 commit 444d7df

6 files changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{"seq":1,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
2+
{"seq":2,"at":0,"dir":"in","kind":"reply-start"}
3+
{"seq":3,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
4+
{"seq":4,"at":0,"dir":"in","kind":"partial","data":{"text":"Working on the fix"}}
5+
{"seq":5,"at":300,"dir":"in","kind":"partial","data":{"text":"Working on the fix: patching now."}}
6+
{"seq":6,"at":300,"dir":"out","kind":"sendMessage","data":{"payload":{"parse_mode":"HTML","reply_parameters":{"allow_sending_without_reply":true,"message_id":1},"text":"Working on the fix: patching now."},"result":{"message_id":1001},"target":"4242"}}
7+
{"seq":7,"at":600,"dir":"in","kind":"cancel"}
8+
{"seq":8,"at":600,"dir":"in","kind":"idle"}
9+
{"seq":9,"at":4300,"dir":"out","kind":"deleteMessage","data":{"payload":{"message_id":1001},"result":true,"target":"4242"}}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{"seq":1,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
2+
{"seq":2,"at":0,"dir":"in","kind":"reply-start"}
3+
{"seq":3,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
4+
{"seq":4,"at":0,"dir":"in","kind":"final","data":{"text":"All checks passed."}}
5+
{"seq":5,"at":0,"dir":"out","kind":"sendMessage","data":{"payload":{"parse_mode":"HTML","reply_parameters":{"allow_sending_without_reply":true,"message_id":1},"text":"All checks passed."},"result":{"message_id":1001},"target":"4242"}}
6+
{"seq":6,"at":0,"dir":"in","kind":"idle"}

extensions/telegram/src/__traces__/overflow-pagination.trace.jsonl

Lines changed: 9 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{"seq":1,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
2+
{"seq":2,"at":0,"dir":"in","kind":"reply-start"}
3+
{"seq":3,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
4+
{"seq":4,"at":0,"dir":"in","kind":"partial","data":{"text":"Collecting logs"}}
5+
{"seq":5,"at":300,"dir":"in","kind":"wire-fault","data":{"fault":"rate-limit","retryAfterMs":1000}}
6+
{"seq":6,"at":300,"dir":"in","kind":"partial","data":{"text":"Collecting logs from the gateway."}}
7+
{"seq":7,"at":300,"dir":"out","kind":"sendMessage","data":{"payload":{"parse_mode":"HTML","reply_parameters":{"allow_sending_without_reply":true,"message_id":1},"text":"Collecting logs from the gateway."},"result":{"message_id":1001},"target":"4242"}}
8+
{"seq":8,"at":1400,"dir":"in","kind":"partial","data":{"text":"Collecting logs from the gateway. Found the failure."}}
9+
{"seq":9,"at":1400,"dir":"out","kind":"editMessageText","data":{"payload":{"message_id":1001,"parse_mode":"HTML","text":"Collecting logs from the gateway. Found the failure."},"result":{"error_code":429,"parameters":{"retry_after":1}},"target":"4242"}}
10+
{"seq":10,"at":1700,"dir":"in","kind":"final","data":{"text":"Collecting logs from the gateway. Found the failure."}}
11+
{"seq":11,"at":1700,"dir":"in","kind":"idle"}
12+
{"seq":12,"at":2400,"dir":"out","kind":"editMessageText","data":{"payload":{"message_id":1001,"parse_mode":"HTML","text":"Collecting logs from the gateway. Found the failure."},"result":true,"target":"4242"}}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{"seq":1,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
2+
{"seq":2,"at":0,"dir":"in","kind":"reply-start"}
3+
{"seq":3,"at":0,"dir":"out","kind":"sendChatAction","data":{"payload":{"action":"typing"},"result":true,"target":"4242"}}
4+
{"seq":4,"at":0,"dir":"in","kind":"partial","data":{"text":"Deploy status:"}}
5+
{"seq":5,"at":300,"dir":"in","kind":"partial","data":{"text":"Deploy status: build is green."}}
6+
{"seq":6,"at":300,"dir":"out","kind":"sendMessage","data":{"payload":{"parse_mode":"HTML","reply_parameters":{"allow_sending_without_reply":true,"message_id":1},"text":"Deploy status: build is green."},"result":{"message_id":1001},"target":"4242"}}
7+
{"seq":7,"at":600,"dir":"in","kind":"block-final","data":{"text":"Deploy status: build is green."}}
8+
{"seq":8,"at":600,"dir":"in","kind":"partial","data":{"text":"Rolling out to production now."}}
9+
{"seq":9,"at":900,"dir":"in","kind":"final","data":{"text":"Deploy status: build is green.\n\nRolling out to production now."}}
10+
{"seq":10,"at":900,"dir":"out","kind":"editMessageText","data":{"payload":{"message_id":1001,"parse_mode":"HTML","text":"Deploy status: build is green.\n\nRolling out to production now."},"result":true,"target":"4242"}}
11+
{"seq":11,"at":900,"dir":"in","kind":"idle"}
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
// Telegram delivery trace goldens: replayable wire-level lifecycle recordings.
2+
//
3+
// Drives the REAL dispatcher wiring: buildTelegramMessageContext builds the
4+
// turn context, dispatchTelegramMessage builds its draft lanes and deliver
5+
// glue, and the injected dispatchReplyWithBufferedBlockDispatcher seam captures
6+
// the exact dispatcher/reply options the SDK reply dispatcher would consume.
7+
// The scripted IN steps stand in for the model loop; OUT events are the grammY
8+
// Bot API calls (sendMessage / editMessageText / sendChatAction /
9+
// deleteMessage) observed at a recording API mock with scripted message ids.
10+
// Refresh goldens with OPENCLAW_TRACE_UPDATE=1 (see delivery-trace harness docs).
11+
import type { Bot } from "grammy";
12+
import {
13+
deliveryTraceScenarios,
14+
expectDeliveryTraceMatchesGolden,
15+
runDeliveryTraceScenario,
16+
type DeliveryTraceInStep,
17+
type DeliveryTraceScenarioName,
18+
type WireRecorder,
19+
} from "openclaw/plugin-sdk/channel-contract-testing";
20+
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
21+
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
22+
import { describe, it, vi } from "vitest";
23+
import type { TelegramBotDeps } from "./bot-deps.js";
24+
import {
25+
baseTelegramMessageContextConfig,
26+
buildTelegramMessageContextForTest,
27+
} from "./bot-message-context.test-harness.js";
28+
import {
29+
dispatchTelegramMessage,
30+
resetTelegramReplyFenceForTests,
31+
} from "./bot-message-dispatch.js";
32+
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
33+
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
34+
35+
type RecordedWireCall = Parameters<WireRecorder["recordWireCall"]>[0];
36+
type BufferedDispatcherParams = Parameters<
37+
TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"]
38+
>[0];
39+
type BufferedDispatcherResult = Awaited<
40+
ReturnType<TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"]>
41+
>;
42+
43+
const TRACE_CHAT_ID = 4242;
44+
// The scripted preview-delete dwell (MIN_PREVIEW_DWELL_MS = 4s) plus margin, so
45+
// the detached teardown delete of an unfinalized preview lands in the trace.
46+
const TRACE_TEARDOWN_ADVANCE_MS = 5_000;
47+
48+
type TelegramTraceWireState = {
49+
recordWireCall: (call: RecordedWireCall) => void;
50+
/** Faults consumed by the next editMessageText call (flood-wait script). */
51+
wireFaults: Array<{ retryAfterMs: number }>;
52+
};
53+
54+
function compactParams(params: unknown): Record<string, unknown> {
55+
if (!params || typeof params !== "object") {
56+
return {};
57+
}
58+
return Object.fromEntries(
59+
Object.entries(params as Record<string, unknown>).filter(([, value]) => value !== undefined),
60+
);
61+
}
62+
63+
function createRecordingTelegramApi(state: TelegramTraceWireState): Bot["api"] {
64+
let messageCount = 1000;
65+
const nextMessageId = () => {
66+
messageCount += 1;
67+
return messageCount;
68+
};
69+
const api = {
70+
sendMessage: (chatId: number | string, text: string, params?: Record<string, unknown>) => {
71+
const message_id = nextMessageId();
72+
state.recordWireCall({
73+
method: "sendMessage",
74+
target: String(chatId),
75+
payload: { text, ...compactParams(params) },
76+
result: { message_id },
77+
});
78+
return Promise.resolve({ message_id });
79+
},
80+
editMessageText: (
81+
chatId: number | string,
82+
messageId: number,
83+
text: string,
84+
params?: Record<string, unknown>,
85+
) => {
86+
const fault = state.wireFaults.shift();
87+
if (fault) {
88+
const retryAfterS = Math.ceil(fault.retryAfterMs / 1000);
89+
state.recordWireCall({
90+
method: "editMessageText",
91+
target: String(chatId),
92+
payload: { message_id: messageId, text, ...compactParams(params) },
93+
result: { error_code: 429, parameters: { retry_after: retryAfterS } },
94+
});
95+
// Mirrors grammY's GrammyError surface for a Bot API flood wait: the
96+
// structured error_code/parameters fields drive isTelegramRateLimitError
97+
// and readTelegramRetryAfterMs in the preview suspension path.
98+
return Promise.reject(
99+
Object.assign(new Error(`429: Too Many Requests: retry after ${retryAfterS}`), {
100+
error_code: 429,
101+
parameters: { retry_after: retryAfterS },
102+
}),
103+
);
104+
}
105+
state.recordWireCall({
106+
method: "editMessageText",
107+
target: String(chatId),
108+
payload: { message_id: messageId, text, ...compactParams(params) },
109+
result: true,
110+
});
111+
return Promise.resolve(true);
112+
},
113+
sendChatAction: (
114+
chatId: number | string,
115+
action: string,
116+
params?: Record<string, unknown>,
117+
): Promise<true> => {
118+
state.recordWireCall({
119+
method: "sendChatAction",
120+
target: String(chatId),
121+
payload: { action, ...compactParams(params) },
122+
result: true,
123+
});
124+
return Promise.resolve(true);
125+
},
126+
deleteMessage: (chatId: number | string, messageId: number) => {
127+
state.recordWireCall({
128+
method: "deleteMessage",
129+
target: String(chatId),
130+
payload: { message_id: messageId },
131+
result: true,
132+
});
133+
return Promise.resolve(true);
134+
},
135+
setMessageReaction: () => Promise.resolve(true),
136+
};
137+
return api as unknown as Bot["api"];
138+
}
139+
140+
function createTraceTelegramDeps(captured: {
141+
dispatcherOptions?: BufferedDispatcherParams["dispatcherOptions"];
142+
replyOptions?: BufferedDispatcherParams["replyOptions"];
143+
resolveDispatch?: (result: BufferedDispatcherResult) => void;
144+
}): TelegramBotDeps {
145+
return {
146+
getRuntimeConfig: (() => ({
147+
config: baseTelegramMessageContextConfig,
148+
})) as unknown as TelegramBotDeps["getRuntimeConfig"],
149+
resolveStorePath: (() =>
150+
"/tmp/openclaw-trace-unused.json") as TelegramBotDeps["resolveStorePath"],
151+
// No session entry: keeps the transcript mirror and final-text recovery
152+
// inert so the trace stays a pure wire recording.
153+
getSessionEntry: (() => undefined) as TelegramBotDeps["getSessionEntry"],
154+
readChannelAllowFromStore: (async () => []) as TelegramBotDeps["readChannelAllowFromStore"],
155+
upsertChannelPairingRequest: (async () => ({
156+
code: "TRACE",
157+
created: false,
158+
})) as unknown as TelegramBotDeps["upsertChannelPairingRequest"],
159+
enqueueSystemEvent: (async () => {}) as unknown as TelegramBotDeps["enqueueSystemEvent"],
160+
// The capture seam: dispatchTelegramMessage hands the SDK reply dispatcher
161+
// its deliver wiring here; the trace drives those callbacks directly and
162+
// resolves this promise at the scripted idle step, exactly where the real
163+
// agent loop would settle.
164+
dispatchReplyWithBufferedBlockDispatcher: ((params: BufferedDispatcherParams) => {
165+
captured.dispatcherOptions = params.dispatcherOptions;
166+
captured.replyOptions = params.replyOptions;
167+
return new Promise((resolve) => {
168+
captured.resolveDispatch = resolve;
169+
});
170+
}) as TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"],
171+
buildModelsProviderData: (async () => ({
172+
byProvider: new Map<string, Set<string>>(),
173+
providers: [],
174+
resolvedDefault: { provider: "openai", model: "gpt-test" },
175+
modelNames: new Map<string, string>(),
176+
})) as unknown as TelegramBotDeps["buildModelsProviderData"],
177+
listSkillCommandsForAgents:
178+
(() => []) as unknown as TelegramBotDeps["listSkillCommandsForAgents"],
179+
wasSentByBot: (() => false) as TelegramBotDeps["wasSentByBot"],
180+
deliverInboundReplyWithMessageSendContext: (async () => ({
181+
status: "unsupported",
182+
reason: "missing_outbound_handler",
183+
})) as unknown as TelegramBotDeps["deliverInboundReplyWithMessageSendContext"],
184+
emitInternalMessageSentHook: (() => {}) as TelegramBotDeps["emitInternalMessageSentHook"],
185+
recordOutboundMessageForPromptContext: (async () =>
186+
true) as TelegramBotDeps["recordOutboundMessageForPromptContext"],
187+
};
188+
}
189+
190+
async function setupTelegramTrace(recorder: WireRecorder) {
191+
resetTelegramReplyFenceForTests();
192+
const state: TelegramTraceWireState = {
193+
recordWireCall: recorder.recordWireCall,
194+
wireFaults: [],
195+
};
196+
const api = createRecordingTelegramApi(state);
197+
// Real per-account handler so typing choreography (401 backoff, cooldowns)
198+
// runs the production path over the recording API.
199+
const sendChatActionHandler = createTelegramSendChatActionHandler({
200+
sendChatActionFn: (chatId, action, threadParams) =>
201+
api.sendChatAction(chatId, action, threadParams) as Promise<true>,
202+
logger: () => {},
203+
});
204+
// Real context construction (route, thread spec, typing cue, turn record);
205+
// ingress/spool stays out of scope — the context build is the delivery-side
206+
// boundary the dispatcher consumes.
207+
const context = await buildTelegramMessageContextForTest({
208+
message: {
209+
message_id: 1,
210+
date: 1_700_000_000,
211+
text: "run the deploy",
212+
from: { id: 42, first_name: "Alice" },
213+
chat: { id: TRACE_CHAT_ID, type: "private" },
214+
},
215+
botApi: api as unknown as Record<string, unknown>,
216+
sendChatActionHandler,
217+
});
218+
if (!context) {
219+
throw new Error("trace context was not built");
220+
}
221+
const captured: Parameters<typeof createTraceTelegramDeps>[0] = {};
222+
const dispatchDone = dispatchTelegramMessage({
223+
context,
224+
bot: { api } as unknown as Bot,
225+
cfg: baseTelegramMessageContextConfig,
226+
runtime: { log: () => {}, error: () => {} } as unknown as RuntimeEnv,
227+
// "first" is the single-use reply mode: the first visible message consumes
228+
// the reply target (prod default is "off", which would hide that contract).
229+
replyToMode: "first",
230+
streamMode: "partial",
231+
textLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
232+
telegramCfg: {},
233+
telegramDeps: createTraceTelegramDeps(captured),
234+
opts: { token: "trace-token" },
235+
});
236+
// Swallow here only to avoid an unhandled rejection warning racing the idle
237+
// step; the idle handler awaits dispatchDone and surfaces real failures.
238+
dispatchDone.catch(() => {});
239+
for (let drain = 0; drain < 50 && !captured.dispatcherOptions; drain += 1) {
240+
await vi.advanceTimersByTimeAsync(0);
241+
}
242+
const options = captured.dispatcherOptions;
243+
const replyOptions = captured.replyOptions;
244+
if (!options || !replyOptions || !captured.resolveDispatch) {
245+
throw new Error("dispatcher options were not captured");
246+
}
247+
248+
const pendingFinals: Array<Promise<void>> = [];
249+
let armedRetryAfterMs = 0;
250+
const counts = { tool: 0, block: 0, final: 0 };
251+
const deliverPayload = async (payload: ReplyPayload, info: { kind: "block" | "final" }) => {
252+
// Mirror the real dispatcher order: beforeDeliver (identity for telegram),
253+
// then deliver with the block's assistant-message context.
254+
const deliverInfo =
255+
info.kind === "block" ? { kind: info.kind, assistantMessageIndex: 0 } : { kind: info.kind };
256+
const prepared = (await options.beforeDeliver?.(payload, deliverInfo)) ?? payload;
257+
await options.deliver(prepared, deliverInfo);
258+
};
259+
260+
return async (step: DeliveryTraceInStep) => {
261+
switch (step.kind) {
262+
case "reply-start":
263+
await options.typingCallbacks?.onReplyStart?.();
264+
break;
265+
case "partial":
266+
await replyOptions?.onPartialReply?.({ text: step.text });
267+
break;
268+
case "block-final":
269+
counts.block += 1;
270+
await replyOptions?.onBlockReplyQueued?.({ text: step.text }, { assistantMessageIndex: 0 });
271+
await deliverPayload({ text: step.text }, { kind: "block" });
272+
break;
273+
case "tool-progress":
274+
await replyOptions?.onToolStart?.({ name: step.name, phase: step.phase });
275+
break;
276+
case "final": {
277+
counts.final += 1;
278+
const payload: ReplyPayload = {
279+
...(step.text !== undefined ? { text: step.text } : {}),
280+
...(step.mediaUrls ? { mediaUrls: step.mediaUrls } : {}),
281+
...(step.isError ? { isError: true } : {}),
282+
};
283+
// Not awaited inline: a flood-suspended final parks inside the draft
284+
// stream's retry_after wait, so the idle step advances the clock past
285+
// the suspension and settles it there instead of deadlocking here.
286+
const delivery = deliverPayload(payload, { kind: "final" });
287+
delivery.catch(() => {});
288+
pendingFinals.push(delivery);
289+
break;
290+
}
291+
case "cancel":
292+
// An aborted run stops emitting payloads; teardown happens on idle.
293+
break;
294+
case "wire-fault":
295+
state.wireFaults.push({ retryAfterMs: step.retryAfterMs });
296+
armedRetryAfterMs = step.retryAfterMs;
297+
break;
298+
case "idle": {
299+
await vi.advanceTimersByTimeAsync(0);
300+
if (armedRetryAfterMs > 0) {
301+
// Drain the flood suspension: the preview engine holds the newest
302+
// snapshot until retry_after expires, then flushes it in one edit.
303+
await vi.advanceTimersByTimeAsync(armedRetryAfterMs);
304+
}
305+
await Promise.all(pendingFinals);
306+
options.typingCallbacks?.onIdle?.();
307+
options.typingCallbacks?.onCleanup?.();
308+
captured.resolveDispatch?.({ queuedFinal: false, counts });
309+
await dispatchDone;
310+
// Unfinalized previews are torn down with a detached delete behind the
311+
// on-screen dwell; advance past it so the teardown lands in the trace.
312+
await vi.advanceTimersByTimeAsync(TRACE_TEARDOWN_ADVANCE_MS);
313+
break;
314+
}
315+
}
316+
};
317+
}
318+
319+
const TELEGRAM_TRACE_SCENARIOS: readonly DeliveryTraceScenarioName[] = [
320+
"streaming-happy",
321+
"final-only",
322+
"cancel-mid-stream",
323+
"rate-limit-during-preview",
324+
"overflow-pagination",
325+
];
326+
327+
describe("telegram delivery trace goldens", () => {
328+
for (const scenarioName of TELEGRAM_TRACE_SCENARIOS) {
329+
it(`records ${scenarioName}`, async () => {
330+
const events = await runDeliveryTraceScenario({
331+
scenario: deliveryTraceScenarios[scenarioName],
332+
setup: setupTelegramTrace,
333+
});
334+
expectDeliveryTraceMatchesGolden({
335+
goldenUrl: new URL(`./__traces__/${scenarioName}.trace.jsonl`, import.meta.url),
336+
events,
337+
});
338+
});
339+
}
340+
});

0 commit comments

Comments
 (0)