Skip to content

Commit d9df80b

Browse files
authored
Merge pull request #396 from changeroa/feat/adaptive-streaming-buffer
feat(coding-agent): smooth streamed response pacing
2 parents 1a2880e + 766ea19 commit d9df80b

9 files changed

Lines changed: 655 additions & 383 deletions

packages/coding-agent/src/modes/interactive/changes.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@
5656
- MEDIUM: `components/footer.ts` `render()` was rewritten around `FooterSegment` pairs; upstream footer layout
5757
changes will conflict textually. `components/footer-layout.ts` is additive.
5858

59+
## Adaptive smooth-streaming buffer (2026-07-27)
60+
61+
### What changed
62+
63+
- `streaming-reveal.ts`, `streaming-reveal-pacing.ts`, and `streaming-reveal-content.ts`: smooth assistant output
64+
waits for an 80ms startup buffer, estimates the provider's grapheme arrival rate with an EWMA, and follows that
65+
learned base rate without a hard ceiling. Individual outlier samples are limited to four times the prior
66+
estimate, extra catch-up is bounded independently, and signed backlog correction converges toward roughly
67+
140ms of queued text across provider chunk cadences.
68+
- Fully drained bursts reset fractional progress so a later chunk cannot inherit reveal budget from an earlier
69+
burst. Streaming tool arguments retain one-code-unit progress and parse in bounded 64-unit batches, preserving
70+
surrogate pairs while sharing the assistant pacing helper.
71+
- `../../../test/streaming-reveal-{content,pacing}.test.ts`, `../../../test/streaming-reveal.test.ts`, and
72+
`../../../test/helpers/streaming-reveal.ts`: split grapheme, pacing, and controller coverage into focused modules
73+
and exercise timed 45/90/180/240/500-unit-per-second arrivals, multiple cadences, sustained fast streams,
74+
convergence and final-tail bounds, lifecycle flushes, and drained-burst carry reset.
75+
76+
### Why
77+
78+
- The previous fixed 267ms catch-up policy drained each provider burst completely, while the first adaptive
79+
implementation capped the total reveal rate at 240 graphemes per second. Providers above that rate accumulated
80+
an unbounded tail that snapped onscreen at `message_end`; separating the learned base rate from bounded
81+
correction keeps immediate completion flushes small without delaying lifecycle events.
82+
83+
### Expected merge conflict zones
84+
85+
- LOW: the fork-only streaming reveal modules, focused tests, and the shared pacing call in `tool-args-reveal.ts`.
86+
5987
## Runtime-error headline rendering (2026-07-27)
6088

