Skip to content

Commit 1ad484f

Browse files
adaguesobviyus
authored andcommitted
fix(telegram): mirror outbound replies to session transcript
Telegram's deliverReplies dispatches via Grammy SDK directly, bypassing deliverOutboundPayloads where the channel-mirror writer runs. Outbound assistant replies were never appended to the session transcript, leaving Telegram .jsonl files empty (the sessions.json sessionFile path was populated but the file was never created on disk). Add an optional transcriptMirror callback param to deliverReplies and populate it from bot-message-dispatch's deliveryBaseOptions. Reuses the existing appendAssistantMessageToSessionTranscript helper that deliverOutboundPayloads already calls. Also mirrors preview-finalized replies so the transcript captures all final assistant output. Plugin SDK boundary expansion: re-export appendAssistantMessageToSessionTranscript from plugin-sdk/agent-harness-runtime so extension code can call it without reaching into core src/. API baseline regenerated. Addresses #75991 for telegram + CLI runtime combinations. Supersedes #77484 (incorporates reviewer feedback: preview- finalized mirror + changelog entry).
1 parent a99729f commit 1ad484f

6 files changed

Lines changed: 220 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ Docs: https://docs.openclaw.ai
166166
- Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys.
167167
- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys.
168168
- Telegram: persist reply-chain message cache records as a compact append log instead of rewriting the full cache on every inbound message, reducing large-group turn latency.
169+
- Telegram/CLI-backend: mirror outbound replies to the session transcript so CLI-backend agent responses create `.jsonl` session files, preventing `sessionId=unknown` on subsequent runs. Fixes #75991.
169170
- QQBot: route gateway WebSocket connections through the ambient proxy agent so deployments with `https_proxy`, `HTTPS_PROXY`, or `HTTP_PROXY` can reach the QQ gateway. (#72961) Thanks @xialonglee.
170171
- Agents/subagents: treat `sessions_spawn` `model: "default"` as the default-model fallback and ignore ACP-only stream targets for native sub-agent spawns. Fixes #72078. (#72101) Thanks @xialonglee.
171172
- Agents/failover: stop retrying assistant-prefill format rejections across auth profiles or model fallbacks, surfacing the deterministic provider error instead of requeueing the lane. Fixes #79688. (#79728) Thanks @hclsys.

extensions/telegram/src/bot-message-dispatch.runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
loadSessionStore,
3+
resolveAndPersistSessionFile,
34
resolveSessionStoreEntry,
45
} from "openclaw/plugin-sdk/session-store-runtime";
56
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,16 @@ const createChannelMessageReplyPipeline = vi.hoisted(() =>
5252
})),
5353
);
5454
const wasSentByBot = vi.hoisted(() => vi.fn(() => false));
55+
const appendSessionTranscriptMessage = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1" })));
56+
const emitSessionTranscriptUpdate = vi.hoisted(() => vi.fn());
5557
const loadSessionStore = vi.hoisted(() => vi.fn());
5658
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
59+
const resolveAndPersistSessionFile = vi.hoisted(() =>
60+
vi.fn(async () => ({
61+
sessionFile: "/tmp/session.jsonl",
62+
sessionEntry: { sessionId: "s1", sessionFile: "/tmp/session.jsonl" },
63+
})),
64+
);
5765
const generateTopicLabel = vi.hoisted(() => vi.fn());
5866
const describeStickerImage = vi.hoisted(() => vi.fn(async () => null));
5967
const loadModelCatalog = vi.hoisted(() => vi.fn(async () => ({})));
@@ -86,6 +94,15 @@ vi.mock("openclaw/plugin-sdk/channel-message", async (importOriginal) => {
8694
};
8795
});
8896

