fix(mobile): stop the streaming gate adopting a reply as its own history

A textless status tick was re-anchoring the gate's pre-stream baseline, so
two paths still rendered wrong:

- The reply's transcript push beats its throttled status text whenever the
  pane stays `working` past the turn (a live subagent or background task).
  The tick in between adopted the just-landed reply as history, and the
  status text that followed rendered it a second time — a duplicate bubble,
  and a regression against main's suppress-on-prefix rule.
- Peeking at the terminal between turns empties the transcript. That empty
  tail was adopted as the baseline, so the next repeated-prefix reply was
  swallowed again — the bug this PR exists to fix.

Only a tick that carries a real tail and sits outside a live turn anchors
now, with an exception for a gate that has never anchored: mounted mid-turn,
the first real tail it sees is the best history it will ever get.

Also drop `buildMobileNativeChatData`, a test-only builder this PR had wired
the new gate into; its green test asserted the exact suppression this PR
removes. Its fold/pending/image coverage moves to the builder the view calls.
This commit is contained in:
Brennan Benson
2026-08-04 04:19:58 -07:00
parent 1ccb9516bc
commit fed9ca386c
6 changed files with 132 additions and 103 deletions
@@ -145,6 +145,21 @@ describe('MobileNativeChatOverlay streaming gate', () => {
expect(streaming()).toBe('Done.')
})
it('keeps the bubble across a peek at the terminal taken between turns', async () => {
// Same toggle, but taken while idle: the transcript empties before the next
// turn starts, so the gate has to reject that empty tail as a baseline.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ show: false, messages: [] })
await update({ show: false, messages: [], streamLive: true })
await update({ messages: [], streamLive: true })
await update({ messages: [], streamingText: 'Done.', streamLive: true })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
})
it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
// Between parts the status frame carries no assistant text (a tool call), so
// the stream goes textless while the turn is still live and the part that
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildMobileNativeChatData,
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
@@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => {
})
})
describe('buildMobileNativeChatData', () => {
/** Mirrors the view: fold the raw transcript, then assemble the list. */
function build(
messages: NativeChatMessage[],
streaming: string | null,
pending: Parameters<typeof buildMobileNativeChatTransientData>[0]['pending']
): NativeChatMessage[] {
return buildMobileNativeChatTransientData({
folded: foldMobileNativeChatMessages(messages),
streaming,
pending
}).data
}
describe('buildMobileNativeChatTransientData', () => {
it('appends pending optimistic messages at the tail as user turns', () => {
const { data } = buildMobileNativeChatData({
messages: [assistant('a1', 'hello')],
streamingText: undefined,
pending: [{ id: 'p1', text: 'queued' }]
})
const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }])
const last = data[data.length - 1]
expect(last.id).toBe('p1')
expect(last.role).toBe('user')
@@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => {
})
it('renders a pending send with images as text followed by image-ref thumbnails', () => {
const { data } = buildMobileNativeChatData({
messages: [],
pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
})
const data = build([], null, [
{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }
])
const last = data[data.length - 1]
expect(last.role).toBe('user')
expect(last.blocks).toEqual([
@@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => {
})
it('renders an image-only pending send (no text) as just the thumbnail', () => {
const { data } = buildMobileNativeChatData({
messages: [],
pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
})
const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }])
expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
})
@@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => {
// Claude records an attached image as `[Image: source: /path]` + an
// `[Image #1] `-prefixed caption turn; the fold must merge them into one
// user turn with an image-ref block instead of showing raw marker text.
const { data } = buildMobileNativeChatData({
messages: [
const data = build(
[
user('u1', '[Image: source: /tmp/a.png]'),
user('u2', '[Image #1] look at this'),
assistant('a1', 'nice photo')
],
pending: []
})
null,
[]
)
const merged = data.find((message) => message.role === 'user')
expect(merged?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
@@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => {
})
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
const { data } = buildMobileNativeChatData({
messages: [user('u1', '[Image: source: /tmp/a.png]')],
pending: []
})
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
})
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [user('u1', 'hi')],
streamingText: 'thinking out loud',
pending: []
})
expect(streaming).toBe('thinking out loud')
expect(data.some((m) => m.id === 'streaming')).toBe(true)
it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
// Whether text streams at all is the gate's call
// (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }])
expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1'])
expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }])
})
it('shows a short new streaming reply even after a longer previous turn', () => {
// The last folded turn is a long completed reply; a short new stream must not
// be suppressed just for being shorter than the prior turn.
const { streaming, data } = buildMobileNativeChatData({
messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')],
streamingText: 'Ok',
pending: []
})
expect(streaming).toBe('Ok')
expect(data.some((m) => m.id === 'streaming')).toBe(true)
})
it('drops the streaming bubble once the real assistant turn already contains it', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [assistant('a1', 'done answer')],
streamingText: 'done',
pending: []
})
expect(streaming).toBeNull()
expect(data.some((m) => m.id === 'streaming')).toBe(false)
})
it('returns no streaming bubble for empty/whitespace streaming text', () => {
expect(
buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming
).toBeNull()
expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull()
it('omits the bubble when the gate withheld the streaming text', () => {
const data = build([assistant('a1', 'done answer')], null, [])
expect(data.some((message) => message.id === 'streaming')).toBe(false)
})
})
@@ -7,10 +7,6 @@ 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
@@ -47,36 +43,15 @@ export type MobileNativeChatPendingItem = {
images?: string[]
}
/** 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.
* 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,
pending
}: {
messages: NativeChatMessage[]
streamingText?: string
pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
const { streaming } = deriveMobileNativeChatStreaming(
createMobileNativeChatStreamingGate(),
folded,
streamingText
)
return buildMobileNativeChatTransientData({ folded, streaming, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
// Normalize first (desktop assembler parity): image marker turns fold into
// image-ref blocks instead of rendering as raw `[Image: …]` text.
return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
}
/** Assemble the list data the chat renders: the folded transcript, then a
* synthetic bubble for the streaming text the gate let through, then the
* route-owned optimistic "queued" messages at the tail. */
export function buildMobileNativeChatTransientData({
folded,
streaming,
@@ -106,6 +106,47 @@ describe('deriveMobileNativeChatStreaming', () => {
expect(results).toEqual([null, 'Done.', null, null])
})
it('hides a reply whose own turn landed before its status text arrived', () => {
// The pane stays `working` past the reply (a subagent or a background task
// is still live), and the transcript push beats the throttled status text.
// Anchoring on that textless tick would adopt the reply as pre-stream
// history and render it a second time as a bubble.
const prior = [assistant('a1', 'Done.')]
const landed = [...prior, assistant('a2', 'Done.')]
const { results } = run([
{ folded: prior, live: true },
{ folded: landed, live: true },
{ folded: landed, text: 'Done.', live: true },
{ folded: landed, text: 'Done.', live: true }
])
expect(results).toEqual([null, null, null, null])
})
it('keeps the pre-stream baseline across a hidden gap taken between turns', () => {
// Peeking at the terminal while idle tears the transcript down to empty. An
// empty tail is not history: adopting it strands the baseline and swallows
// the repeated-prefix reply that arrives next.
const prior = [assistant('a1', 'Done.')]
const { results } = run([
{ folded: prior },
{ folded: [] },
{ folded: [], live: true },
{ folded: prior, text: 'Done.', live: true }
])
expect(results).toEqual([null, null, null, 'Done.'])
})
it('anchors on the first tail it sees when mounted mid-turn', () => {
// Opening a workspace whose agent is already working: that first textless
// tick is the only pre-stream history the gate will ever get.
const prior = [assistant('a1', 'Done.')]
const { results } = run([
{ folded: prior, live: true },
{ folded: prior, text: 'Done.', live: true }
])
expect(results).toEqual([null, 'Done.'])
})
it('anchors on a textless tick once the turn ends', () => {
const prior = [assistant('a1', 'first answer')]
const landed = [...prior, assistant('a2', 'second answer')]
@@ -131,9 +172,9 @@ describe('deriveMobileNativeChatStreaming', () => {
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.
it('falls back to suppress-on-prefix when text arrives on the first tick', () => {
// No tail ever observed before the text: 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])
@@ -13,8 +13,8 @@ export type MobileNativeChatStreamingGate = {
/** 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. */
* gate has never observed a transcript tail (text arrived on its very first
* tick), where the legacy suppress-on-prefix rule applies. */
baselineTailId: string | null
}
@@ -68,23 +68,23 @@ export function deriveMobileNativeChatStreaming(
const tail = folded.at(-1)
const tailId = tail?.id ?? null
if (!text) {
if (options.streamLive && scopedGate.prevText !== '') {
// A gap inside a live segment (chat hidden, transcript reloading, throttle
// not caught up): re-anchoring here would adopt the reply that just landed
// as pre-stream history and render it a second time as a bubble.
return { gate: scopedGate, streaming: null }
}
// 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 }
// Only a textless tick that carries a real tail and is outside a live turn
// is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle
// lull, the transcript landing the reply before its status text) would
// otherwise adopt that reply as history and render it a second time as a
// bubble; a torn-down transcript carries no tail at all. The exception is a
// gate that has never anchored — mounted mid-turn, the first real tail it
// sees is the best pre-stream history it will ever get.
const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null)
return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, 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)
// A null baseline (mounted mid-stream, never saw an idle tick) is unequal to
// every real tail id, so this degrades to the legacy suppress-on-prefix rule.
// A null baseline (text on the very first tick, no tail ever seen) is unequal
// to every real tail id, so this degrades to the legacy suppress-on-prefix rule.
const caughtUp = tailLeadsWithStream && tailId !== baselineTailId
return {
gate: advanceGate(scopedGate, text, baselineTailId),
@@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
}))
const answerSendArgs: { streamIdentity?: string }[] = []
vi.mock('./use-mobile-native-chat-answer-send', () => ({
useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
answerSendArgs.push(args)
return { answerAsk: vi.fn(), cancelPending: vi.fn() }
}
}))
vi.mock('./mobile-native-chat-permission-send', () => ({
useMobileNativeChatPermissionSend: () => vi.fn()
@@ -500,4 +504,20 @@ describe('useMobileNativeChatController streaming scope', () => {
expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey)
expect(controller?.nativeChatStreamLive).toBe(true)
})
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
// `streamIdentity` fences a delayed answer/stop to the route it was armed
// on, so it must still drop its session when chat closes — the scope key is
// the one that has to survive the toggle. Same string while chat is open.
const before = answerSendArgs.at(-1)?.streamIdentity
expect(before).toBe(controller?.nativeChatStreamScopeKey)
viewMode.isTabChatView = () => false
act(() => renderer?.update(createElement(Harness)))
const after = answerSendArgs.at(-1)?.streamIdentity
expect(after).not.toBe(before)
expect(after).not.toContain('session-1')
expect(controller?.nativeChatStreamScopeKey).toBe(before)
})
})