6189
### What changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { AssistantMessage } from "@earendil-works/pi-ai";
2+
import { getGraphemeSegmenter } from "@earendil-works/pi-tui";
3+
4+
type AssistantContentBlock = AssistantMessage["content"][number];
5+
export type GraphemeCounter = (index: number, text: string) => number;
6+
export type GraphemeSlicer = (index: number, text: string, units: number) => string;
7+
8+
function countGraphemesFrom(text: string, start: number): { count: number; tailStart: number } {
9+
let count = 0;
10+
let tailStart = start;
11+
for (const segment of getGraphemeSegmenter().segment(start === 0 ? text : text.slice(start))) {
12+
count += 1;
13+
tailStart = start + segment.index;
14+
}
15+
return { count, tailStart };
16+
}
17+
18+
function segmentFrom(text: string, start: number, clusters: number): { count: number; end: number; lastStart: number } {
19+
let count = 0;
20+
let end = start;
21+
let lastStart = start;
22+
for (const segment of getGraphemeSegmenter().segment(start === 0 ? text : text.slice(start))) {
23+
count += 1;
24+
lastStart = start + segment.index;
25+
end = lastStart + segment.segment.length;
26+
if (count >= clusters) break;
27+
}
28+
return { count, end, lastStart };
29+
}
30+
31+
export class BlockUnitCounter {
32+
readonly #entries = new Map<number, { text: string; count: number; tailStart: number }>();
33+
readonly #sliceEntries = new Map<number, { text: string; units: number; end: number; lastStart: number }>();
34+
35+
count(index: number, text: string): number {
36+
const entry = this.#entries.get(index);
37+
if (entry !== undefined) {
38+
if (entry.text === text) return entry.count;
39+
if (entry.count > 0 && text.length > entry.text.length && text.startsWith(entry.text)) {
40+
const tail = countGraphemesFrom(text, entry.tailStart);
41+
const next = { text, count: entry.count - 1 + tail.count, tailStart: tail.tailStart };
42+
this.#entries.set(index, next);
43+
return next.count;
44+
}
45+
}
46+
const full = countGraphemesFrom(text, 0);
47+
this.#entries.set(index, { text, count: full.count, tailStart: full.tailStart });
48+
return full.count;
49+
}
50+
51+
slice(index: number, text: string, units: number): string {
52+
const wholeUnits = Math.floor(units);
53+
if (wholeUnits <= 0 || text.length === 0) return "";
54+
const entry = this.#sliceEntries.get(index);
55+
if (entry?.text === text && entry.units === wholeUnits) {
56+
return entry.end >= text.length ? text : text.slice(0, entry.end);
57+
}
58+
if (entry !== undefined && (entry.text === text || text.startsWith(entry.text)) && wholeUnits >= entry.units) {
59+
const segment = segmentFrom(text, entry.lastStart, wholeUnits - entry.units + 1);
60+
this.#sliceEntries.set(index, {
61+
text,
62+
units: entry.units - 1 + segment.count,
63+
end: segment.end,
64+
lastStart: segment.lastStart,
65+
});
66+
return segment.end >= text.length ? text : text.slice(0, segment.end);
67+
}
68+
const segment = segmentFrom(text, 0, wholeUnits);
69+
this.#sliceEntries.set(index, {
70+
text,
71+
units: segment.count,
72+
end: segment.end,
73+
lastStart: segment.lastStart,
74+
});
75+
return segment.end >= text.length ? text : text.slice(0, segment.end);
76+
}
77+
78+
reset(): void {
79+
this.#entries.clear();
80+
this.#sliceEntries.clear();
81+
}
82+
}
83+
84+
function countGraphemes(text: string): number {
85+
return countGraphemesFrom(text, 0).count;
86+
}
87+
88+
function sliceGraphemes(text: string, units: number): string {
89+
if (units <= 0 || text.length === 0) return "";
90+
const segment = segmentFrom(text, 0, units);
91+
return segment.end >= text.length ? text : text.slice(0, segment.end);
92+
}
93+
94+
export function countVisibleUnits(message: AssistantMessage, hideThinking: boolean, countOf: GraphemeCounter): number {
95+
let total = 0;
96+
for (let index = 0; index < message.content.length; index++) {
97+
const block = message.content[index];
98+
if (block?.type === "text") {
99+
total += countOf(index, block.text);
100+
} else if (block?.type === "thinking" && !hideThinking) {
101+
total += countOf(index, block.thinking);
102+
}
103+
}
104+
return total;
105+
}
106+
107+
export function visibleUnits(message: AssistantMessage, hideThinking: boolean): number {
108+
return countVisibleUnits(message, hideThinking, (_index, text) => countGraphemes(text));
109+
}
110+
111+
export function buildDisplayMessage(
112+
target: AssistantMessage,
113+
revealed: number,
114+
hideThinking: boolean,
115+
countOf: GraphemeCounter = (_index, text) => countGraphemes(text),
116+
sliceOf: GraphemeSlicer = (_index, text, units) => sliceGraphemes(text, units),
117+
): AssistantMessage {
118+
let remaining = Math.max(0, Math.floor(revealed));
119+
const content: AssistantContentBlock[] = [];
120+
for (let index = 0; index < target.content.length; index++) {
121+
const block = target.content[index];
122+
if (!block) continue;
123+
if (block.type === "text") {
124+
const units = countOf(index, block.text);
125+
content.push(
126+
remaining <= 0
127+
? block.text.length === 0
128+
? block
129+
: { ...block, text: "" }
130+
: remaining >= units
131+
? block
132+
: { ...block, text: sliceOf(index, block.text, remaining) },
133+
);
134+
remaining = Math.max(0, remaining - units);
135+
} else if (block.type === "thinking" && !hideThinking) {
136+
const units = countOf(index, block.thinking);
137+
content.push(
138+
remaining <= 0
139+
? block.thinking.length === 0
140+
? block
141+
: { ...block, thinking: "" }
142+
: remaining >= units
143+
? block
144+
: { ...block, thinking: sliceOf(index, block.thinking, remaining) },
145+
);
146+
remaining = Math.max(0, remaining - units);
147+
} else {
148+
content.push(block);
149+
}
150+
}
151+
return { ...target, content };
152+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export const INITIAL_BUFFER_MS = 80;
2+
export const TARGET_BUFFER_MS = 140;
3+
export const CATCHUP_WINDOW_MS = 267;
4+
export const MIN_ARRIVAL_UNITS_PER_SEC = 45;
5+
export const MAX_ARRIVAL_SAMPLE_MULTIPLIER = 4;
6+
export const MAX_EXTRA_CATCHUP_UNITS_PER_SEC = 600;
7+
export const ARRIVAL_RATE_ALPHA = 0.25;
8+
export const MIN_SMOOTH_FPS = 30;
9+
export const MAX_SMOOTH_FPS = 120;
10+
export const DEFAULT_SMOOTH_FPS = 60;
11+
12+
export function updateArrivalRate(currentRate: number, appendedUnits: number, elapsedMs: number): number {
13+
if (appendedUnits <= 0 || elapsedMs <= 0) return currentRate;
14+
const sampleRate = (appendedUnits * 1000) / elapsedMs;
15+
const maximumSample = Math.max(MIN_ARRIVAL_UNITS_PER_SEC, currentRate * MAX_ARRIVAL_SAMPLE_MULTIPLIER);
16+
const boundedSample = Math.min(maximumSample, Math.max(MIN_ARRIVAL_UNITS_PER_SEC, sampleRate));
17+
return currentRate + ARRIVAL_RATE_ALPHA * (boundedSample - currentRate);
18+
}
19+
20+
export function nextStep(backlog: number, dtMs: number, arrivalRate = 90): number {
21+
if (backlog <= 0) return 0;
22+
const dt = Math.min(Math.max(dtMs, 1), 100);
23+
const baseArrivalRate = Math.max(MIN_ARRIVAL_UNITS_PER_SEC, arrivalRate);
24+
const targetBacklog = (baseArrivalRate * TARGET_BUFFER_MS) / 1000;
25+
const backlogError = backlog - targetBacklog;
26+
const correctionRate = Math.min(
27+
MAX_EXTRA_CATCHUP_UNITS_PER_SEC,
28+
Math.max(-baseArrivalRate, backlogError * (1000 / CATCHUP_WINDOW_MS)),
29+
);
30+
const revealRate = Math.max(0, baseArrivalRate + correctionRate);
31+
return Math.min(backlog, (revealRate * dt) / 1000);
32+
}

0 commit comments

Comments
 (0)