97+
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
98+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
99+
return {
100+
...actual,
101+
appendSessionTranscriptMessage,
102+
emitSessionTranscriptUpdate,
103+
};
104+
});
105+
89106
vi.mock("./bot/delivery.js", () => ({
90107
deliverReplies,
91108
emitInternalMessageSentHook,
@@ -111,6 +128,7 @@ vi.mock("./bot-message-dispatch.runtime.js", () => ({
111128
generateTopicLabel,
112129
getAgentScopedMediaLocalRoots,
113130
loadSessionStore,
131+
resolveAndPersistSessionFile,
114132
resolveAutoTopicLabelConfig: resolveAutoTopicLabelConfigRuntime,
115133
resolveChunkMode,
116134
resolveMarkdownTableMode,
@@ -196,8 +214,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
196214
listSkillCommandsForAgents.mockReset();
197215
createChannelMessageReplyPipeline.mockReset();
198216
wasSentByBot.mockReset();
217+
appendSessionTranscriptMessage.mockReset();
218+
emitSessionTranscriptUpdate.mockReset();
199219
loadSessionStore.mockReset();
200220
resolveStorePath.mockReset();
221+
resolveAndPersistSessionFile.mockReset();
201222
generateTopicLabel.mockReset();
202223
getAgentScopedMediaLocalRoots.mockClear();
203224
resolveChunkMode.mockClear();
@@ -248,6 +269,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
248269
});
249270
wasSentByBot.mockReturnValue(false);
250271
resolveStorePath.mockReturnValue("/tmp/sessions.json");
272+
resolveAndPersistSessionFile.mockResolvedValue({
273+
sessionFile: "/tmp/session.jsonl",
274+
sessionEntry: { sessionId: "s1", sessionFile: "/tmp/session.jsonl" },
275+
});
251276
loadSessionStore.mockReturnValue({});
252277
generateTopicLabel.mockResolvedValue("Topic label");
253278
describeStickerImage.mockResolvedValue(null);
@@ -906,6 +931,40 @@ describe("dispatchTelegramMessage draft streaming", () => {
906931
);
907932
});
908933

934+
it("mirrors preview-finalized finals into the session transcript", async () => {
935+
setupDraftStreams({ answerMessageId: 2001 });
936+
const context = createContext();
937+
context.ctxPayload.SessionKey = "agent:default:telegram:direct:123";
938+
loadSessionStore.mockReturnValue({
939+
"agent:default:telegram:direct:123": { sessionId: "s1" },
940+
});
941+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
942+
await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" });
943+
return { queuedFinal: true };
944+
});
945+
946+
await dispatchWithContext({ context });
947+
948+
expect(appendSessionTranscriptMessage).toHaveBeenCalledWith(
949+
expect.objectContaining({
950+
transcriptPath: "/tmp/session.jsonl",
951+
message: expect.objectContaining({
952+
role: "assistant",
953+
provider: "openclaw",
954+
model: "delivery-mirror",
955+
content: [{ type: "text", text: "Final answer" }],
956+
}),
957+
}),
958+
);
959+
expect(emitSessionTranscriptUpdate).toHaveBeenCalledWith(
960+
expect.objectContaining({
961+
sessionFile: "/tmp/session.jsonl",
962+
sessionKey: "agent:default:telegram:direct:123",
963+
messageId: "m1",
964+
}),
965+
);
966+
});
967+
909968
it("streams block and final text through the same answer message", async () => {
910969
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
911970
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import path from "node:path";
12
import type { Bot } from "grammy";
3+
import {
4+
appendSessionTranscriptMessage,
5+
emitSessionTranscriptUpdate,
6+
} from "openclaw/plugin-sdk/agent-harness-runtime";
27
import {
38
DEFAULT_TIMING,
49
logAckFailure,
@@ -60,6 +65,7 @@ import {
6065
resolveAutoTopicLabelConfig,
6166
resolveChunkMode,
6267
resolveMarkdownTableMode,
68+
resolveAndPersistSessionFile,
6369
resolveSessionStoreEntry,
6470
} from "./bot-message-dispatch.runtime.js";
6571
import type { TelegramBotOptions } from "./bot.types.js";
@@ -133,6 +139,8 @@ type DispatchTelegramMessageParams = {
133139

134140
type TelegramReasoningLevel = "off" | "on" | "stream";
135141

142+
type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] };
143+
136144
type TelegramReplyFenceState = {
137145
generation: number;
138146
activeDispatches: number;
@@ -235,6 +243,94 @@ function resolveTelegramReasoningLevel(params: {
235243
return configDefault;
236244
}
237245

246+
function resolveTelegramMirroredTranscriptText(
247+
payload: TelegramTranscriptMirrorPayload,
248+
): string | null {
249+
const mediaUrls = payload.mediaUrls?.filter((url) => url.trim()) ?? [];
250+
if (mediaUrls.length > 0) {
251+
return mediaUrls
252+
.map((url) => {
253+
const pathname = url.split("#")[0]?.split("?")[0] ?? url;
254+
const base = path.basename(pathname);
255+
return base && base !== "." && base !== "/" ? base : "media";
256+
})
257+
.join(", ");
258+
}
259+
260+
const text = payload.text?.trim();
261+
return text ? text : null;
262+
}
263+
264+
async function mirrorTelegramAssistantReplyToTranscript(params: {
265+
cfg: OpenClawConfig;
266+
route: TelegramMessageContext["route"];
267+
sessionKey: string;
268+
telegramDeps: TelegramBotDeps;
269+
payload: TelegramTranscriptMirrorPayload;
270+
}) {
271+
const text = resolveTelegramMirroredTranscriptText(params.payload);
272+
if (!text) {
273+
return;
274+
}
275+
const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, {
276+
agentId: params.route.agentId,
277+
});
278+
const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
279+
skipCache: true,
280+
});
281+
const sessionEntry = resolveSessionStoreEntry({
282+
store,
283+
sessionKey: params.sessionKey,
284+
}).existing;
285+
if (!sessionEntry?.sessionId) {
286+
return;
287+
}
288+
const { sessionFile } = await resolveAndPersistSessionFile({
289+
sessionId: sessionEntry.sessionId,
290+
sessionKey: params.sessionKey,
291+
sessionStore: store,
292+
storePath,
293+
sessionEntry,
294+
agentId: params.route.agentId,
295+
sessionsDir: path.dirname(storePath),
296+
});
297+
const message = {
298+
role: "assistant" as const,
299+
content: [{ type: "text" as const, text }],
300+
api: "openai-responses",
301+
provider: "openclaw",
302+
model: "delivery-mirror",
303+
usage: {
304+
input: 0,
305+
output: 0,
306+
total: 0,
307+
prompt_tokens: 0,
308+
completion_tokens: 0,
309+
total_tokens: 0,
310+
cache: {
311+
read: 0,
312+
write: 0,
313+
cacheRead: 0,
314+
cacheWrite: 0,
315+
total: 0,
316+
},
317+
},
318+
stopReason: "stop" as const,
319+
timestamp: Date.now(),
320+
};
321+
const { messageId } = await appendSessionTranscriptMessage({
322+
transcriptPath: sessionFile,
323+
message,
324+
config: params.cfg,
325+
});
326+
emitSessionTranscriptUpdate({
327+
sessionFile,
328+
sessionKey: params.sessionKey,
329+
message,
330+
messageId,
331+
});
332+
}
333+
238334
const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300;
239335

240336
function clipProgressMarkdownText(text: string): string {
@@ -710,6 +806,7 @@ export const dispatchTelegramMessage = async ({
710806
});
711807
}
712808
};
809+
const sessionKey = ctxPayload.SessionKey;
713810
const deliveryBaseOptions = {
714811
chatId: String(chatId),
715812
accountId: route.accountId,
@@ -731,6 +828,17 @@ export const dispatchTelegramMessage = async ({
731828
replyQuotePosition,
732829
replyQuoteEntities,
733830
replyQuoteByMessageId,
831+
transcriptMirror: sessionKey
832+
? async (payload: TelegramTranscriptMirrorPayload) => {
833+
await mirrorTelegramAssistantReplyToTranscript({
834+
cfg,
835+
route,
836+
sessionKey,
837+
telegramDeps,
838+
payload,
839+
});
840+
}
841+
: undefined,
734842
};
735843
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
736844
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
@@ -902,6 +1010,15 @@ export const dispatchTelegramMessage = async ({
9021010
isGroup: deliveryBaseOptions.mirrorIsGroup,
9031011
groupId: deliveryBaseOptions.mirrorGroupId,
9041012
});
1013+
if (deliveryBaseOptions.transcriptMirror && result.delivery.content) {
1014+
void deliveryBaseOptions
1015+
.transcriptMirror({ text: result.delivery.content })
1016+
.catch((err: unknown) => {
1017+
logVerbose(
1018+
`telegram preview-finalized transcriptMirror failed: ${formatErrorMessage(err)}`,
1019+
);
1020+
});
1021+
}
9051022
};
9061023
const deliverLaneText = createLaneTextDeliverer({
9071024
lanes,

extensions/telegram/src/bot/delivery.replies.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,13 +702,16 @@ export async function deliverReplies(params: {
702702
replyQuoteByMessageId?: TelegramNativeQuoteCandidateByMessageId;
703703
/** Override media loader (tests). */
704704
mediaLoader?: typeof loadWebMedia;
705+
transcriptMirror?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
705706
}): Promise<{ delivered: boolean }> {
706707
const progress: DeliveryProgress = {
707708
hasReplied: false,
708709
hasDelivered: false,
709710
deliveredCount: 0,
710711
};
711712
const mediaLoader = params.mediaLoader ?? loadWebMedia;
713+
const transcriptMirror = params.transcriptMirror;
714+
const deliveredContents: Array<{ text: string; mediaUrls: string[] }> = [];
712715
const hookRunner = getGlobalHookRunner();
713716
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
714717
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
@@ -873,6 +876,10 @@ export async function deliverReplies(params: {
873876
firstDeliveredMessageId,
874877
});
875878

879+
if (progress.deliveredCount > deliveredCountBeforeReply && transcriptMirror) {
880+
deliveredContents.push({ text: contentForSentHook, mediaUrls: mediaList });
881+
}
882+
876883
emitMessageSentHooks({
877884
hookRunner,
878885
enabled: hasMessageSentHooks,
@@ -902,5 +909,23 @@ export async function deliverReplies(params: {
902909
}
903910
}
904911

912+
if (progress.hasDelivered && transcriptMirror) {
913+
const text = deliveredContents
914+
.map((content) => content.text)
915+
.filter(Boolean)
916+
.join("\n\n");
917+
const mediaUrls = deliveredContents.flatMap((content) => content.mediaUrls);
918+
if (text || mediaUrls.length > 0) {
919+
try {
920+
await transcriptMirror({
921+
text: text || undefined,
922+
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
923+
});
924+
} catch (mirrorErr) {
925+
logVerbose(`telegram transcriptMirror failed: ${formatErrorMessage(mirrorErr)}`);
926+
}
927+
}
928+
}
929+
905930
return { delivered: progress.hasDelivered };
906931
}

extensions/telegram/src/bot/delivery.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,23 @@ describe("deliverReplies", () => {
213213
expect(sendMessage.mock.calls[0]?.[1]).toBe("hello");
214214
});
215215

216+
it("mirrors delivered replies once after successful sends", async () => {
217+
const runtime = createRuntime(false);
218+
const transcriptMirror = vi.fn();
219+
const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "123" } });
220+
const bot = createBot({ sendMessage });
221+
222+
await deliverWith({
223+
replies: [{ text: "hello" }, { text: "world" }],
224+
runtime,
225+
bot,
226+
transcriptMirror,
227+
});
228+
229+
expect(transcriptMirror).toHaveBeenCalledOnce();
230+
expect(transcriptMirror).toHaveBeenCalledWith({ text: "hello\n\nworld", mediaUrls: undefined });
231+
});
232+
216233
it("renders shared interactive reply buttons as Telegram inline buttons", async () => {
217234
const runtime = createRuntime(false);
218235
const sendMessage = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" } });

0 commit comments

Comments
 (0)