Skip to content

Commit 9790433

Browse files
fix(review): wire timer-cleanup replace contract through server-chat + dedupe reasoning header
- Restore the rolling-timer terminal-cleanup signal on the existing live-chat `replace` contract (cli-output -> execute agent event) and forward it through server-chat's emitChatDelta into resolveMergedAssistantText, so a shorter timer-stripped prefix replaces the buffer instead of being kept as a stale rollback in live chat / Control UI. Adds merge coverage for both the kept-prefix and replaced-prefix cases. - Interleaved reasoning display: store only the formatted body, not the full formatReasoningMessage output, so updateInterleavedDisplay's "Thinking" header is no longer duplicated.
1 parent 5f9ae14 commit 9790433

5 files changed

Lines changed: 66 additions & 14 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2024,8 +2024,18 @@ export const dispatchTelegramMessage = async ({
20242024
// can't leak tags or duplicate already-formatted
20252025
// labels. Checkpoint on the normalized text length.
20262026
const rawText = typeof payload.text === "string" ? payload.text : "";
2027-
const normalizedReasoning =
2027+
// splitTelegramReasoningText returns formatReasoningMessage
2028+
// output ("Thinking\n\n" + italic body). updateInterleavedDisplay
2029+
// already renders the "Thinking" header, so store only the
2030+
// italic body here — otherwise the interleaved message shows
2031+
// the heading twice. The header is plain text; body lines are
2032+
// `_…_`, so a leading "Thinking\n\n" strip can't touch the body.
2033+
const formattedReasoning =
20282034
splitTelegramReasoningText(rawText, true).reasoningText ?? "";
2035+
const normalizedReasoning = formattedReasoning.replace(
2036+
/^Thinking\n\n/u,
2037+
"",
2038+
);
20292039
const newPart = normalizedReasoning.slice(rawReasoningCheckpoint);
20302040
if (newPart) {
20312041
interleavedOutput += newPart;

src/agents/cli-output.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export type CliStreamingDelta = {
3030
thinkingDelta?: string;
3131
/** Accumulated thinking text so far; set whenever `thinkingDelta` is present. */
3232
thinkingText?: string;
33+
/**
34+
* Full-state replacement signal on the existing live-chat `replace`
35+
* contract. When true, `text` must be applied as-is even when it is a
36+
* strict prefix of the previously emitted text — bypassing the merger's
37+
* `previousText.startsWith(nextText) && !nextDelta` rollback branch. Used
38+
* by the rolling-timer terminal cleanup, where the new text is the prior
39+
* text with the `_ Ns ..._` suffix stripped (and therefore shorter).
40+
*/
41+
replace?: boolean;
3342
};
3443

3544
function isClaudeCliProvider(providerId: string): boolean {
@@ -668,17 +677,17 @@ export function createCliJsonlStreamingParser(params: {
668677

669678
// For terminal paths (result, finish) where no follow-up text delta is
670679
// coming: re-emit the full assistant text with the `_ Ns ..._` tick
671-
// stripped, so the last-sent timer line disappears. We emit full `text`
672-
// with an empty `delta`; the assistant-stream bridge forwards full text
673-
// (it does not append deltas), so the live-chat merger replaces by `text`
674-
// and the shorter, timer-free text wins. No bespoke replacement signal is
675-
// needed — this rides the existing full-text replace contract.
680+
// stripped so the last-sent timer line disappears. The new text is a
681+
// strict prefix of what was already shown, which the live-chat mergers
682+
// (server-chat + Telegram) treat as a stale rollback and ignore by
683+
// default. Set `replace: true` on the existing live-chat replace contract
684+
// so the shorter, timer-free text is applied as a full-state replacement.
676685
const emitTickReplacementIfPainted = (): void => {
677686
if (toolTickStart < 0) {
678687
return;
679688
}
680689
stripToolTick();
681-
params.onAssistantDelta({ text: assistantText, delta: "" });
690+
params.onAssistantDelta({ text: assistantText, delta: "", replace: true });
682691
};
683692

684693
const trackClaudeToolUse = createClaudeToolUseTracker({

src/agents/cli-runner/execute.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ export async function executePreparedCliRun(
499499
useResume,
500500
noOutputTimeoutMs,
501501
getProcessSupervisor: executeDeps.getProcessSupervisor,
502-
onAssistantDelta: ({ text, delta, thinkingDelta, thinkingText }) => {
502+
onAssistantDelta: ({ text, delta, thinkingDelta, thinkingText, replace }) => {
503503
if (thinkingDelta !== undefined && thinkingText !== undefined) {
504504
emitAgentEvent({
505505
runId: params.runId,
@@ -520,6 +520,12 @@ export async function executePreparedCliRun(
520520
delta,
521521
context.backendResolved.textTransforms?.output,
522522
),
523+
// Forward the full-state replacement signal on the existing
524+
// live-chat `replace` contract so the merger applies the
525+
// shorter timer-cleanup text instead of treating its prefix
526+
// as a stale rollback. Consumed by server-chat's
527+
// resolveMergedAssistantText and the Telegram merger.
528+
...(replace ? { replace: true } : {}),
523529
},
524530
});
525531
},
@@ -589,7 +595,7 @@ export async function executePreparedCliRun(
589595
backend,
590596
providerId: context.backendResolved.id,
591597
shouldInjectToolInlineMarkers: shouldInjectToolInlineMarkersHeadless,
592-
onAssistantDelta: ({ text, delta, thinkingDelta, thinkingText }) => {
598+
onAssistantDelta: ({ text, delta, thinkingDelta, thinkingText, replace }) => {
593599
if (thinkingDelta !== undefined && thinkingText !== undefined) {
594600
emitAgentEvent({
595601
runId: params.runId,
@@ -610,6 +616,8 @@ export async function executePreparedCliRun(
610616
delta,
611617
context.backendResolved.textTransforms?.output,
612618
),
619+
// See live-session branch above for replace semantics.
620+
...(replace ? { replace: true } : {}),
613621
},
614622
});
615623
},

src/gateway/server-chat.stream-text-merge.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import {
3-
MAX_LIVE_CHAT_BUFFER_CHARS,
4-
resolveMergedAssistantText,
5-
} from "./live-chat-projector.js";
2+
import { MAX_LIVE_CHAT_BUFFER_CHARS, resolveMergedAssistantText } from "./live-chat-projector.js";
63

74
describe("server chat stream text merge", () => {
85
it.each([
@@ -60,6 +57,29 @@ describe("server chat stream text merge", () => {
6057
).toBe("Before tool call\nAfter tool call");
6158
});
6259

60+
it("keeps the longer buffer when a shorter prefix arrives without a replacement signal", () => {
61+
expect(
62+
resolveMergedAssistantText({
63+
previousText: "Reply body\n\n_ 12s — 21:04:31_",
64+
nextText: "Reply body",
65+
nextDelta: "",
66+
}),
67+
).toBe("Reply body\n\n_ 12s — 21:04:31_");
68+
});
69+
70+
it("replaces the buffer with a shorter prefix when the replacement signal is set", () => {
71+
// Rolling-timer terminal cleanup: the tick suffix is stripped, leaving a
72+
// shorter prefix that must win over the timer-painted buffer.
73+
expect(
74+
resolveMergedAssistantText({
75+
previousText: "Reply body\n\n_ 12s — 21:04:31_",
76+
nextText: "Reply body",
77+
nextDelta: "",
78+
replacement: true,
79+
}),
80+
).toBe("Reply body");
81+
});
82+
6383
it("caps merged live text while preserving the newest assistant output", () => {
6484
const result = resolveMergedAssistantText({
6585
previousText: "a".repeat(MAX_LIVE_CHAT_BUFFER_CHARS - 2),

src/gateway/server-chat.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,14 +505,18 @@ export function createAgentEventHandler({
505505
seq: number,
506506
text: string,
507507
delta?: unknown,
508-
opts?: { controlUiVisible?: boolean },
508+
opts?: { controlUiVisible?: boolean; replace?: boolean },
509509
) => {
510510
const cleaned = normalizeLiveAssistantEventText({ text, delta });
511511
const previousRawText = chatRunState.rawBuffers.get(clientRunId) ?? "";
512512
const mergedRawText = resolveMergedAssistantText({
513513
previousText: previousRawText,
514514
nextText: cleaned.text,
515515
nextDelta: cleaned.delta,
516+
// Honour the live-chat `replace` contract so a shorter full-state
517+
// text (e.g. rolling-timer cleanup) replaces the buffer instead of
518+
// being kept as a prefix rollback.
519+
replacement: opts?.replace === true,
516520
});
517521
if (!mergedRawText) {
518522
return;
@@ -1059,6 +1063,7 @@ export function createAgentEventHandler({
10591063
) {
10601064
emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text, evt.data.delta, {
10611065
controlUiVisible: isControlUiVisible,
1066+
replace: evt.data.replace === true,
10621067
});
10631068
}
10641069
}

0 commit comments

Comments
 (0)