feat: telegram draft streaming

This commit is contained in:
Peter Steinberger
2026-01-07 11:08:11 +01:00
parent e8420bd047
commit a700f9896d
26 changed files with 458 additions and 52 deletions

View File

@@ -106,6 +106,12 @@ describe("directive parsing", () => {
expect(res.reasoningLevel).toBe("on");
});
it("matches reasoning stream directive", () => {
const res = extractReasoningDirective("/reasoning stream please");
expect(res.hasDirective).toBe(true);
expect(res.reasoningLevel).toBe("stream");
});
it("matches elevated with leading space", () => {
const res = extractElevatedDirective(" please /elevated on now");
expect(res.hasDirective).toBe(true);

View File

@@ -401,7 +401,8 @@ export async function getReplyFromConfig(
agentCfg?.blockStreamingBreak === "message_end"
? "message_end"
: "text_end";
const blockStreamingEnabled = resolvedBlockStreaming === "on";
const blockStreamingEnabled =
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
const blockReplyChunking = blockStreamingEnabled
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
: undefined;

View File

@@ -197,6 +197,9 @@ export async function runReplyAgent(params: {
let fallbackProvider = followupRun.run.provider;
let fallbackModel = followupRun.run.model;
try {
const allowPartialStream = !(
followupRun.run.reasoningLevel === "stream" && opts?.onReasoningStream
);
const fallbackResult = await runWithModelFallback({
cfg: followupRun.run.config,
provider: followupRun.run.provider,
@@ -227,32 +230,41 @@ export async function runReplyAgent(params: {
runId,
blockReplyBreak: resolvedBlockStreamingBreak,
blockReplyChunking,
onPartialReply: opts?.onPartialReply
? async (payload) => {
let text = payload.text;
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
onPartialReply:
opts?.onPartialReply && allowPartialStream
? async (payload) => {
let text = payload.text;
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
if (!isHeartbeat) {
await typing.startTypingOnText(text);
}
await opts.onPartialReply?.({
text,
mediaUrls: payload.mediaUrls,
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
if (!isHeartbeat) {
await typing.startTypingOnText(text);
}
await opts.onPartialReply?.({
text,
: undefined,
onReasoningStream: opts?.onReasoningStream
? async (payload) => {
await opts.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
}

View File

@@ -385,7 +385,7 @@ export async function handleDirectiveOnly(params: {
}
if (directives.hasReasoningDirective && !directives.reasoningLevel) {
return {
text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off.`,
text: `Unrecognized reasoning level "${directives.rawReasoningLevel ?? ""}". Valid levels: on, off, stream.`,
};
}
if (directives.hasElevatedDirective && !directives.elevatedLevel) {
@@ -563,7 +563,9 @@ export async function handleDirectiveOnly(params: {
parts.push(
directives.reasoningLevel === "off"
? `${SYSTEM_MARK} Reasoning visibility disabled.`
: `${SYSTEM_MARK} Reasoning visibility enabled.`,
: directives.reasoningLevel === "stream"
? `${SYSTEM_MARK} Reasoning stream enabled (Telegram only).`
: `${SYSTEM_MARK} Reasoning visibility enabled.`,
);
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {

View File

@@ -17,4 +17,9 @@ describe("normalizeReasoningLevel", () => {
expect(normalizeReasoningLevel("show")).toBe("on");
expect(normalizeReasoningLevel("hide")).toBe("off");
});
it("accepts stream", () => {
expect(normalizeReasoningLevel("stream")).toBe("stream");
expect(normalizeReasoningLevel("streaming")).toBe("stream");
});
});

View File

@@ -1,7 +1,7 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
// Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel(
@@ -82,5 +82,6 @@ export function normalizeReasoningLevel(
)
)
return "on";
if (["stream", "streaming", "draft", "live"].includes(key)) return "stream";
return undefined;
}

View File

@@ -5,8 +5,10 @@ export type GetReplyOptions = {
onTypingController?: (typing: TypingController) => void;
isHeartbeat?: boolean;
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
onBlockReply?: (payload: ReplyPayload) => Promise<void> | void;
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
disableBlockStreaming?: boolean;
};
export type ReplyPayload = {