Compare commits

..

1 Commits

Author SHA1 Message Date
Ayaan Zaidi
c80a3547ac fix(telegram): preserve literal reasoning tags 2026-06-27 10:23:44 -07:00
10 changed files with 92 additions and 158 deletions

View File

@@ -3608,10 +3608,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
await run;
});
it("suppresses reasoning-only finals without raw text fallback", async () => {
it("suppresses typed reasoning-only finals without raw text fallback", async () => {
setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "<think>hidden</think>" }, { kind: "final" });
await dispatcherOptions.deliver(
{ text: "<think>hidden</think>", isReasoning: true },
{ kind: "final" },
);
return { queuedFinal: true };
});
@@ -3621,6 +3624,25 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("keeps unflagged angle-bracket text visible on the answer lane", async () => {
const { answerDraftStream } = setupDraftStreams({
answerMessageId: 2001,
reasoningMessageId: 3001,
});
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver(
{ text: "Before <think>literal tag text after" },
{ kind: "final" },
);
return { queuedFinal: true };
});
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenCalledWith("Before <think>literal tag text after");
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not add silent fallback when source delivery is message-tool-only", async () => {
setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 });
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({

View File

@@ -30,6 +30,11 @@ describe("markdownToTelegramHtml", () => {
"<script>nope</script>",
"&lt;script&gt;nope&lt;/script&gt;",
],
[
"escapes literal reasoning-looking tags",
"Before <think>literal tag text after",
"Before &lt;think&gt;literal tag text after",
],
["escapes unsafe characters", "a & b < c", "a &amp; b &lt; c"],
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
["renders lists without block HTML", "- one\n- two", "• one\n• two"],

View File

@@ -3,10 +3,23 @@ import { describe, expect, it } from "vitest";
import { splitTelegramReasoningText } from "./reasoning-lane-coordinator.js";
describe("splitTelegramReasoningText", () => {
it("splits real tagged reasoning and answer", () => {
expect(splitTelegramReasoningText("<think>example</think>Done")).toEqual({
it("keeps unflagged angle-bracket reasoning tags in the answer lane", () => {
const text = "<think>example</think>Done";
expect(splitTelegramReasoningText(text)).toEqual({
answerText: text,
});
});
it("keeps unclosed unflagged reasoning-looking text in the answer lane", () => {
const text = "Before <think>unclosed content after";
expect(splitTelegramReasoningText(text)).toEqual({
answerText: text,
});
});
it("formats tagged text when the payload is explicitly reasoning", () => {
expect(splitTelegramReasoningText("<think>example</think>Done", true)).toEqual({
reasoningText: "Thinking\n\n_example_",
answerText: "Done",
});
});
@@ -25,7 +38,7 @@ describe("splitTelegramReasoningText", () => {
});
it("does not emit partial reasoning tag prefixes", () => {
expect(splitTelegramReasoningText(" <thi")).toStrictEqual({});
expect(splitTelegramReasoningText(" <thi", true)).toStrictEqual({});
});
it("keeps visible Thinking-prefixed answers in the answer lane", () => {

View File

@@ -73,6 +73,10 @@ export function splitTelegramReasoningText(
return {};
}
if (isReasoning !== true) {
return { answerText: text };
}
const trimmed = text.trim();
if (isPartialReasoningTagPrefix(trimmed)) {
return {};

View File

@@ -1170,6 +1170,22 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
});
it("escapes literal reasoning-looking tags on the text path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
await sendMessageTelegram("123", "Before <think>literal tag text after", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botApi.sendMessage).toHaveBeenCalledWith(
"123",
"Before &lt;think&gt;literal tag text after",
{ parse_mode: "HTML" },
);
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
});
it("escapes HTML media tags on the text path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });

View File

@@ -514,7 +514,6 @@ export async function compactEmbeddedAgentSessionDirect(
agentId: fallbackAgentId,
sessionId: params.sessionId,
sessionKey: fallbackSessionKey,
abortSignal: params.abortSignal,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: params.config,

View File

@@ -2761,8 +2761,6 @@ describe("runWithModelFallback", () => {
it("does not fall back on user aborts", async () => {
const cfg = makeCfg();
const controller = new AbortController();
controller.abort(Object.assign(new Error("timeout"), { name: "TimeoutError" }));
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
@@ -2773,7 +2771,6 @@ describe("runWithModelFallback", () => {
cfg,
provider: "openai",
model: "gpt-4.1-mini",
abortSignal: controller.signal,
run,
}),
).rejects.toThrow("aborted");
@@ -2800,94 +2797,6 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("does not fall back when user cancels with AbortError reason", async () => {
const cfg = makeCfg();
const controller = new AbortController();
controller.abort(Object.assign(new Error("cancelled"), { name: "AbortError" }));
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
.mockResolvedValueOnce("should not run");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
abortSignal: controller.signal,
run,
}),
).rejects.toThrow("aborted");
expect(run).toHaveBeenCalledTimes(1);
});
it("does not fall back when caller cancellation uses a string reason", async () => {
const cfg = makeCfg();
const controller = new AbortController();
controller.abort("Cancelled by operator.");
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
.mockResolvedValueOnce("should not run");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
abortSignal: controller.signal,
run,
}),
).rejects.toThrow("aborted");
expect(run).toHaveBeenCalledTimes(1);
});
it("does not fall back when caller cancellation throws a plain error", async () => {
const cfg = makeCfg();
const controller = new AbortController();
controller.abort("Cancelled by operator.");
const run = vi
.fn()
.mockRejectedValueOnce(new Error("Cancelled by operator."))
.mockResolvedValueOnce("should not run");
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
abortSignal: controller.signal,
run,
}),
).rejects.toThrow("Cancelled by operator.");
expect(run).toHaveBeenCalledTimes(1);
});
it("falls back when AbortError comes from the LLM provider (no external signal)", async () => {
const cfg = makeProviderFallbackCfg("openai");
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("This operation was aborted"), { name: "AbortError" }),
)
.mockResolvedValueOnce({ payloads: [{ text: "fallback ok" }] });
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toEqual({ payloads: [{ text: "fallback ok" }] });
expect(run).toHaveBeenCalledTimes(2);
expect(result.attempts[0]?.provider).toBe("openai");
expect(result.attempts[0]?.error).toBe("This operation was aborted");
});
it("does not fall back when the caller abort signal timed out", async () => {
const cfg = makeCfg();
const timeoutReason = new Error("chat run timed out");
@@ -2945,37 +2854,6 @@ describe("runWithModelFallback", () => {
expect(classifyResult).toHaveBeenCalledTimes(1);
});
it("does not fall back when a user AbortError is classified from the result", async () => {
const cfg = makeProviderFallbackCfg("openai");
const abortReason = new Error("chat run cancelled");
abortReason.name = "AbortError";
const controller = new AbortController();
controller.abort(abortReason);
const run = vi
.fn()
.mockResolvedValueOnce({ payloads: [] })
.mockResolvedValueOnce({ payloads: [{ text: "fallback should not run" }] });
const classifyResult = vi.fn(() => ({
message: "This operation was aborted",
reason: "timeout" as const,
code: "terminal_abort",
}));
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "m1",
abortSignal: controller.signal,
run,
classifyResult,
}),
).rejects.toThrow("This operation was aborted");
expect(run).toHaveBeenCalledTimes(1);
expect(classifyResult).toHaveBeenCalledTimes(1);
});
it("does not fall back when a restart abort is classified from the result", async () => {
const cfg = makeProviderFallbackCfg("openai");
const controller = new AbortController();

View File

@@ -39,6 +39,7 @@ import {
describeFailoverError,
isFailoverError,
isNonProviderRuntimeCoordinationError,
isTimeoutError,
} from "./failover-error.js";
import {
shouldAllowCooldownProbeForReason,
@@ -188,6 +189,25 @@ type ModelFallbackRunFn<T> = (
options?: ModelFallbackRunOptions,
) => Promise<T>;
/**
* Fallback abort check. Only treats explicit AbortError names as user aborts.
* Message-based checks (e.g., "aborted") can mask timeouts and skip fallback.
*/
function isFallbackAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
if (isFailoverError(err)) {
return false;
}
const name = "name" in err ? String(err.name) : "";
return name === "AbortError";
}
function shouldRethrowAbort(err: unknown): boolean {
return isFallbackAbortError(err) && !isTimeoutError(err);
}
function isTerminalAbort(signal: AbortSignal | undefined): boolean {
if (!signal?.aborted) {
return false;
@@ -205,10 +225,6 @@ function isTerminalAbort(signal: AbortSignal | undefined): boolean {
return reason.name === "ClientDisconnectError";
}
function isCallerAbortSignal(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}
function createModelCandidateCollector(allowlist: Set<string> | null | undefined): {
candidates: ModelCandidate[];
addExplicitCandidate: (candidate: ModelCandidate) => void;
@@ -351,10 +367,7 @@ async function runFallbackCandidate<T>(params: {
if (isNonProviderRuntimeCoordinationError(err)) {
throw err;
}
if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) {
throw err;
}
if (isAgentRunRestartAbortReason(err)) {
if (isTerminalAbort(params.abortSignal)) {
throw err;
}
// Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED)
@@ -365,6 +378,9 @@ async function runFallbackCandidate<T>(params: {
sessionId: params.attribution?.sessionId,
lane: params.attribution?.lane,
});
if (shouldRethrowAbort(err) && !normalizedFailover) {
throw err;
}
return { ok: false, error: normalizedFailover ?? err };
}
}
@@ -414,7 +430,7 @@ async function runFallbackAttempt<T>(params: {
attribution: params.attribution,
});
if (classifiedError) {
if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) {
if (isTerminalAbort(params.abortSignal)) {
throw toErrorObject(classifiedError, "Non-Error thrown");
}
const preserveResultOnExhaustion =
@@ -1307,8 +1323,7 @@ function shouldDiscardDeferredSessionSuspension(params: {
}): boolean {
return (
isTerminalAbort(params.abortSignal) ||
isCallerAbortSignal(params.abortSignal) ||
isAgentRunRestartAbortReason(params.error) ||
shouldRethrowAbort(params.error) ||
isCommandLaneTaskTimeoutError(params.error) ||
isNonProviderRuntimeCoordinationError(params.error) ||
isLikelyContextOverflowError(formatErrorMessage(params.error))

View File

@@ -283,7 +283,6 @@ export function createCronPromptExecutor(params: {
agentDir: params.agentDir,
agentId: params.agentId,
sessionKey: params.runSessionKey,
abortSignal: params.abortSignal,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: params.cfgWithAgentDefaults,

View File

@@ -665,21 +665,4 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)",
expect(capturedFallbacksOverride).toEqual(["openai/gpt-4o"]);
});
it("forwards the cron abort signal into runWithModelFallback", async () => {
const controller = new AbortController();
let capturedAbortSignal: AbortSignal | undefined;
runWithModelFallbackMock.mockImplementation(
async (params: { provider: string; model: string; abortSignal?: AbortSignal }) => {
capturedAbortSignal = params.abortSignal;
return makeSuccessfulRunResult();
},
);
controller.abort(new Error("cron: job execution timed out"));
await runCronIsolatedAgentTurn(makeParams({ abortSignal: controller.signal }));
expect(capturedAbortSignal).toBe(controller.signal);
});
});