diff --git a/apps/desktop/.storybook/main.ts b/apps/desktop/.storybook/main.ts index d1040d7eac..0e79b06996 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 71868b43fe..0000000000 --- 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 a219bfc2ed..0000000000 --- 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 452cfde8bb..0fe9fdde75 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 67d055b4cd..c1f75870a8 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/cli/src/acp/session-event-mapper.ts b/packages/cli/src/acp/session-event-mapper.ts index 1ebc785d17..d2dceaa80d 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 b56aa7cc0c..1c4585eeaa 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 96ee32362a..95fc47a136 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 129efa5359..214c435d02 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 944644e1c2..e05826d45d 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/README.md b/packages/ui/README.md index ff26b5ba91..94cfa37f41 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 85da464e78..61abfea44a 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__/chat-turn-steering-order.test.ts b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts index 756032d42a..64288f36a8 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 1bb3511b87..d63bc97f68 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/__tests__/stream-delta.test.ts b/packages/ui/src/__tests__/stream-delta.test.ts index 372a67ba79..0adcc90284 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 fdf4a1b7a0..6032fc06d7 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 2cb10ab8e9..7b3a99488d 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/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 0c6bf2a188..2dfdf76c80 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 diff --git a/packages/ui/src/shared-ui-copy.ts b/packages/ui/src/shared-ui-copy.ts index c6c94a4537..74d2aed636 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 31d0dd5ee7..cca6525f34 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 9c5cf65317..e5b7d4ff60 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 9c29e8a47e..997b9715c5 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 }),