Skip to content

Commit b34c188

Browse files
committed
fix(agents): surface Claude CLI thinking token progress
1 parent 32d791f commit b34c188

12 files changed

Lines changed: 417 additions & 25 deletions

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4137,6 +4137,32 @@ describe("dispatchTelegramMessage draft streaming", () => {
41374137
);
41384138
});
41394139

4140+
it("renders CLI thinking token progress in the Telegram progress draft", async () => {
4141+
const draftStream = createSequencedDraftStream(2001);
4142+
createTelegramDraftStream.mockReturnValue(draftStream);
4143+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
4144+
await replyOptions?.onReplyStart?.();
4145+
await replyOptions?.onAssistantMessageStart?.();
4146+
await replyOptions?.onReasoningProgress?.({ progressTokens: 50 });
4147+
await replyOptions?.onReasoningProgress?.({ progressTokens: 200 });
4148+
return { queuedFinal: false };
4149+
});
4150+
4151+
await dispatchWithContext({
4152+
context: createContext(),
4153+
streamMode: "progress",
4154+
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
4155+
});
4156+
4157+
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
4158+
expect(draftStream.updatePreview).toHaveBeenLastCalledWith(
4159+
telegramProgressPreview(
4160+
"Shelling\n\n🧠 Thinking… (~200 tokens)",
4161+
"<b>Shelling</b>\n<b>🧠 Thinking… (~200 tokens)</b>",
4162+
),
4163+
);
4164+
});
4165+
41404166
it("renders model markdown in streamed reasoning and commentary lanes", async () => {
41414167
const draftStream = createSequencedDraftStream(2001);
41424168
createTelegramDraftStream.mockReturnValue(draftStream);

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,19 @@ function formatTelegramProgressLine(text: string): string {
394394
: formatProgressAsMarkdownCode(text);
395395
}
396396

397+
function buildTelegramThinkingProgressLine(progressTokens: number): ChannelProgressDraftLine {
398+
const label = `Thinking… (~${Math.round(progressTokens)} tokens)`;
399+
const text = `🧠 ${label}`;
400+
return {
401+
id: "reasoning:token-progress",
402+
kind: "item",
403+
icon: "🧠",
404+
label,
405+
text,
406+
prefix: false,
407+
};
408+
}
409+
397410
function escapeTelegramProgressHtml(text: string): string {
398411
return text
399412
.replaceAll("&", "&amp;")
@@ -2631,6 +2644,15 @@ export const dispatchTelegramMessage = async ({
26312644
await pushStreamReasoningProgress(payload);
26322645
})
26332646
: undefined,
2647+
onReasoningProgress: answerLane.stream
2648+
? (payload) =>
2649+
enqueueDraftLaneEvent(async () => {
2650+
await pushStreamToolProgress(
2651+
buildTelegramThinkingProgressLine(payload.progressTokens),
2652+
{ startImmediately: true },
2653+
);
2654+
})
2655+
: undefined,
26342656
onAssistantMessageStart: answerLane.stream
26352657
? () =>
26362658
enqueueDraftLaneEvent(async () => {

src/agents/cli-output.test.ts

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Tests CLI JSON/JSONL output parsing, streamed deltas, and error extraction. */
2+
import { readFileSync } from "node:fs";
23
import { describe, expect, it } from "vitest";
34
import {
45
createCliJsonlStreamingParser,
@@ -7,6 +8,7 @@ import {
78
parseCliJsonl,
89
parseCliOutput,
910
supportsCliJsonlToolEvents,
11+
type CliThinkingProgress,
1012
type CliToolResultDelta,
1113
type CliToolUseStartDelta,
1214
} from "./cli-output.js";
@@ -1286,7 +1288,7 @@ describe("createCliJsonlStreamingParser", () => {
12861288
event: {
12871289
type: "content_block_delta",
12881290
index: 1,
1289-
delta: { type: "input_json_delta", partial_json: "{\"file_path\":\"x\"}" },
1291+
delta: { type: "input_json_delta", partial_json: '{"file_path":"x"}' },
12901292
},
12911293
}),
12921294
JSON.stringify({
@@ -1322,6 +1324,127 @@ describe("createCliJsonlStreamingParser", () => {
13221324
]);
13231325
});
13241326

1327+
it("streams indexless thinking deltas from content block framing", () => {
1328+
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
1329+
const parser = createCliJsonlStreamingParser({
1330+
backend: {
1331+
command: "local-cli",
1332+
output: "jsonl",
1333+
jsonlDialect: "claude-stream-json",
1334+
sessionIdFields: ["session_id"],
1335+
},
1336+
providerId: "local-cli",
1337+
onAssistantDelta: () => {},
1338+
onThinkingDelta: (delta) => thinking.push(delta),
1339+
});
1340+
1341+
parser.push(
1342+
[
1343+
JSON.stringify({
1344+
type: "stream_event",
1345+
event: {
1346+
type: "message_start",
1347+
message: { id: "msg-1" },
1348+
},
1349+
}),
1350+
JSON.stringify({
1351+
type: "stream_event",
1352+
event: {
1353+
type: "content_block_start",
1354+
content_block: { type: "thinking" },
1355+
},
1356+
}),
1357+
JSON.stringify({
1358+
type: "stream_event",
1359+
event: {
1360+
type: "content_block_delta",
1361+
delta: { type: "thinking_delta", thinking: "A" },
1362+
},
1363+
}),
1364+
JSON.stringify({
1365+
type: "stream_event",
1366+
event: { type: "content_block_stop" },
1367+
}),
1368+
JSON.stringify({
1369+
type: "stream_event",
1370+
event: {
1371+
type: "content_block_start",
1372+
content_block: { type: "tool_use", id: "tool-1", name: "Read" },
1373+
},
1374+
}),
1375+
JSON.stringify({
1376+
type: "stream_event",
1377+
event: {
1378+
type: "content_block_delta",
1379+
delta: { type: "input_json_delta", partial_json: '{"file_path":"x"}' },
1380+
},
1381+
}),
1382+
JSON.stringify({
1383+
type: "stream_event",
1384+
event: { type: "content_block_stop" },
1385+
}),
1386+
JSON.stringify({
1387+
type: "stream_event",
1388+
event: {
1389+
type: "content_block_start",
1390+
content_block: { type: "thinking" },
1391+
},
1392+
}),
1393+
JSON.stringify({
1394+
type: "stream_event",
1395+
event: {
1396+
type: "content_block_delta",
1397+
delta: { type: "thinking_delta", thinking: "B" },
1398+
},
1399+
}),
1400+
JSON.stringify({
1401+
type: "stream_event",
1402+
event: { type: "content_block_stop" },
1403+
}),
1404+
JSON.stringify({
1405+
type: "assistant",
1406+
message: {
1407+
id: "msg-1",
1408+
content: [{ type: "text", text: "Answer." }],
1409+
},
1410+
}),
1411+
].join("\n"),
1412+
);
1413+
parser.finish();
1414+
1415+
expect(thinking).toEqual([
1416+
{ text: "A", delta: "A", isReasoningSnapshot: true },
1417+
{ text: "AB", delta: "B", isReasoningSnapshot: true },
1418+
]);
1419+
});
1420+
1421+
it("emits token progress for Claude CLI 2.1 empty thinking deltas", () => {
1422+
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
1423+
const progress: CliThinkingProgress[] = [];
1424+
const parser = createCliJsonlStreamingParser({
1425+
backend: {
1426+
command: "claude",
1427+
output: "jsonl",
1428+
jsonlDialect: "claude-stream-json",
1429+
sessionIdFields: ["session_id"],
1430+
},
1431+
providerId: "claude-cli",
1432+
onAssistantDelta: () => {},
1433+
onThinkingDelta: (delta) => thinking.push(delta),
1434+
onThinkingProgress: (payload) => progress.push(payload),
1435+
});
1436+
1437+
parser.push(readFileSync("test/fixtures/cli/claude-2.1-thinking-progress.jsonl", "utf8"));
1438+
parser.finish();
1439+
1440+
expect(thinking).toEqual([]);
1441+
expect(progress).toEqual([
1442+
{ progressTokens: 50 },
1443+
{ progressTokens: 200 },
1444+
{ progressTokens: 300 },
1445+
]);
1446+
});
1447+
13251448
it("resets per-index thinking state on a new message within the same turn (tool round-trip)", () => {
13261449
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
13271450
const parser = createCliJsonlStreamingParser({
@@ -1410,7 +1533,7 @@ describe("createCliJsonlStreamingParser", () => {
14101533
]);
14111534
});
14121535

1413-
it("ignores thinking deltas without a numeric content-block index", () => {
1536+
it("ignores indexless thinking deltas without content block framing", () => {
14141537
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
14151538
const parser = createCliJsonlStreamingParser({
14161539
backend: {

0 commit comments

Comments
 (0)