fix(mobile): keep repeated-prefix chat replies streaming

Text alone can't tell "the transcript caught up with this stream" from
"a new reply repeats the previous turn's prefix", so the old suppress-on-
prefix rule swallowed genuine repeated replies. A stateful gate remembers
which transcript tail predates the current stream segment and hides the
bubble only when that tail moved during the segment, scoped to the active
host/workspace/tab/session so a swapped chat can't inherit a baseline.

Refs STA-3333.
This commit is contained in:
Brennan Benson
2026-08-04 01:59:56 -07:00
parent e9cf106769
commit 838cb67ffe
8 changed files with 342 additions and 43 deletions
@@ -49,6 +49,7 @@ export function MobileNativeChatOverlay({
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
streamingText={controller.nativeChatStreamingText}
streamIdentity={controller.nativeChatStreamIdentity}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
+67 -11
View File
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatView } from './MobileNativeChatView'
vi.mock('react-native', () => ({
@@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => {
})
type Overrides = {
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
streamingText?: string
streamIdentity?: string
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
@@ -75,6 +79,23 @@ function suppressRendererWarning(): () => void {
return () => spy.mockRestore()
}
function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
return createElement(MobileNativeChatView, {
messages: [],
status: 'ready',
streamIdentity: 'test-stream',
onSend: vi.fn().mockResolvedValue(true),
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
}
describe('MobileNativeChatView send-error banner', () => {
let renderer: ReactTestRenderer | null = null
@@ -91,23 +112,25 @@ describe('MobileNativeChatView send-error banner', () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatView, {
messages: [],
status: 'ready',
onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
)
renderer = create(chatViewElement(overrides))
})
} finally {
restore()
}
}
async function update(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer?.update(chatViewElement(overrides))
})
}
/** Ids of the rows the list is currently rendering. */
function listIds(): string[] {
const list = renderer!.root.find((node) => node.type === 'FlatList')
return (list.props.data as { id: string }[]).map((row) => row.id)
}
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
@@ -161,4 +184,37 @@ describe('MobileNativeChatView send-error banner', () => {
expect(onClearSendError).toHaveBeenCalledOnce()
})
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
const prior = [assistantTurn('a1', 'The tests pass.')]
await render({ messages: prior })
expect(listIds()).toEqual(['a1'])
await update({ messages: prior, streamingText: 'The tests' })
expect(listIds()).toEqual(['a1', 'streaming'])
})
it('drops the streaming bubble once the reply lands as its own turn', async () => {
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.' })
expect(listIds()).toEqual(['a1', 'streaming'])
await update({
messages: [...prior, assistantTurn('a2', 'Done.')],
streamingText: 'Done.'
})
expect(listIds()).toEqual(['a1', 'a2'])
})
it("does not carry one chat's baseline into another stream identity", async () => {
const prior = [assistantTurn('a1', 'Shared answer text')]
await render({ messages: prior, streamIdentity: 'tab-a' })
await update({ messages: prior, streamingText: 'Shared answer', streamIdentity: 'tab-b' })
expect(listIds()).toEqual(['a1'])
})
})
+11 -2
View File
@@ -21,6 +21,7 @@ import {
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
@@ -50,6 +51,8 @@ type Props = {
/** Live partial assistant text while a turn is still streaming (from the agent
* status hook). Shown as an in-progress bubble until the transcript catches up. */
streamingText?: string
/** Scopes streaming catch-up state to the active host/workspace/tab/session. */
streamIdentity: string
hasMore?: boolean
loadingEarlier?: boolean
onLoadEarlier?: () => void
@@ -108,6 +111,7 @@ export function MobileNativeChatView({
agentWorking,
onStop,
streamingText,
streamIdentity,
hasMore,
loadingEarlier,
onLoadEarlier,
@@ -166,9 +170,14 @@ export function MobileNativeChatView({
// route-owned optimistic queued messages. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages])
const streaming = useMobileNativeChatStreamingBubble(
foldedMessages,
streamingText,
streamIdentity
)
const { data } = useMemo(
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }),
[foldedMessages, streamingText, pending]
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streaming, pending }),
[foldedMessages, streaming, pending]
)
// Follow the tail as the conversation grows and keep the newest message above
@@ -7,6 +7,10 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import { stripNoiseMessages } from './mobile-native-chat-noise'
import {
createMobileNativeChatStreamingGate,
deriveMobileNativeChatStreaming
} from './mobile-native-chat-streaming-gate'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
@@ -46,7 +50,9 @@ export type MobileNativeChatPendingItem = {
/** Derive the list data from the raw transcript: fold tool turns into the
* assistant turn, optionally append a synthetic streaming bubble, then the
* route-owned optimistic "queued" messages at the tail. Returns the
* intermediate `folded`/`streaming` so the caller can memoize on them. */
* intermediate `folded`/`streaming` so the caller can memoize on them.
* One-shot form: derives the streaming bubble through a fresh gate (no
* cross-tick memory); the live view threads its own gate instead. */
export function buildMobileNativeChatData({
messages,
streamingText,
@@ -57,7 +63,12 @@ export function buildMobileNativeChatData({
pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
const { streaming } = deriveMobileNativeChatStreaming(
createMobileNativeChatStreamingGate(),
folded,
streamingText
)
return buildMobileNativeChatTransientData({ folded, streaming, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
@@ -68,16 +79,14 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat
export function buildMobileNativeChatTransientData({
folded,
streamingText,
streaming,
pending
}: {
folded: NativeChatMessage[]
streamingText?: string
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
streaming: string | null
pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
// Only show the streaming bubble while its text leads the transcript — once the
// real assistant turn lands with the same text, drop the synthetic one.
const streaming = deriveStreaming(folded, streamingText)
const data: NativeChatMessage[] = [
...folded,
...(streaming
@@ -106,26 +115,3 @@ export function buildMobileNativeChatTransientData({
]
return { folded, streaming, data }
}
function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null {
const text = streamingText?.trim()
if (!text) {
return null
}
const last = folded[folded.length - 1]
const lastText =
last?.role === 'assistant'
? last.blocks
.filter((b) => b.type === 'text')
.map((b) => (b.type === 'text' ? b.text : ''))
.join('')
.trim()
: ''
// Hide the synthetic bubble only once the real turn has landed leading with the
// streamed text. A bare length compare would suppress a short new reply behind a
// longer previous turn; a completed prior turn won't start with the new prefix.
if (lastText.startsWith(text)) {
return null
}
return text
}
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
createMobileNativeChatStreamingGate,
deriveMobileNativeChatStreaming,
type MobileNativeChatStreamingGate
} from './mobile-native-chat-streaming-gate'
function assistant(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
/** Run a sequence of (folded, streamingText) ticks through one gate. */
function run(ticks: { folded: NativeChatMessage[]; text?: string }[]): {
gate: MobileNativeChatStreamingGate
results: (string | null)[]
} {
let gate = createMobileNativeChatStreamingGate()
const results: (string | null)[] = []
for (const tick of ticks) {
const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text)
gate = step.gate
results.push(step.streaming)
}
return { gate, results }
}
describe('deriveMobileNativeChatStreaming', () => {
it('shows a genuine reply that repeats the previous turn as a prefix', () => {
const prior = [assistant('a1', 'The tests pass.')]
const { results } = run([
{ folded: prior }, // idle tick anchors the pre-stream tail
{ folded: prior, text: 'The' },
{ folded: prior, text: 'The tests' },
{ folded: prior, text: 'The tests pass.' }
])
expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.'])
})
it('hides the bubble once the real turn lands leading with the streamed text', () => {
const prior = [assistant('a1', 'earlier turn')]
const landed = [...prior, assistant('a2', 'fresh answer with a tail')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'fresh answer' },
{ folded: landed, text: 'fresh answer' }
])
expect(results).toEqual([null, 'fresh answer', null])
})
it('suppresses an identical repeated reply once its own turn lands', () => {
const prior = [assistant('a1', 'Done.')]
const landed = [...prior, assistant('a2', 'Done.')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible
{ folded: landed, text: 'Done.' } // its own turn landed — hide
])
expect(results).toEqual([null, 'Done.', null])
})
it('keeps hiding for the rest of a segment after the turn lands', () => {
const prior = [assistant('a1', 'earlier')]
const landed = [...prior, assistant('a2', 'answer body')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'answer' },
{ folded: landed, text: 'answer' },
{ folded: landed, text: 'answer bo' }
])
expect(results).toEqual([null, 'answer', null, null])
})
it('re-anchors when a new reply part replaces the stream mid-turn', () => {
const prior = [assistant('a1', 'context')]
const partOneLanded = [...prior, assistant('a2', 'part one full text')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'part one' },
{ folded: partOneLanded, text: 'part one' }, // caught up — hide
// Part two is not an extension of part one: new segment, new baseline.
{ folded: partOneLanded, text: 'part' }
])
expect(results).toEqual([null, 'part one', null, 'part'])
})
it('falls back to suppress-on-prefix when mounted mid-stream', () => {
// No idle tick ever observed: a duplicate bubble is worse than briefly
// hiding a mount-coincident repeated reply.
const landed = [assistant('a1', 'flushed part still streaming in status')]
const { results } = run([{ folded: landed, text: 'flushed part' }])
expect(results).toEqual([null])
})
it('is idempotent for a repeated tick', () => {
const prior = [assistant('a1', 'The tests pass.')]
const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }])
const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests')
expect(again.streaming).toBe('The tests')
expect(again.gate).toBe(first.gate)
})
it('drops a prior chat baseline when the stream identity changes', () => {
// The other chat's tail must not license showing a bubble here — a swapped
// scope resets to the mid-stream fallback rather than reusing its baseline.
const repeatedId = [assistant('a1', 'new answer landed')]
let gate = createMobileNativeChatStreamingGate('tab-a')
gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, 'tab-a').gate
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', 'tab-b')
expect(switched.streaming).toBeNull()
expect(switched.gate.scopeKey).toBe('tab-b')
expect(switched.gate.baselineTailId).toBeNull()
})
it('returns null for empty or whitespace streaming text', () => {
const prior = [assistant('a1', 'x')]
expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null])
expect(run([{ folded: prior }]).results).toEqual([null])
})
it('shows the first reply of an empty chat and hides it once the turn lands', () => {
const landed = [assistant('a1', 'Hello there')]
const { results } = run([
{ folded: [] },
{ folded: [], text: 'Hello' },
{ folded: landed, text: 'Hello' }
])
expect(results).toEqual([null, 'Hello', null])
})
})
@@ -0,0 +1,82 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
/** Decides whether the live streaming preview should render as a synthetic
* bubble. Text alone can't tell "the transcript caught up with this stream"
* from "a new reply happens to repeat the previous turn's prefix" — the old
* prefix test swallowed genuine repeated-prefix replies. The gate keeps the
* transcript tail observed when the current stream segment began: the bubble
* hides only when the tail MOVED during the segment and leads with the
* streamed text (the real turn landed), never for an older identical turn. */
export type MobileNativeChatStreamingGate = {
/** Chat/session identity this baseline belongs to. */
scopeKey: string | null
/** Streamed text seen on the previous tick ('' while idle). */
prevText: string
/** Folded tail message id when the current segment began; null while the
* gate has never observed an idle tick (mounted mid-stream), where the
* legacy suppress-on-prefix rule applies. */
baselineTailId: string | null
}
export function createMobileNativeChatStreamingGate(
scopeKey: string | null = null
): MobileNativeChatStreamingGate {
return { scopeKey, prevText: '', baselineTailId: null }
}
function assistantTailText(tail: NativeChatMessage | undefined): string {
if (!tail || tail.role !== 'assistant') {
return ''
}
return tail.blocks
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
}
// Reuses the incoming gate object when nothing moved, so a caller can detect
// "no change" by reference (and a render-time state adjustment can settle).
function advanceGate(
gate: MobileNativeChatStreamingGate,
prevText: string,
baselineTailId: string | null
): MobileNativeChatStreamingGate {
return gate.prevText === prevText && gate.baselineTailId === baselineTailId
? gate
: { ...gate, prevText, baselineTailId }
}
/** Advance the gate one tick and derive the visible streaming text (null hides
* the bubble). Pure and idempotent for a repeated (text, tail) pair, so a
* re-render without new data cannot flip the decision. */
export function deriveMobileNativeChatStreaming(
gate: MobileNativeChatStreamingGate,
folded: readonly NativeChatMessage[],
streamingText: string | undefined,
scopeKey: string | null = gate.scopeKey
): { gate: MobileNativeChatStreamingGate; streaming: string | null } {
const scopedGate =
gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey)
const text = streamingText?.trim() ?? ''
const tail = folded.at(-1)
const tailId = tail?.id ?? null
if (!text) {
// Idle: keep anchoring the baseline to the live tail so the next segment
// knows which tail predates it.
return { gate: advanceGate(scopedGate, '', tailId), streaming: null }
}
// A stream that is not an extension of the previous tick is a new segment
// (next reply part); re-anchor to the tail that predates it.
const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
// baselineTailId === null: mounted mid-stream with no pre-stream observation —
// fall back to suppress-on-prefix (a duplicate bubble is worse than briefly
// hiding an improbable mount-coincident repeated reply).
const caughtUp = tailLeadsWithStream && (baselineTailId === null || tailId !== baselineTailId)
return {
gate: advanceGate(scopedGate, text, baselineTailId),
streaming: caughtUp ? null : text
}
}
@@ -49,6 +49,8 @@ export type MobileNativeChatController = {
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Host/workspace/tab/session identity for stateful streaming suppression. */
nativeChatStreamIdentity: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
@@ -273,6 +275,7 @@ export function useMobileNativeChatController(args: {
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatStreamIdentity: streamIdentity,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
@@ -0,0 +1,24 @@
import { useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
createMobileNativeChatStreamingGate,
deriveMobileNativeChatStreaming
} from './mobile-native-chat-streaming-gate'
/** Live streaming-bubble text for the chat list. The gate remembers which
* transcript tail predates the current stream segment, so a reply that repeats
* the previous turn's prefix still shows while streaming. */
export function useMobileNativeChatStreamingBubble(
folded: readonly NativeChatMessage[],
streamingText: string | undefined,
scopeKey: string
): string | null {
const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey))
const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, scopeKey)
if (step.gate !== gate) {
// Render-time state adjustment (derived-state pattern): the advance is
// idempotent for a repeated (text, tail) pair, so this settles in one pass.
setGate(step.gate)
}
return step.streaming
}