From d9bd477037c733dd8688da30e74cd7f68060f100 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 10:12:50 +0800 Subject: [PATCH 1/2] fix(ui): stop truncating Runtime Host stream slices in the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer capped every text/thinking delta at 4 KB and replaced the head of a larger one with "[…单条 delta 已截断]". That cap assumed a delta is one provider chunk. Since the Runtime Host owns the stream, a delta is a transport slice addressed by startOffset: the Host merges queued deltas up to SESSION_LIVE_DELTA_MAX_BYTES (16 KB) under backpressure, pays mid-turn joins out in 8 KB backlog chunks, and a reconnect seed carries the whole in-flight text. Legitimate slices were therefore cut, dropping reasoning mid-stream and leaving a new window with only the last 4 KB of a running turn. Oversized frames are already rejected by the protocol decoder, and renderer state stays bounded by the existing per-stream total caps, so the per-delta cap is removed rather than raised: - drop maxDeltaChars / chunkMarker, truncateStreamingDisplayAppend and the two chunk-truncated copy keys; - drop the extra whole-delta redactSecrets pass and the `redacted` result flag, which no production code read; - detach bounded output whenever less of a delta survives than arrived, instead of keying that copy on the removed cap; - remove the desktop copies of the @maka/ui stream tests (covered by the ui oracle and stream-delta tests) and the @maka/ui/assistant-stream subpath export plus aliases that only they used. Generated-by: Claude Code --- apps/desktop/.storybook/main.ts | 1 - .../main/__tests__/assistant-stream.test.ts | 196 ------------------ .../main/__tests__/thinking-stream.test.ts | 74 ------- apps/desktop/tsconfig.storybook.json | 1 - apps/desktop/vite.config.ts | 1 - packages/ui/README.md | 2 +- packages/ui/package.json | 1 - .../ui/src/__tests__/stream-delta.test.ts | 29 +-- .../streaming-display-redaction.test.ts | 49 ++--- packages/ui/src/assistant-stream.ts | 24 +-- packages/ui/src/shared-ui-copy.ts | 8 +- packages/ui/src/stream-delta.ts | 99 +++------ .../ui/src/streaming-display-redaction.ts | 46 +--- packages/ui/src/thinking-stream.ts | 19 +- 14 files changed, 73 insertions(+), 477 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/assistant-stream.test.ts delete mode 100644 apps/desktop/src/main/__tests__/thinking-stream.test.ts diff --git a/apps/desktop/.storybook/main.ts b/apps/desktop/.storybook/main.ts index d1040d7eac7..0e79b069966 100644 --- a/apps/desktop/.storybook/main.ts +++ b/apps/desktop/.storybook/main.ts @@ -55,7 +55,6 @@ const config: StorybookConfig = { { find: 'node:crypto', replacement: STORYBOOK_NODE_CRYPTO_BOUNDARY }, { find: '@maka/ui/icons', replacement: resolve(UI_SRC, 'icons.tsx') }, { find: '@maka/ui/artifact-preview-registry', replacement: resolve(UI_SRC, 'artifact-preview-registry.ts') }, - { find: '@maka/ui/assistant-stream', replacement: resolve(UI_SRC, 'assistant-stream.ts') }, { find: '@maka/ui/maka-uri', replacement: resolve(UI_SRC, 'maka-uri.ts') }, { find: /^@maka\/ui$/, replacement: resolve(UI_SRC, 'index.ts') }, ], diff --git a/apps/desktop/src/main/__tests__/assistant-stream.test.ts b/apps/desktop/src/main/__tests__/assistant-stream.test.ts deleted file mode 100644 index 71868b43fee..00000000000 --- a/apps/desktop/src/main/__tests__/assistant-stream.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { - ASSISTANT_MAX_DELTA_CHARS, - ASSISTANT_MAX_TOTAL_CHARS, - applyAssistantComplete as applyAssistantCompleteWithLocale, - applyAssistantDelta as applyAssistantDeltaWithLocale, -} from '@maka/ui/assistant-stream'; -// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. -const applyAssistantDelta = (prev: string, delta: string, options?: Partial[2]>) => - applyAssistantDeltaWithLocale(prev, delta, { locale: 'zh-CN', ...options }); -const applyAssistantComplete = (text: string, options?: Partial[1]>) => - applyAssistantCompleteWithLocale(text, { locale: 'zh-CN', ...options }); - -function visibleDeltaResult(result: ReturnType) { - return { - text: result.text, - redacted: result.redacted, - truncated: result.truncated, - }; -} - -describe('assistant stream state boundary', () => { - it('appends valid deltas and drops malformed input', () => { - assert.deepEqual(visibleDeltaResult(applyAssistantDelta('', 'hello')), { - text: 'hello', - redacted: false, - truncated: false, - }); - assert.equal(applyAssistantDelta('hello ', 'world').text, 'hello world'); - assert.equal(applyAssistantDelta(undefined as unknown as string, 'x').text, 'x'); - assert.equal(applyAssistantDelta(null as unknown as string, 'x').text, 'x'); - - for (const delta of [undefined, null, 42, {}, [], true, '']) { - assert.deepEqual(visibleDeltaResult( - applyAssistantDelta('so far', delta as unknown as string), - ), { - text: 'so far', - redacted: false, - truncated: false, - }); - } - }); - - it('redacts secrets before they enter state', () => { - for (const secret of [ - 'sk-test-abc-1234567890', - 'sk-abcdef1234567890abcdef1234567890', - ]) { - const result = applyAssistantDelta('', `Authorization: Bearer ${secret}`); - assert.equal(result.text.includes(secret), false); - assert.equal(result.redacted, true); - } - assert.deepEqual(visibleDeltaResult(applyAssistantDelta('', 'normal prose')), { - text: 'normal prose', - redacted: false, - truncated: false, - }); - }); - - it('enforces per-delta limits without truncating exact-boundary input', () => { - const oversized = applyAssistantDelta('', 'word '.repeat(ASSISTANT_MAX_DELTA_CHARS)); - assert.ok(oversized.text.length <= ASSISTANT_MAX_DELTA_CHARS); - assert.ok(oversized.text.startsWith('\n[…单条 delta 已截断]\n')); - assert.equal(oversized.truncated, true); - - const overridden = applyAssistantDelta('', 'aaaaaaaa', { maxDeltaChars: 4 }); - assert.equal(overridden.truncated, true); - - const exact = applyAssistantDelta('', 'x'.repeat(ASSISTANT_MAX_DELTA_CHARS)); - assert.equal(exact.text.length, ASSISTANT_MAX_DELTA_CHARS); - assert.equal(exact.truncated, false); - }); - - it('enforces total limits and keeps truncation monotonic after the cap', () => { - const big = 'word '.repeat(ASSISTANT_MAX_TOTAL_CHARS / 5 + 100); - const first = applyAssistantDelta('', big, { maxDeltaChars: big.length }); - assert.ok(first.text.length <= ASSISTANT_MAX_TOTAL_CHARS); - assert.ok(first.text.endsWith('\n\n[…后续已截断]')); - assert.equal(first.truncated, true); - - const dropped = applyAssistantDelta(first.text, 'more'); - assert.equal(dropped.text, first.text); - assert.equal(dropped.truncated, true); - - const overridden = applyAssistantDelta('', 'hello world this is too long', { - maxTotalChars: 16, - }); - assert.ok(overridden.text.length <= 16); - assert.equal(overridden.truncated, true); - - assert.deepEqual(visibleDeltaResult(applyAssistantDelta('hi ', 'world', { maxTotalChars: 8 })), { - text: 'hi world', - redacted: false, - truncated: false, - }); - }); - - it('redacts secrets before oversized deltas are truncated', () => { - for (const [secret, raw] of [ - [ - 'sk-abcdef1234567890abcdef1234567890', - `leaked sk-abcdef1234567890abcdef1234567890 ${'word '.repeat(ASSISTANT_MAX_DELTA_CHARS)}`, - ], - [ - 'sk-deadbeef00000000deadbeef00000000', - `${'lorem ipsum dolor sit amet '.repeat(200)} sk-deadbeef00000000deadbeef00000000`, - ], - ] as const) { - const result = applyAssistantDelta('', raw); - assert.equal(result.text.includes(secret), false); - assert.equal(result.redacted, true); - assert.equal(result.truncated, true); - } - }); - - it('redacts opaque tokens completed across delta seams', () => { - const firstHalf = 'a1b2c3d4e5f60718293a4b5c'; - const secondHalf = '6d7e8f9012345678abcdef'; - const first = applyAssistantDelta('', firstHalf); - assert.equal(first.redacted, false); - assert.ok(first.redactionState); - - const second = applyAssistantDelta(first.text, secondHalf, { - redactionState: first.redactionState, - }); - assert.equal(second.text.includes(firstHalf + secondHalf), false); - assert.equal(second.redacted, true); - - const completed = applyAssistantDelta('Token: sk-', 'abcdef0123456789abcdef0123456789'); - assert.equal(completed.text.includes('sk-abcdef0123456789abcdef0123456789'), false); - assert.equal(completed.redacted, true); - }); - - it('never exposes a secret streamed through tiny deltas', () => { - const secret = 'sk-deadbeef00000000deadbeef00000000'; - let text = ''; - let redacted = false; - let redactionState: NonNullable['redactionState']> - | undefined; - for (const delta of `Authorization: Bearer ${secret}`) { - const result = applyAssistantDelta(text, delta, { - ...(redactionState === undefined ? {} : { redactionState }), - }); - text = result.text; - redacted ||= result.redacted; - redactionState = result.redactionState; - } - assert.equal(text.includes(secret), false); - assert.equal(redacted, true); - }); - - it('replaces state from complete payloads using only total limits', () => { - assert.deepEqual(applyAssistantComplete(''), { - text: '', - redacted: false, - truncated: false, - }); - - const underTotal = 'word '.repeat(Math.ceil(ASSISTANT_MAX_DELTA_CHARS / 5) + 20); - assert.ok(underTotal.length > ASSISTANT_MAX_DELTA_CHARS); - assert.deepEqual(applyAssistantComplete(underTotal), { - text: underTotal, - redacted: false, - truncated: false, - }); - - const secret = 'sk-abcdef1234567890abcdef1234567890'; - const capped = applyAssistantComplete(`token ${secret} tail that is too long`, { - maxTotalChars: 24, - }); - assert.equal(capped.text.includes(secret), false); - assert.ok(capped.text.endsWith('\n\n[…后续已截断]')); - assert.equal(capped.redacted, true); - assert.equal(capped.truncated, true); - }); -}); diff --git a/apps/desktop/src/main/__tests__/thinking-stream.test.ts b/apps/desktop/src/main/__tests__/thinking-stream.test.ts deleted file mode 100644 index a219bfc2ed3..00000000000 --- a/apps/desktop/src/main/__tests__/thinking-stream.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** Thinking may echo credentials, so renderer state keeps a second redaction boundary. */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { applyThinkingComplete as applyThinkingCompleteWithLocale, applyThinkingDelta as applyThinkingDeltaWithLocale } from '@maka/ui'; -// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. -const applyThinkingDelta = (prev: string, delta: string, options?: Partial[2]>) => - applyThinkingDeltaWithLocale(prev, delta, { locale: 'zh-CN', ...options }); -const applyThinkingComplete = (text: string, options?: Partial[1]>) => - applyThinkingCompleteWithLocale(text, { locale: 'zh-CN', ...options }); - -describe('applyThinkingDelta — secondary redaction', () => { - it('masks raw `Authorization: Bearer ...` text before storing', () => { - const result = applyThinkingDelta( - 'reasoning so far...\n', - 'I should call the API with Authorization: Bearer sk-test1234567890ABCDEF and check the response', - ); - assert.equal(result.redacted, true); - // The actual mask token comes from @maka/ui redactSecrets; the - // contract that matters is the raw bearer never survives. - assert.equal( - result.text.includes('sk-test1234567890ABCDEF'), - false, - 'raw token must NOT survive into stored thinking state', - ); - }); - - it('masks bare API-key prefixes inside thinking', () => { - const result = applyThinkingDelta('', 'planning to use sk-ant-1234567890abcdefghijklmnopqrstuvwxyz for this'); - assert.equal(result.redacted, true); - assert.equal( - result.text.includes('sk-ant-1234567890abcdefghijklmnopqrstuvwxyz'), - false, - ); - }); -}); - -describe('applyThinkingComplete — final replace path', () => { - it('redacts secrets in the final payload', () => { - const result = applyThinkingComplete('final thinking with Authorization: Bearer sk-secret123ABC ...'); - assert.equal(result.redacted, true); - assert.equal(result.text.includes('sk-secret123ABC'), false); - }); -}); - -describe('applyThinkingDelta — combined secret + oversize', () => { - it('secret never appears in stored text regardless of which gate fires', () => { - const noise = 'reasoning step '.repeat(2000); // > maxDelta - const secret = 'Authorization: Bearer sk-secret1234567890ABCDEF'; - const result = applyThinkingDelta('', noise + secret); - // Either redaction or truncation (or both) may have fired; the - // ONLY contract that matters here: secret never in result.text. - assert.equal(result.text.includes('sk-secret1234567890ABCDEF'), false); - }); -}); diff --git a/apps/desktop/tsconfig.storybook.json b/apps/desktop/tsconfig.storybook.json index 452cfde8bb8..0fe9fdde75a 100644 --- a/apps/desktop/tsconfig.storybook.json +++ b/apps/desktop/tsconfig.storybook.json @@ -8,7 +8,6 @@ "@maka/ui": ["../../packages/ui/src/index.ts"], "@maka/ui/icons": ["../../packages/ui/src/icons.tsx"], "@maka/ui/artifact-preview-registry": ["../../packages/ui/src/artifact-preview-registry.ts"], - "@maka/ui/assistant-stream": ["../../packages/ui/src/assistant-stream.ts"], "@maka/ui/maka-uri": ["../../packages/ui/src/maka-uri.ts"], } }, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 67d055b4cd9..c1f75870a88 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -58,7 +58,6 @@ export default defineConfig({ { find: '@maka/ui/client-plugin', replacement: resolve(UI_SRC, 'client-plugin-slots.tsx') }, { find: '@maka/ui/icons', replacement: resolve(UI_SRC, 'icons.tsx') }, { find: '@maka/ui/artifact-preview-registry', replacement: resolve(UI_SRC, 'artifact-preview-registry.ts') }, - { find: '@maka/ui/assistant-stream', replacement: resolve(UI_SRC, 'assistant-stream.ts') }, { find: '@maka/ui/maka-uri', replacement: resolve(UI_SRC, 'maka-uri.ts') }, { find: /^@maka\/ui$/, replacement: resolve(UI_SRC, 'index.ts') }, ], diff --git a/packages/ui/README.md b/packages/ui/README.md index ff26b5ba915..94cfa37f418 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -43,7 +43,7 @@ Four export surfaces, in the order to look: import { Badge, Button, ChatView, Composer, useToast } from '@maka/ui'; ``` -Sub-path exports (declared in `package.json` `exports`): `@maka/ui/artifact-preview-registry`, `@maka/ui/assistant-stream`, `@maka/ui/icons`, and `@maka/ui/maka-uri`. (`@maka/ui/icons` re-exports Lucide symbols; model-provider brand logos live in the renderer's `settings/provider-*`, not here — bot-provider logos are in `@maka/ui`'s `bot-brand-logo`.) +Sub-path exports (declared in `package.json` `exports`): `@maka/ui/artifact-preview-registry`, `@maka/ui/icons`, and `@maka/ui/maka-uri`. (`@maka/ui/icons` re-exports Lucide symbols; model-provider brand logos live in the renderer's `settings/provider-*`, not here — bot-provider logos are in `@maka/ui`'s `bot-brand-logo`.) New renderer CSS owns product layout containers only. It must not target Astryx internal elements, roles, slots, or generated classes to restyle component chrome. diff --git a/packages/ui/package.json b/packages/ui/package.json index 85da464e783..61abfea44aa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,7 +9,6 @@ "exports": { ".": "./dist/index.js", "./artifact-preview-registry": "./dist/artifact-preview-registry.js", - "./assistant-stream": "./dist/assistant-stream.js", "./client-plugin": "./dist/client-plugin-slots.js", "./client-plugin-runtime": "./dist/client-plugin-runtime.js", "./icons": "./dist/icons.js", diff --git a/packages/ui/src/__tests__/stream-delta.test.ts b/packages/ui/src/__tests__/stream-delta.test.ts index 372a67ba79c..0adcc90284e 100644 --- a/packages/ui/src/__tests__/stream-delta.test.ts +++ b/packages/ui/src/__tests__/stream-delta.test.ts @@ -35,15 +35,12 @@ import { type StreamDeltaSpec, } from '../stream-delta.js'; -const CHUNK = '[C]'; const TOTAL = '[T]'; function spec(overrides: Partial = {}): StreamDeltaSpec { return { - maxDeltaChars: 1024, maxTotalChars: 8, recovery: 'head', - chunkMarker: CHUNK, totalMarker: TOTAL, ...overrides, }; @@ -77,36 +74,21 @@ describe('applyStreamDelta — the two recovery directions', () => { it('freezes a full head-kept buffer and keeps a tail-kept window sliding', () => { const frozen = `abcde${TOTAL}`; const dropped = applyStreamDelta(frozen, 'more', spec({ recovery: 'head' })); - assert.deepEqual(dropped, { text: frozen, redacted: false, truncated: true }); + assert.deepEqual(dropped, { text: frozen, truncated: true }); const sliding = `${TOTAL}fghij`; const advanced = applyStreamDelta(sliding, 'KL', spec({ recovery: 'tail' })); assert.equal(advanced.text, `${TOTAL}hijKL`); assert.equal(advanced.truncated, true); }); - - it('caps an oversize single delta the same way in both directions', () => { - // Well under the total cap, so only the per-delta gate can fire. - const perDelta = spec({ maxDeltaChars: 6, maxTotalChars: 1024 }); - const expected = `xy${CHUNK}fgh`; - - assert.equal( - applyStreamDelta('xy', 'abcdefgh', { ...perDelta, recovery: 'head' }).text, - expected, - ); - assert.equal( - applyStreamDelta('xy', 'abcdefgh', { ...perDelta, recovery: 'tail' }).text, - expected, - ); - }); }); describe('applyStreamDelta — defensive guard', () => { - it('drops a non-string delta without claiming redaction, in both directions', () => { + it('drops a non-string delta in both directions', () => { for (const recovery of ['head', 'tail'] as const) { assert.deepEqual( applyStreamDelta('so far', undefined as unknown as string, spec({ recovery })), - { text: 'so far', redacted: false, truncated: false }, + { text: 'so far', truncated: false }, ); } assert.equal( @@ -148,12 +130,11 @@ describe('applyStreamComplete', () => { ); }); - it('redacts before the cap and reports it', () => { + it('redacts the final payload', () => { const result = applyStreamComplete( 'Authorization: Bearer sk-secret123ABCDEFGHIJKLMNOP', { maxTotalChars: 1024, recovery: 'head', totalMarker: TOTAL }, ); - assert.equal(result.redacted, true); assert.equal(result.truncated, false); assert.equal(result.text.includes('sk-secret123ABCDEFGHIJKLMNOP'), false); }); @@ -165,7 +146,7 @@ describe('applyStreamComplete', () => { recovery: 'tail', totalMarker: TOTAL, }), - { text: '', redacted: false, truncated: false }, + { text: '', truncated: false }, ); }); }); diff --git a/packages/ui/src/__tests__/streaming-display-redaction.test.ts b/packages/ui/src/__tests__/streaming-display-redaction.test.ts index fdf4a1b7a00..6032fc06d75 100644 --- a/packages/ui/src/__tests__/streaming-display-redaction.test.ts +++ b/packages/ui/src/__tests__/streaming-display-redaction.test.ts @@ -151,7 +151,6 @@ describe('streaming display redaction', () => { const previousText = redactSecrets(source); const continued = appendStreamingDisplayRedaction(previousText, 'suffix'); assert.equal(continued.text, previousText); - assert.equal(continued.redacted, true); const terminated = appendStreamingDisplayRedaction( continued.text, @@ -161,33 +160,29 @@ describe('streaming display redaction', () => { assert.equal(terminated.text, redactSecrets(`${source}suffix done`)); }); - it('redacts cross-delta secrets before per-delta and total truncation', () => { + it('redacts cross-delta secrets before total truncation', () => { for (const apply of [applyAssistantDelta, applyThinkingDelta]) { const initialState = createStreamingDisplayRedactionState(); const opener = apply('', 'Authorization:', { locale: 'zh-CN' as const, - maxDeltaChars: 128, maxTotalChars: 512, redactionState: initialState, }); const secret = `Bearer ${'s'.repeat(5_000)}`; - const truncated = apply(opener.text, secret, { + const redacted = apply(opener.text, secret, { locale: 'zh-CN' as const, - maxDeltaChars: 128, maxTotalChars: 512, redactionState: opener.redactionState, }); - assert.equal(truncated.text.includes('s'.repeat(32)), false); - assert.equal(truncated.truncated, true); + assert.equal(redacted.text.includes('s'.repeat(32)), false); assert.ok( - (truncated.redactionState?.settledChars ?? 0) - + (truncated.redactionState?.pendingChars ?? 0) + (redacted.redactionState?.settledChars ?? 0) + + (redacted.redactionState?.pendingChars ?? 0) <= 512, ); const total = apply('', 'safe '.repeat(200), { locale: 'zh-CN' as const, - maxDeltaChars: 2_000, maxTotalChars: 128, redactionState: createStreamingDisplayRedactionState(), }); @@ -200,7 +195,7 @@ describe('streaming display redaction', () => { } }); - it('does not expose a secret continuation after an oversized contextual delta', () => { + it('does not expose a secret continuation after a long contextual delta', () => { let projection = applyLiveTurnEvent(undefined, { type: 'text_delta', id: 'text-1', turnId: 'turn-1', messageId: 'message-1', ts: 1, text: 'Authorization: Bearer ', @@ -218,17 +213,19 @@ describe('streaming display redaction', () => { assert.equal(projection.steps[0]?.text?.text, redactedPrefix); }); - it('preserves an unfinished opener when production truncates a reseed delta', () => { + it('keeps a large reseed delta whole and preserves its unfinished opener', () => { for (const opener of [ 'Authorization: Bearer ', 'x-api-key: ', 'https://example.test/?access_token=', ]) { + const seed = `${'safe '.repeat(4_000)}${opener}`; let projection = applyLiveTurnEvent(undefined, { type: 'text_delta', id: 'text-1', turnId: 'turn-1', messageId: 'message-1', ts: 1, - text: `${'safe '.repeat(1_000)}${opener}`, + startOffset: 0, text: seed, }); - assert.equal(projection.steps[0]?.text?.truncated, true); + assert.equal(projection.steps[0]?.text?.text, redactSecrets(seed)); + assert.equal(projection.steps[0]?.text?.truncated, false); const secret = 'secret-value-arriving-after-reseed'; projection = applyLiveTurnEvent(projection, { @@ -248,7 +245,6 @@ describe('streaming display redaction', () => { `x-api-key: ${'k'.repeat(2_000)}`, ]) { const result = applyAssistantDelta('', input, { - maxDeltaChars: input.length, maxTotalChars: 256, redactionState: createStreamingDisplayRedactionState(), locale: 'en', @@ -269,7 +265,6 @@ describe('streaming display redaction', () => { const secondSecret = 'b'.repeat(5_000); const input = `Authorization: Bearer ${firstSecret} safe Authorization: Bearer ${secondSecret}`; const result = applyAssistantDelta('', input, { - maxDeltaChars: input.length, maxTotalChars, locale: 'en', }); @@ -294,7 +289,6 @@ describe('streaming display redaction', () => { `ghp_${'a'.repeat(1_000)}`, ]) { const compacted = apply('', input, { - maxDeltaChars: 2_000, maxTotalChars, locale: 'en', }); @@ -308,7 +302,6 @@ describe('streaming display redaction', () => { const invalidatingCharacter = input.startsWith('ghp_') ? '_' : 'g'; const invalidated = apply(compacted.text, invalidatingCharacter, { - maxDeltaChars: 2_000, maxTotalChars, redactionState: compacted.redactionState, locale: 'en', @@ -325,7 +318,6 @@ describe('streaming display redaction', () => { const maxTotalChars = 64; const source = `${'safe-prefix '.repeat(20)}ghp_${'a'.repeat(1_000)}`; const first = applyThinkingDelta('', source, { - maxDeltaChars: source.length, maxTotalChars, locale: 'en', }); @@ -333,7 +325,6 @@ describe('streaming display redaction', () => { assert.ok(first.redactionState); const invalidated = applyThinkingDelta(first.text, '_', { - maxDeltaChars: source.length, maxTotalChars, redactionState: first.redactionState, locale: 'en', @@ -344,16 +335,18 @@ describe('streaming display redaction', () => { ); }); - it('continues thinking after a per-delta truncation below the total cap', () => { + it('keeps a Host-merged thinking slice whole below the total cap', () => { + const merged = 'reasoning '.repeat(1_600); let projection = applyLiveTurnEvent(undefined, { type: 'thinking_delta', id: 'thinking-1', turnId: 'turn-2', - messageId: 'message-2', ts: 1, text: 'x'.repeat(5_000), + messageId: 'message-2', ts: 1, startOffset: 0, text: merged, }); projection = applyLiveTurnEvent(projection, { type: 'thinking_delta', id: 'thinking-2', turnId: 'turn-2', - messageId: 'message-2', ts: 2, text: 'VISIBLE_NEXT', + messageId: 'message-2', ts: 2, startOffset: merged.length, text: 'VISIBLE_NEXT', }); - assert.equal(projection.steps[0]?.thinking?.text.includes('VISIBLE_NEXT'), true); + assert.equal(projection.steps[0]?.thinking?.text, `${merged}VISIBLE_NEXT`); + assert.equal(projection.steps[0]?.thinking?.truncated, false); }); it('continues tail-kept thinking after the total cap without leaking an active secret', () => { @@ -361,7 +354,6 @@ describe('streaming display redaction', () => { '', `${'safe '.repeat(40)}Authorization: Bearer ${'s'.repeat(1_000)}`, { - maxDeltaChars: 2_000, maxTotalChars: 128, redactionState: createStreamingDisplayRedactionState(), locale: 'en', @@ -371,7 +363,6 @@ describe('streaming display redaction', () => { assert.ok(first.redactionState); const continuation = applyThinkingDelta(first.text, 'SECRET_CONTINUATION', { - maxDeltaChars: 2_000, maxTotalChars: 128, redactionState: first.redactionState, locale: 'en', @@ -379,7 +370,6 @@ describe('streaming display redaction', () => { assert.equal(continuation.text.includes('SECRET_CONTINUATION'), false); const terminated = applyThinkingDelta(continuation.text, ' done', { - maxDeltaChars: 2_000, maxTotalChars: 128, redactionState: continuation.redactionState, locale: 'en', @@ -391,7 +381,6 @@ describe('streaming display redaction', () => { const maxTotalChars = 64; const source = `${'safe '.repeat(20)}Authorization:${' '.repeat(40)}`; const first = applyThinkingDelta('', source, { - maxDeltaChars: source.length, maxTotalChars, locale: 'en', }); @@ -400,7 +389,6 @@ describe('streaming display redaction', () => { const continuation = 'Bearer secret-value done'; const resumed = applyThinkingDelta(first.text, continuation, { - maxDeltaChars: source.length, maxTotalChars, redactionState: first.redactionState, locale: 'en', @@ -413,21 +401,18 @@ describe('streaming display redaction', () => { const overlongSource = `${'safe '.repeat(20)}Authorization:${' '.repeat(100)}`; const overlong = applyThinkingDelta('', overlongSource, { - maxDeltaChars: overlongSource.length, maxTotalChars, locale: 'en', }); assert.ok(overlong.redactionState); assert.ok(overlong.redactionState.pendingChars <= maxTotalChars + 1); const hiddenSecret = applyThinkingDelta(overlong.text, 'Bearer hidden-secret', { - maxDeltaChars: overlongSource.length, maxTotalChars, redactionState: overlong.redactionState, locale: 'en', }); assert.equal(hiddenSecret.text.includes('hidden-secret'), false); const afterLine = applyThinkingDelta(hiddenSecret.text, '\nVISIBLE', { - maxDeltaChars: overlongSource.length, maxTotalChars, redactionState: hiddenSecret.redactionState, locale: 'en', diff --git a/packages/ui/src/assistant-stream.ts b/packages/ui/src/assistant-stream.ts index 2cb10ab8e90..7b3a99488d1 100644 --- a/packages/ui/src/assistant-stream.ts +++ b/packages/ui/src/assistant-stream.ts @@ -38,20 +38,13 @@ import { } from './stream-delta.js'; /** - * Default caps. Tuned to: - * - 4 KB per single delta: matches A3 tool-output's per-chunk - * cap and the runtime's `TOOL_OUTPUT_DELTA_MAX_CHARS`. Streaming - * models normally emit ≤ a few hundred chars per delta; a single - * 4KB+ delta is misbehavior and gets tail-kept. - * - 256 KB total per session: a generous bound for ONE assistant - * turn. A typical model reply runs 200B-30KB; long-form code + - * prose can hit ~80KB; 256KB caps a runaway stream while - * leaving 99% of legitimate replies untouched. Past this cap, - * further deltas are dropped (the buffer freezes with a - * trailing marker; the user sees the head of the answer plus - * "[…后续已截断]" — not a silently-truncated mess). + * 256 KB total per session: a generous bound for ONE assistant turn. A + * typical model reply runs 200B-30KB; long-form code + prose can hit ~80KB; + * 256KB caps a runaway stream while leaving 99% of legitimate replies + * untouched. Past this cap, further deltas are dropped (the buffer freezes + * with a trailing marker; the user sees the head of the answer plus + * "[…后续已截断]" — not a silently-truncated mess). */ -export const ASSISTANT_MAX_DELTA_CHARS = 4 * 1024; export const ASSISTANT_MAX_TOTAL_CHARS = 256 * 1024; export interface ApplyAssistantOptions extends ApplyStreamOptions { @@ -67,13 +60,10 @@ export function applyAssistantDelta( rawDelta: string, options: ApplyAssistantOptions, ): ApplyAssistantResult { - const copy = getSharedUiCopy(options.locale).stream; return applyStreamDelta(prev, rawDelta, { - maxDeltaChars: options.maxDeltaChars ?? ASSISTANT_MAX_DELTA_CHARS, maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, recovery: 'head', - chunkMarker: copy.assistantChunkTruncated, - totalMarker: copy.assistantTailTruncated, + totalMarker: getSharedUiCopy(options.locale).stream.assistantTailTruncated, ...(options.redactionState === undefined ? {} : { redactionState: options.redactionState }), diff --git a/packages/ui/src/shared-ui-copy.ts b/packages/ui/src/shared-ui-copy.ts index c6c94a4537f..74d2aed6366 100644 --- a/packages/ui/src/shared-ui-copy.ts +++ b/packages/ui/src/shared-ui-copy.ts @@ -105,10 +105,8 @@ export interface SharedUiCopy { cancel: string; }; stream: { - assistantChunkTruncated: string; assistantTailTruncated: string; thinkingHeadTruncated: string; - thinkingChunkTruncated: string; toolChunkTruncated: string; }; artifact: { unknownSize: string }; @@ -192,7 +190,7 @@ const SHARED_UI_COPY = { }, primitives: { loading: '加载中', close: '关闭', resizeHandle: '调整宽度' }, toast: { notifications: '通知', closeNotification: '关闭通知', confirm: '确定', cancel: '取消' }, - stream: { assistantChunkTruncated: '\n[…单条 delta 已截断]\n', assistantTailTruncated: '\n\n[…后续已截断]', thinkingHeadTruncated: '[…已截断早期 reasoning]\n', thinkingChunkTruncated: '\n[…单条 delta 已截断]\n', toolChunkTruncated: '\n[…已截断]\n' }, + stream: { assistantTailTruncated: '\n\n[…后续已截断]', thinkingHeadTruncated: '[…已截断早期 reasoning]\n', toolChunkTruncated: '\n[…已截断]\n' }, artifact: { unknownSize: '未知大小' }, providers: { minimaxChina: 'MiniMax 中国站', custom: '自定义', claudeSubscription: 'Claude 订阅' }, }, @@ -272,7 +270,7 @@ const SHARED_UI_COPY = { }, primitives: { loading: '載入中', close: '關閉', resizeHandle: '調整寬度' }, toast: { notifications: '通知', closeNotification: '關閉通知', confirm: '確定', cancel: '取消' }, - stream: { assistantChunkTruncated: '\n[…單條 delta 已截斷]\n', assistantTailTruncated: '\n\n[…後續已截斷]', thinkingHeadTruncated: '[…已截斷早期 reasoning]\n', thinkingChunkTruncated: '\n[…單條 delta 已截斷]\n', toolChunkTruncated: '\n[…已截斷]\n' }, + stream: { assistantTailTruncated: '\n\n[…後續已截斷]', thinkingHeadTruncated: '[…已截斷早期 reasoning]\n', toolChunkTruncated: '\n[…已截斷]\n' }, artifact: { unknownSize: '未知大小' }, providers: { minimaxChina: 'MiniMax 中國站', custom: '自訂', claudeSubscription: 'Claude 訂閱' }, }, @@ -352,7 +350,7 @@ const SHARED_UI_COPY = { }, primitives: { loading: 'Loading', close: 'Close', resizeHandle: 'Resize handle' }, toast: { notifications: 'Notifications', closeNotification: 'Close notification', confirm: 'Confirm', cancel: 'Cancel' }, - stream: { assistantChunkTruncated: '\n[…single delta truncated]\n', assistantTailTruncated: '\n\n[…remaining output truncated]', thinkingHeadTruncated: '[…earlier reasoning truncated]\n', thinkingChunkTruncated: '\n[…single delta truncated]\n', toolChunkTruncated: '\n[…truncated]\n' }, + stream: { assistantTailTruncated: '\n\n[…remaining output truncated]', thinkingHeadTruncated: '[…earlier reasoning truncated]\n', toolChunkTruncated: '\n[…truncated]\n' }, artifact: { unknownSize: 'Unknown size' }, providers: { minimaxChina: 'MiniMax China', custom: 'Custom', claudeSubscription: 'Claude subscription' }, }, diff --git a/packages/ui/src/stream-delta.ts b/packages/ui/src/stream-delta.ts index 31d0dd5ee7e..cca6525f340 100644 --- a/packages/ui/src/stream-delta.ts +++ b/packages/ui/src/stream-delta.ts @@ -21,16 +21,18 @@ * The one trust-boundary pipeline the renderer runs over a streamed text * buffer, shared by `assistant-stream` and `thinking-stream`. Both streams * append provider text into a live `LiveTurnProjection` field, and both need - * the same three guarantees before that text becomes React state: + * the same two guarantees before that text becomes React state: * - * - secondary `redactSecrets` BEFORE state — the renderer cannot trust - * upstream to have masked every secret, and a raw `Authorization: - * Bearer …` prefix sitting in the live projection would leak through a - * React DevTools snapshot, the "copy message" affordance, and any future - * serialization that walks the streaming state; - * - a per-delta cap, defensive against one misbehaving multi-MB chunk; + * - `redactSecrets` BEFORE state — a raw `Authorization: Bearer …` prefix + * sitting in the live projection would leak through a React DevTools + * snapshot, the "copy message" affordance, and any future serialization + * that walks the streaming state; * - a per-session total cap that bounds renderer state for a runaway stream. * + * Delta boundaries are Runtime Host transport slices (merged under + * backpressure, paid out as backlog, or a whole reconnect seed), so nothing + * here keys on the size of a single delta. + * * The streams differ only in their caps, their user-visible markers, and which * end of an over-cap buffer survives — the `recovery` direction * `streaming-display-redaction` already parameterizes: @@ -45,10 +47,6 @@ * head. The user watching extended thinking is following the CURRENT chain * of thought, so the oldest reasoning is the least relevant. * - * The per-delta cap is tail-keep with a head marker in BOTH streams: a single - * oversize delta is runtime misbehavior, the user has not been reading inside - * that chunk yet, and it is about to be appended atomically. - * * `tool-output-stream` is deliberately not folded in here — it accumulates an * array of chunks with dedup-by-seq, which is a different problem. */ @@ -58,15 +56,12 @@ import { appendStreamingDisplayRedaction, copyStreamingDisplayRedactionState, createStreamingDisplayRedactionState, - truncateStreamingDisplayAppend, truncateStreamingDisplayTail, type StreamingDisplayRedactionState, } from './streaming-display-redaction.js'; /** Caller-facing knobs, identical for both streams. */ export interface ApplyStreamOptions { - /** Override per-delta cap. */ - maxDeltaChars?: number; /** Override per-session total cap. */ maxTotalChars?: number; /** Differential-safe state returned by the preceding delta. */ @@ -76,9 +71,7 @@ export interface ApplyStreamOptions { export interface ApplyStreamResult { /** Resulting accumulated text (post-redaction, post-cap). */ text: string; - /** True if redaction modified anything during this call. */ - redacted: boolean; - /** True if any per-delta or total truncation happened during this call. */ + /** True if the total cap cut text during this call. */ truncated: boolean; /** Bounded state needed to keep later prefixes oracle-equivalent. */ redactionState?: StreamingDisplayRedactionState; @@ -86,22 +79,16 @@ export interface ApplyStreamResult { /** Everything a concrete stream resolves before the shared pipeline runs. */ export interface StreamDeltaSpec { - maxDeltaChars: number; maxTotalChars: number; /** Which end of an over-cap buffer survives. */ recovery: 'head' | 'tail'; - /** Marker for a single oversize delta (always prepended). */ - chunkMarker: string; /** Marker for the total cap: appended for 'head', prepended for 'tail'. */ totalMarker: string; redactionState?: StreamingDisplayRedactionState; } /** The `complete` path replaces rather than appends, so it needs less. */ -export type StreamCompleteSpec = Pick< - StreamDeltaSpec, - 'maxTotalChars' | 'recovery' | 'totalMarker' ->; +export type StreamCompleteSpec = Omit; /** * Apply a single delta to the prior accumulated text. Pure: no React state, @@ -110,9 +97,7 @@ export type StreamCompleteSpec = Pick< * Pipeline (in order): * 1. Append through the differential-safe redactor. It caches complete * lines and re-runs the whole-text oracle over only the mutable suffix. - * 2. If the delta alone is oversized, cap the already-redacted mutable - * suffix so a cross-delta secret cannot leak through truncation. - * 3. If the result exceeds `maxTotalChars`, cut the end `recovery` names. + * 2. If the result exceeds `maxTotalChars`, cut the end `recovery` names. * * The carried state is opaque: the live projection stores only a WeakMap key * and length counters, never the raw mutable suffix as enumerable React state. @@ -122,15 +107,14 @@ export function applyStreamDelta( rawDelta: string, spec: StreamDeltaSpec, ): ApplyStreamResult { - const { maxDeltaChars, maxTotalChars, recovery, chunkMarker, totalMarker } = spec; + const { maxTotalChars, recovery, totalMarker } = spec; const previousText = prev ?? ''; // Defensive guard: a non-string delta is a runtime contract violation. Drop - // it silently rather than coerce to '' and claim redaction happened. + // it silently rather than coerce it. if (typeof rawDelta !== 'string') { return { text: previousText, - redacted: false, truncated: false, ...(spec.redactionState === undefined ? {} @@ -146,7 +130,7 @@ export function applyStreamDelta( previousText.length >= maxTotalChars && previousText.endsWith(totalMarker) ) { - return { text: previousText, redacted: false, truncated: true }; + return { text: previousText, truncated: true }; } const redactionState = spec.redactionState ?? appendStreamingDisplayRedaction( @@ -158,32 +142,17 @@ export function applyStreamDelta( }), ).state; - // Oversize deltas keep the established redact-before-truncate behavior. - // Normal deltas stay raw until the line-aware append below so a later prefix - // can legitimately make an opaque token visible again. - const redactedDelta = redactSecrets(rawDelta); - const perDeltaRedactionHappened = redactedDelta !== rawDelta; - - const rawAppended = appendStreamingDisplayRedaction( + const appended = appendStreamingDisplayRedaction( previousText, rawDelta, redactionState, ); - const appended = redactedDelta.length > maxDeltaChars - ? truncateStreamingDisplayAppend( - previousText, - rawAppended, - maxDeltaChars, - chunkMarker, - ) - : rawAppended; - const deltaTruncated = appended !== rawAppended; let result = appended.text; let capped = appended; - let totalTruncated = false; + let truncated = false; if (result.length > maxTotalChars) { - totalTruncated = true; + truncated = true; if (recovery === 'head') { result = result.slice(0, maxTotalChars - totalMarker.length) + totalMarker; } else { @@ -192,22 +161,22 @@ export function applyStreamDelta( } } - // Reconnect seeds can contain the full stream. Copy only the final bounded - // display and recovery state so their slices cannot keep that payload alive. - if (rawDelta.length > maxDeltaChars) { + // A reconnect seed can carry the full stream in one delta. When less of it + // survives than arrived, copy the bounded display and recovery state so + // their slices cannot keep that payload alive. + if (rawDelta.length > result.length) { result = structuredClone(result); - if (!(recovery === 'head' && totalTruncated)) { + if (!(recovery === 'head' && truncated)) { capped = { ...capped, state: copyStreamingDisplayRedactionState(capped.state) }; } } return { text: result, - redacted: perDeltaRedactionHappened || appended.redacted, - truncated: deltaTruncated || totalTruncated, + truncated, // A head-keep cut drops the mutable suffix the state describes, so there // is nothing left to carry; every other path hands the state forward. - ...(recovery === 'head' && totalTruncated + ...(recovery === 'head' && truncated ? {} : { redactionState: capped.state }), }; @@ -215,8 +184,7 @@ export function applyStreamDelta( /** * Apply a `complete` final payload. The complete event carries the FULL final - * text, so this is a replace path: redact and apply only the per-session total - * cap, not the per-delta cap used for incremental chunks. + * text, so this is a replace path: redact, then apply the total cap. */ export function applyStreamComplete( rawText: string, @@ -225,25 +193,22 @@ export function applyStreamComplete( const { maxTotalChars, recovery, totalMarker } = spec; if (typeof rawText !== 'string') { - return { text: '', redacted: false, truncated: false }; + return { text: '', truncated: false }; } - const redacted = redactSecrets(rawText); - - let result = redacted; - let totalTruncated = false; + let result = redactSecrets(rawText); + let truncated = false; if (result.length > maxTotalChars) { const keep = maxTotalChars - totalMarker.length; result = recovery === 'head' ? result.slice(0, keep) + totalMarker : totalMarker + result.slice(result.length - keep); - totalTruncated = true; + truncated = true; } return { // Detach the bounded display from a slice's potentially much larger backing string. - text: totalTruncated ? structuredClone(result) : result, - redacted: redacted !== rawText, - truncated: totalTruncated, + text: truncated ? structuredClone(result) : result, + truncated, }; } diff --git a/packages/ui/src/streaming-display-redaction.ts b/packages/ui/src/streaming-display-redaction.ts index 9c5cf653172..e5b7d4ff60f 100644 --- a/packages/ui/src/streaming-display-redaction.ts +++ b/packages/ui/src/streaming-display-redaction.ts @@ -83,7 +83,6 @@ export function createStreamingDisplayRedactionState( export interface StreamingDisplayRedactionResult { readonly text: string; - readonly redacted: boolean; readonly state: StreamingDisplayRedactionState; } @@ -116,7 +115,6 @@ export function appendStreamingDisplayRedaction( if (terminatorIndex < 0) { return { text: settledText, - redacted: delta.length > 0, state: stateFor(settledText, '', continuationTerminator, { maxRecoveryChars, recovery, @@ -151,7 +149,6 @@ export function appendStreamingDisplayRedaction( const nextSettledText = settledText + stableSuffix.settledPrefixText; return { text: settledText + stableSuffix.text, - redacted: true, state: stateFor(nextSettledText, stableSuffix.compactedSuffix, undefined, { maxRecoveryChars, recovery, @@ -170,7 +167,6 @@ export function appendStreamingDisplayRedaction( }; return { text: settledText + redactSecrets(reversibleSuffix.compactedInput), - redacted: true, state: stateFor(settledText, reversibleSuffix.compactedInput, undefined, { maxRecoveryChars, recovery, @@ -186,15 +182,10 @@ export function appendStreamingDisplayRedaction( const completedRaw = settlementLineBreak < 0 ? '' : pending.slice(0, settlementLineBreak + 1); const pendingRaw = settlementLineBreak < 0 ? pending : pending.slice(settlementLineBreak + 1); - const redactedCompleted = completedRaw ? redactSecrets(completedRaw) : ''; - const redactedPending = redactSecrets(pendingRaw); - const nextSettledText = settledText + redactedCompleted; + const nextSettledText = settledText + (completedRaw ? redactSecrets(completedRaw) : ''); return { - text: nextSettledText + redactedPending, - redacted: - redactedCompleted !== completedRaw - || redactedPending !== pendingRaw, + text: nextSettledText + redactSecrets(pendingRaw), state: stateFor(nextSettledText, pendingRaw, undefined, { maxRecoveryChars, recovery, @@ -231,38 +222,6 @@ export function copyStreamingDisplayRedactionState( ); } -/** - * Apply the existing per-delta tail cap to an already-safe append. The mutable - * suffix begins at the first character whose whole-prefix redaction changed; - * capping that suffix preserves cross-delta masking before discarding bytes. - */ -export function truncateStreamingDisplayAppend( - previousText: string, - appended: StreamingDisplayRedactionResult, - maxDeltaChars: number, - marker: string, -): StreamingDisplayRedactionResult { - let mutableStart = 0; - const commonLength = Math.min(previousText.length, appended.text.length); - while ( - mutableStart < commonLength - && previousText.charCodeAt(mutableStart) === appended.text.charCodeAt(mutableStart) - ) { - mutableStart += 1; - } - const keep = Math.max(0, maxDeltaChars - marker.length); - const mutableText = appended.text.slice(mutableStart); - const text = previousText.slice(0, mutableStart) - + marker - + mutableText.slice(Math.max(0, mutableText.length - keep)); - const appendedPrivateState = PRIVATE_STATE.get(appended.state); - return { - text, - redacted: appended.redacted, - state: stateAfterTruncation(text, appendedPrivateState), - }; -} - /** Apply the established thinking tail cap without losing an active secret. */ export function truncateStreamingDisplayTail( appended: StreamingDisplayRedactionResult, @@ -274,7 +233,6 @@ export function truncateStreamingDisplayTail( const appendedPrivateState = PRIVATE_STATE.get(appended.state); return { text, - redacted: appended.redacted, state: stateAfterTruncation(text, appendedPrivateState), }; } diff --git a/packages/ui/src/thinking-stream.ts b/packages/ui/src/thinking-stream.ts index 9c29e8a47e5..997b9715c53 100644 --- a/packages/ui/src/thinking-stream.ts +++ b/packages/ui/src/thinking-stream.ts @@ -27,7 +27,7 @@ * `
{thinkingText}
` — no Markdown, no redaction, no size * cap. Two trust-boundary failures: (1) model thinking output can * echo prompts / env / tool stderr / pasted credentials, so the - * raw text must NOT enter React state without secondary + * raw text must NOT enter React state without * `redactSecrets`; (2) extended thinking can stream tens or * hundreds of KB, and `
` `max-height: 320px` only bounds
  * VISUAL height, not the DOM text length / React state / DevTools
@@ -52,15 +52,11 @@ import {
 } from './stream-delta.js';
 
 /**
- * Default caps. Tuned to:
- *   - 4 KB per single delta: matches A3 tool-output's per-chunk
- *     cap and the runtime's `TOOL_OUTPUT_DELTA_MAX_CHARS`.
- *   - 32 KB total per session: thinking can run longer than tool
- *     stream (multiple paragraphs of reasoning before the answer),
- *     so 2× A3's per-tool cap. Above this we tail-keep so the
- *     "most recent" reasoning is what the user sees scrolling.
+ * 32 KB total per session: thinking can run longer than tool stream
+ * (multiple paragraphs of reasoning before the answer), so 2× A3's per-tool
+ * cap. Above this we tail-keep so the "most recent" reasoning is what the
+ * user sees scrolling.
  */
-export const THINKING_MAX_DELTA_CHARS = 4 * 1024;
 export const THINKING_MAX_TOTAL_CHARS = 32 * 1024;
 
 export interface ApplyThinkingOptions extends ApplyStreamOptions {
@@ -76,13 +72,10 @@ export function applyThinkingDelta(
   rawDelta: string,
   options: ApplyThinkingOptions,
 ): ApplyThinkingResult {
-  const copy = getSharedUiCopy(options.locale).stream;
   return applyStreamDelta(prev, rawDelta, {
-    maxDeltaChars: options.maxDeltaChars ?? THINKING_MAX_DELTA_CHARS,
     maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS,
     recovery: 'tail',
-    chunkMarker: copy.thinkingChunkTruncated,
-    totalMarker: copy.thinkingHeadTruncated,
+    totalMarker: getSharedUiCopy(options.locale).stream.thinkingHeadTruncated,
     ...(options.redactionState === undefined
       ? {}
       : { redactionState: options.redactionState }),

From 8aae2df3f5f610e200abb1bc60f5b227ff7d19b6 Mon Sep 17 00:00:00 2001
From: AstroHan 
Date: Thu, 24 Sep 2026 10:33:01 +0800
Subject: [PATCH 2/2] refactor(core,ui,runtime-host): fold stream offsets once
 and state replacement explicitly

Assistant text/thinking deltas are addressed by absolute startOffset so
replays, backlog payouts and reconnect seeds are idempotent. The offset rule
lived twice with opposite strictness: the projector threw on gaps and
conflicts, while the renderer's replaySafeDelta silently appended across a
gap. The renderer also re-decided whether a completion replaced the streamed
text by comparing the full raw text against its redacted, capped display
(completionRemainder), a different coordinate system. A slice that showed a
redacted token or a tail-capped thinking prefix then failed the check, and
the full completion was duplicated into the post-steering slice.

- foldAssistantDelta in @maka/core/events is the one offset rule (overlap
  trimmed, gap reported, missing offset appends). The projector keeps its
  content-conflict check on top because only it holds raw text; the renderer
  drops a gapped delta instead of appending it; the ACP mapper passes events
  straight through.
- Replacement is a fact the Runtime Host decides on raw text (reset). The
  projector now forwards it as `replaced` on text/thinking completions,
  including terminal reseeds, and the renderer lands a replacement whole and
  otherwise slices the completion at the source offset where earlier slices
  of the step stopped.
- The per-step continuedThinkingEndOffset/continuedTextEndOffset copies are
  removed; a slice's start is derived from the earlier slices of its step.

Generated-by: Claude Code
---
 packages/cli/src/acp/session-event-mapper.ts  |   5 +-
 packages/core/src/__tests__/events.test.ts    |  18 +++
 packages/core/src/events.ts                   |  20 ++++
 .../src/__tests__/session-projector.test.ts   |   1 +
 .../src/adapter/session-projector.ts          |  44 ++++----
 .../chat-turn-steering-order.test.ts          |  47 ++++++--
 .../__tests__/live-turn-projection.test.ts    |  17 +++
 packages/ui/src/live-turn-projection.ts       | 104 ++++--------------
 8 files changed, 138 insertions(+), 118 deletions(-)

diff --git a/packages/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts
index 1ebc785d17f..d2dceaa80de 100644
--- a/packages/cli/src/acp/session-event-mapper.ts
+++ b/packages/cli/src/acp/session-event-mapper.ts
@@ -188,10 +188,7 @@ function deltaText(
   event: Extract,
   current = '',
 ): string {
-  return foldRuntimeHostAssistantDelta(current, {
-    startOffset: event.startOffset ?? current.length,
-    text: event.text,
-  }).text;
+  return foldRuntimeHostAssistantDelta(current, event).text;
 }
 
 function streamKey(kind: StreamKind, messageId: string): string {
diff --git a/packages/core/src/__tests__/events.test.ts b/packages/core/src/__tests__/events.test.ts
index b56aa7cc0ce..1c4585eeaa4 100644
--- a/packages/core/src/__tests__/events.test.ts
+++ b/packages/core/src/__tests__/events.test.ts
@@ -24,9 +24,27 @@ import {
   decodeToolStepProgress,
   encodeToolStepProgress,
   decodeMessageContent,
+  foldAssistantDelta,
   isQuoteRef,
 } from '../events.js';
 
+test('folds assistant deltas idempotently by source offset', () => {
+  assert.deepStrictEqual(foldAssistantDelta(0, { startOffset: 0, text: 'Hello' }), {
+    tail: 'Hello',
+    endOffset: 5,
+  });
+  assert.deepStrictEqual(foldAssistantDelta(5, { startOffset: 0, text: 'Hello world' }), {
+    tail: ' world',
+    endOffset: 11,
+  });
+  assert.deepStrictEqual(foldAssistantDelta(11, { startOffset: 0, text: 'Hello' }), {
+    tail: '',
+    endOffset: 11,
+  });
+  assert.deepStrictEqual(foldAssistantDelta(3, { text: 'abc' }), { tail: 'abc', endOffset: 6 });
+  assert.strictEqual(foldAssistantDelta(3, { startOffset: 4, text: 'gap' }), undefined);
+});
+
 test('aggregates inline references against the combined display text', () => {
   assert.deepStrictEqual(
     aggregateMessageContents([
diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts
index 96ee32362a4..95fc47a1362 100644
--- a/packages/core/src/events.ts
+++ b/packages/core/src/events.ts
@@ -680,6 +680,8 @@ export interface TextDeltaEvent extends BaseEvent {
 export interface TextCompleteEvent extends BaseEvent {
   type: 'text_complete';
   interrupted?: true;
+  /** `text` replaces the streamed deltas instead of extending them. */
+  replaced?: true;
   messageId: string;
   text: string;
   /** Provider-owned text metadata such as Responses URL citations. */
@@ -694,9 +696,27 @@ export interface ThinkingDeltaEvent extends BaseEvent {
   text: string;
 }
 
+/**
+ * Apply a text/thinking delta to a stream that has consumed `currentEnd`
+ * source characters. Overlap with consumed text is dropped, so replayed and
+ * reseeded deltas are idempotent. A delta that starts past `currentEnd` is a
+ * gap and returns `undefined`; one without `startOffset` appends.
+ */
+export function foldAssistantDelta(
+  currentEnd: number,
+  delta: { readonly startOffset?: number; readonly text: string },
+): { tail: string; endOffset: number } | undefined {
+  const startOffset = delta.startOffset ?? currentEnd;
+  if (startOffset > currentEnd) return undefined;
+  const tail = delta.text.slice(currentEnd - startOffset);
+  return { tail, endOffset: currentEnd + tail.length };
+}
+
 export interface ThinkingCompleteEvent extends BaseEvent {
   type: 'thinking_complete';
   interrupted?: true;
+  /** `text` replaces the streamed deltas instead of extending them. */
+  replaced?: true;
   messageId: string;
   text: string;
   /** Anthropic signed thinking — MUST be re-sent on replay. */
diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts
index 129efa53593..214c435d02b 100644
--- a/packages/runtime-host/src/__tests__/session-projector.test.ts
+++ b/packages/runtime-host/src/__tests__/session-projector.test.ts
@@ -92,6 +92,7 @@ test('applies authoritative replacement once and does not complete it again at T
     deltaFrame(2, 5, '', { complete: true, interrupted: true }),
   ).events;
   assert.ok(completed[0]?.type === 'text_complete' && completed[0].interrupted === true);
+  assert.equal(completed[0].replaced, true);
   assert.deepEqual(
     completed.map((event) => [event.type, 'text' in event ? event.text : '']),
     [['text_complete', 'final']],
diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts
index 944644e1c2d..e05826d45dd 100644
--- a/packages/runtime-host/src/adapter/session-projector.ts
+++ b/packages/runtime-host/src/adapter/session-projector.ts
@@ -18,16 +18,16 @@
  */
 
 import { isDeepStrictEqual } from 'node:util';
-import type {
-  ActiveInteractionRequestEvent,
-  ContextCompactionStartedEvent,
-  SessionEvent,
+import {
+  type ActiveInteractionRequestEvent,
+  type ContextCompactionStartedEvent,
+  foldAssistantDelta,
+  type SessionEvent,
 } from '@maka/core/events';
 import type { StoredMessage, TurnRecord } from '@maka/core/session';
 import type {
   InteractionPendingSnapshot,
   SessionContinuitySnapshot,
-  SessionAssistantDelta,
   SessionAssistantStreamIdentity,
   SessionMessageQueueProjection,
   SessionSteeringEvent,
@@ -44,7 +44,8 @@ interface AssistantAccumulator {
   messageId: string;
   text: string;
   complete: boolean;
-  replacing: boolean;
+  /** A Host reset replaced the streamed text; its deltas are withheld until completion. */
+  replaced: boolean;
 }
 
 export interface RuntimeHostSessionProjectionSeed {
@@ -123,7 +124,7 @@ export class RuntimeHostSessionProjector {
           messageId: message.id,
           text: message.thinking.text,
           complete: true,
-          replacing: false,
+          replaced: false,
         });
       }
       if (message.text || message.interrupted) {
@@ -134,7 +135,7 @@ export class RuntimeHostSessionProjector {
           text: message.text,
           ...(message.interrupted ? { interrupted: true } : {}),
           complete: true,
-          replacing: false,
+          replaced: false,
         });
       }
     }
@@ -148,7 +149,7 @@ export class RuntimeHostSessionProjector {
         messageId: stream.messageId,
         text: current?.text ?? '',
         complete: false,
-        replacing: false,
+        replaced: false,
       });
     }
   }
@@ -363,7 +364,7 @@ export class RuntimeHostSessionProjector {
       const key = accumulatorKey(delta.kind, delta.messageId);
       const current = this.#accumulators.get(key);
       const folded = foldRuntimeHostAssistantDelta(delta.reset ? '' : (current?.text ?? ''), delta);
-      const replacing = delta.reset === true || (current?.replacing ?? false);
+      const replaced = delta.reset === true || (current?.replaced ?? false);
       this.#accumulators.set(key, {
         kind: delta.kind,
         turnId: delta.turnId,
@@ -371,7 +372,7 @@ export class RuntimeHostSessionProjector {
         text: folded.text,
         ...(delta.interrupted ? { interrupted: true } : {}),
         complete: delta.complete === true,
-        replacing: delta.complete === true ? false : replacing,
+        replaced,
       });
       if (delta.complete === true) {
         events.push({
@@ -382,8 +383,9 @@ export class RuntimeHostSessionProjector {
           ts: this.#now(),
           text: folded.text,
           ...(delta.interrupted ? { interrupted: true } : {}),
+          ...(replaced ? { replaced: true } : {}),
         });
-      } else if (folded.tail && !replacing) {
+      } else if (folded.tail && !replaced) {
         events.push({
           type: delta.kind === 'text' ? 'text_delta' : 'thinking_delta',
           id: frameIdentity(frame),
@@ -490,6 +492,7 @@ export class RuntimeHostSessionProjector {
         ts: this.#now(),
         text: accumulator.text,
         ...(accumulator.interrupted ? { interrupted: true } : {}),
+        ...(accumulator.replaced ? { replaced: true } : {}),
       });
     }
     if (root.status === 'completed') {
@@ -706,19 +709,16 @@ function projectSessionEvent(
 
 export function foldRuntimeHostAssistantDelta(
   current: string,
-  delta: Pick,
+  delta: { readonly startOffset?: number; readonly text: string },
 ): { text: string; tail: string } {
-  if (delta.startOffset > current.length) throw new Error('Runtime Host assistant delta has a gap');
-  const overlapLength = Math.min(current.length - delta.startOffset, delta.text.length);
-  if (
-    overlapLength > 0 &&
-    current.slice(delta.startOffset, delta.startOffset + overlapLength) !==
-      delta.text.slice(0, overlapLength)
-  ) {
+  const folded = foldAssistantDelta(current.length, delta);
+  if (!folded) throw new Error('Runtime Host assistant delta has a gap');
+  const startOffset = delta.startOffset ?? current.length;
+  const overlap = delta.text.slice(0, delta.text.length - folded.tail.length);
+  if (current.slice(startOffset, startOffset + overlap.length) !== overlap) {
     throw new Error('Runtime Host assistant delta conflicts with prior output');
   }
-  const tail = delta.text.slice(overlapLength);
-  return { text: current + tail, tail };
+  return { text: current + folded.tail, tail: folded.tail };
 }
 
 function newlyPendingInteractions(
diff --git a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts
index 756032d42ac..64288f36a87 100644
--- a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts
+++ b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts
@@ -33,6 +33,7 @@ import { renderTranscriptMarkup } from './transcript-test-dom.js';
 import { ChatSurfaceLayout } from '../chat-surface-layout.js';
 import { armLiveTurn, type LiveTurnProjection } from '../live-turn-projection.js';
 import { applyLiveTurnEvent } from './live-turn-zh.js';
+import { redactSecrets } from '../redact.js';
 import type { SessionSummary, StoredMessage } from '@maka/core/session';
 
 test('renders a thinking-only interruption as a divider without an empty answer bubble', () => {
@@ -244,9 +245,7 @@ test('splits a completion across the boundary instead of duplicating the sealed
   ]);
 });
 
-test('lands a divergent completion whole instead of slicing at the delta offset', () => {
-  // thinking_complete may carry a provider summary that replaces the streamed
-  // deltas outright — the accumulated delta length is not a safe cut point.
+test('lands a replacing completion whole instead of slicing at the delta offset', () => {
   let live: LiveTurnProjection | undefined = applyLiveTurnEvent(armLiveTurn('turn-1'), {
     type: 'thinking_delta', id: 'e1', turnId: 'turn-1', messageId: 'm1', ts: 1, text: 'AAAA',
   });
@@ -257,34 +256,60 @@ test('lands a divergent completion whole instead of slicing at the delta offset'
     type: 'thinking_delta', id: 'e2', turnId: 'turn-1', messageId: 'm1', ts: 3, text: 'BBBB',
   });
   live = applyLiveTurnEvent(live, {
-    type: 'thinking_complete', id: 'e3', turnId: 'turn-1', messageId: 'm1', ts: 4, text: 'Short summary.',
+    type: 'thinking_complete', id: 'e3', turnId: 'turn-1', messageId: 'm1', ts: 4, text: 'ABC', replaced: true,
   });
 
   assert.deepEqual(timelineOrder(overlayLiveTurn([], live, 'en')[0]!.timeline), [
     'thinking:AAAA',
     'user:steer',
-    'thinking:Short summary.',
+    'thinking:ABC',
   ]);
 });
 
-test('lands a completion shorter than the delta offset whole', () => {
+test('applies a reseed across the boundary only to the live slice', () => {
   let live: LiveTurnProjection | undefined = applyLiveTurnEvent(armLiveTurn('turn-1'), {
-    type: 'thinking_delta', id: 'e1', turnId: 'turn-1', messageId: 'm1', ts: 1, text: 'AAAA',
+    type: 'text_delta', id: 'e1', turnId: 'turn-1', messageId: 'm1', ts: 1, startOffset: 0, text: 'pre-',
   });
   live = applyLiveTurnEvent(live, {
     type: 'steering_message', id: 's1', turnId: 'turn-1', messageId: 'steer-1', ts: 2, content: { text: 'steer' },
   });
   live = applyLiveTurnEvent(live, {
-    type: 'thinking_delta', id: 'e2', turnId: 'turn-1', messageId: 'm1', ts: 3, text: 'BBBB',
+    type: 'tool_start', id: 'e2', turnId: 'turn-1', stepId: 'm1', toolUseId: 'tool-1', toolName: 'Read', args: {}, ts: 3,
   });
   live = applyLiveTurnEvent(live, {
-    type: 'thinking_complete', id: 'e3', turnId: 'turn-1', messageId: 'm1', ts: 4, text: 'ABC',
+    type: 'text_delta', id: 'e3', turnId: 'turn-1', messageId: 'm1', ts: 4, startOffset: 4, text: 'post',
+  });
+  live = applyLiveTurnEvent(live, {
+    type: 'text_delta', id: 'seed', turnId: 'turn-1', messageId: 'm1', ts: 5, startOffset: 0, text: 'pre-post more',
   });
 
   assert.deepEqual(timelineOrder(overlayLiveTurn([], live, 'en')[0]!.timeline), [
-    'thinking:AAAA',
+    'text:pre-',
     'user:steer',
-    'thinking:ABC',
+    'tools:tool-1',
+    'text:post more',
+  ]);
+});
+
+test('splits a completion by source offset when the sealed slice shows redacted text', () => {
+  const secret = 'sk-abcdef0123456789abcdef0123456789';
+  let live: LiveTurnProjection | undefined = applyLiveTurnEvent(armLiveTurn('turn-1'), {
+    type: 'thinking_delta', id: 'e1', turnId: 'turn-1', messageId: 'm1', ts: 1, text: `use ${secret}\n`,
+  });
+  live = applyLiveTurnEvent(live, {
+    type: 'steering_message', id: 's1', turnId: 'turn-1', messageId: 'steer-1', ts: 2, content: { text: 'steer' },
+  });
+  live = applyLiveTurnEvent(live, {
+    type: 'thinking_delta', id: 'e2', turnId: 'turn-1', messageId: 'm1', ts: 3, text: 'post',
+  });
+  live = applyLiveTurnEvent(live, {
+    type: 'thinking_complete', id: 'e3', turnId: 'turn-1', messageId: 'm1', ts: 4, text: `use ${secret}\npost`,
+  });
+
+  assert.deepEqual(timelineOrder(overlayLiveTurn([], live, 'en')[0]!.timeline), [
+    `thinking:${redactSecrets(`use ${secret}\n`)}`,
+    'user:steer',
+    'thinking:post',
   ]);
 });
 
diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts
index 1bb3511b878..d63bc97f68b 100644
--- a/packages/ui/src/__tests__/live-turn-projection.test.ts
+++ b/packages/ui/src/__tests__/live-turn-projection.test.ts
@@ -180,6 +180,23 @@ describe('applyLiveTurnEvent', () => {
     );
   });
 
+  it('drops a delta past the consumed source so a later reseed lands whole', () => {
+    const delta = {
+      type: 'text_delta' as const,
+      turnId: 'turn-1',
+      messageId: 'step-1',
+    };
+    const early = applyLiveTurnEvent(undefined, {
+      ...delta, id: 'live-1', ts: 100, startOffset: 5, text: ' world',
+    });
+    assert.equal(early.steps.length, 0);
+
+    const seeded = applyLiveTurnEvent(early, {
+      ...delta, id: 'seed-1', ts: 200, startOffset: 0, text: 'Hello world',
+    });
+    assert.equal(seeded.steps[0]?.text?.text, 'Hello world');
+  });
+
 
   it('projects transient provider retry progress until the next model output', () => {
     const scheduled = applyLiveTurnEvent(armLiveTurn('turn-1'), {
diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts
index 0c6bf2a1886..2dfdf76c803 100644
--- a/packages/ui/src/live-turn-projection.ts
+++ b/packages/ui/src/live-turn-projection.ts
@@ -19,6 +19,7 @@
 
 import {
   decodeToolStepProgress,
+  foldAssistantDelta,
   type MessageContent,
   type ProviderRetryEvent,
   type SessionEvent,
@@ -72,13 +73,6 @@ export interface LiveTurnStepProjection {
    * (`steering:`-prefixed stepIds collide with no real stepId).
    */
   steering?: LiveSteeringProjection;
-  /**
-   * A steering boundary can split a step mid-flight; the continuation slice
-   * keeps the same durable stepId. Replayed deltas trim against the earlier
-   * slice's source length through these baselines instead of re-appending it.
-   */
-  continuedThinkingEndOffset?: number;
-  continuedTextEndOffset?: number;
   thinking?: LiveThinkingProjection;
   text?: LiveTextProjection;
   tools: ToolActivityItem[];
@@ -330,26 +324,23 @@ function projectLiveTurnEvent(
         || event.stepId === existingToolStep.stepId
       ? prior.steps.indexOf(existingToolStep)
       : sameStepIndex;
-  const continuedFrom = stepIndex < 0 && sameStepIndex >= 0 ? prior.steps[sameStepIndex]! : undefined;
-  const continuedThinkingEnd = continuedFrom?.thinking?.sourceEndOffset ?? continuedFrom?.continuedThinkingEndOffset;
-  const continuedTextEnd = continuedFrom?.text?.sourceEndOffset ?? continuedFrom?.continuedTextEndOffset;
   const step: LiveTurnStepProjection = stepIndex < 0
-    ? {
-        stepId,
-        startedAt: event.ts,
-        tools: [],
-        ...(continuedThinkingEnd === undefined
-          ? {}
-          : { continuedThinkingEndOffset: continuedThinkingEnd }),
-        ...(continuedTextEnd === undefined
-          ? {}
-          : { continuedTextEndOffset: continuedTextEnd }),
-      }
+    ? { stepId, startedAt: event.ts, tools: [] }
     : prior.steps[stepIndex]!;
+  // Slices of one step share a source stream; each begins where the earlier
+  // slices of its step stopped. A completion carries the full source text, or
+  // a replacement that lands whole in the last slice.
+  const sliceStart = (kind: 'thinking' | 'text'): number => {
+    const before = stepIndex < 0 ? prior.steps.length : stepIndex;
+    return prior.steps.findLast((candidate, index) =>
+      index < before && candidate.stepId === stepId && candidate[kind] !== undefined,
+    )?.[kind]?.sourceEndOffset ?? 0;
+  };
   let nextStep: LiveTurnStepProjection;
   if (event.type === 'thinking_delta') {
-    const delta = replaySafeDelta(step.thinking?.sourceEndOffset ?? step.continuedThinkingEndOffset, event);
-    const applied = applyThinkingDelta(step.thinking?.text ?? '', delta.text, {
+    const delta = foldAssistantDelta(step.thinking?.sourceEndOffset ?? sliceStart('thinking'), event);
+    if (!delta) return confirmed(prior);
+    const applied = applyThinkingDelta(step.thinking?.text ?? '', delta.tail, {
       locale,
       ...(step.thinking?.redactionState === undefined
         ? {}
@@ -361,9 +352,7 @@ function projectLiveTurnEvent(
         text: applied.text,
         truncated: (step.thinking?.truncated ?? false) || applied.truncated,
         complete: false,
-        ...(delta.sourceEndOffset === undefined
-          ? {}
-          : { sourceEndOffset: delta.sourceEndOffset }),
+        sourceEndOffset: delta.endOffset,
         ...(applied.redactionState === undefined
           ? {}
           : { redactionState: applied.redactionState }),
@@ -371,7 +360,7 @@ function projectLiveTurnEvent(
     };
   } else if (event.type === 'thinking_complete') {
     const applied = applyThinkingComplete(
-      completionRemainder(prior, step, 'thinking', event.text),
+      event.replaced ? event.text : event.text.slice(sliceStart('thinking')),
       { locale },
     );
     nextStep = {
@@ -380,14 +369,13 @@ function projectLiveTurnEvent(
         text: applied.text,
         truncated: applied.truncated,
         complete: true,
-        ...((step.thinking?.sourceEndOffset ?? step.continuedThinkingEndOffset) === undefined
-          ? {}
-          : { sourceEndOffset: event.text.length }),
+        sourceEndOffset: event.text.length,
       },
     };
   } else if (event.type === 'text_delta') {
-    const delta = replaySafeDelta(step.text?.sourceEndOffset ?? step.continuedTextEndOffset, event);
-    const applied = applyAssistantDelta(step.text?.text ?? '', delta.text, {
+    const delta = foldAssistantDelta(step.text?.sourceEndOffset ?? sliceStart('text'), event);
+    if (!delta) return confirmed(prior);
+    const applied = applyAssistantDelta(step.text?.text ?? '', delta.tail, {
       locale,
       ...(step.text?.redactionState === undefined
         ? {}
@@ -399,9 +387,7 @@ function projectLiveTurnEvent(
         text: applied.text,
         truncated: (step.text?.truncated ?? false) || applied.truncated,
         complete: false,
-        ...(delta.sourceEndOffset === undefined
-          ? {}
-          : { sourceEndOffset: delta.sourceEndOffset }),
+        sourceEndOffset: delta.endOffset,
         ...(applied.redactionState === undefined
           ? {}
           : { redactionState: applied.redactionState }),
@@ -409,7 +395,7 @@ function projectLiveTurnEvent(
     };
   } else if (event.type === 'text_complete') {
     const applied = applyAssistantComplete(
-      completionRemainder(prior, step, 'text', event.text),
+      event.replaced ? event.text : event.text.slice(sliceStart('text')),
       { locale },
     );
     nextStep = {
@@ -419,9 +405,7 @@ function projectLiveTurnEvent(
         text: applied.text,
         truncated: applied.truncated,
         complete: true,
-        ...((step.text?.sourceEndOffset ?? step.continuedTextEndOffset) === undefined
-          ? {}
-          : { sourceEndOffset: event.text.length }),
+        sourceEndOffset: event.text.length,
       },
     };
   } else if (event.type === 'tool_start') {
@@ -590,48 +574,6 @@ function liveSteeringMessages(current: LiveTurnProjection): LiveSteeringProjecti
   return current.steps.flatMap((step) => (step.steering ? [step.steering] : []));
 }
 
-/**
- * A completion's full text shares the delta stream's coordinates only when it
- * extends the already-rendered prefix — a provider summary replaces the
- * streamed text outright (`reasoningSummaryText` adoption), so a bare offset
- * would cut real content. Trim only on a verified prefix; otherwise land the
- * payload whole.
- */
-function completionRemainder(
-  prior: LiveTurnProjection,
-  step: LiveTurnStepProjection,
-  kind: 'thinking' | 'text',
-  fullText: string,
-): string {
-  const rendered = prior.steps.flatMap((candidate) =>
-    candidate !== step && candidate.stepId === step.stepId && candidate[kind]
-      ? [candidate[kind]!.text]
-      : []);
-  const prefix = rendered.join('');
-  return fullText.startsWith(prefix) ? fullText.slice(prefix.length) : fullText;
-}
-
-function replaySafeDelta(
-  currentEndOffset: number | undefined,
-  event: Extract,
-): { text: string; sourceEndOffset?: number } {
-  if (event.startOffset === undefined) {
-    return {
-      text: event.text,
-      sourceEndOffset: (currentEndOffset ?? 0) + event.text.length,
-    };
-  }
-  const endOffset = event.startOffset + event.text.length;
-  if (currentEndOffset === undefined || event.startOffset > currentEndOffset) {
-    return { text: event.text, sourceEndOffset: endOffset };
-  }
-  const overlapLength = Math.min(currentEndOffset - event.startOffset, event.text.length);
-  return {
-    text: event.text.slice(overlapLength),
-    sourceEndOffset: Math.max(currentEndOffset, endOffset),
-  };
-}
-
 /**
  * Streaming display handoff: drop the committed text/thinking slots for `stepId`.
  * Tools that still carry live stream evidence (outputChunks) stay — empty