From fed9ca386c7b83d45e131ab1aeb2a448e14d1f21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:19:58 -0700 Subject: [PATCH] fix(mobile): stop the streaming gate adopting a reply as its own history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../session/MobileNativeChatOverlay.test.ts | 15 +++ .../mobile-native-chat-render-data.test.ts | 94 +++++++------------ .../session/mobile-native-chat-render-data.ts | 31 +----- .../mobile-native-chat-streaming-gate.test.ts | 47 +++++++++- .../mobile-native-chat-streaming-gate.ts | 26 ++--- .../use-mobile-native-chat-controller.test.ts | 22 ++++- 6 files changed, 132 insertions(+), 103 deletions(-) diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2fd3da51f36..2680064c94b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -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 diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 4dabff8f600..b4a273d31ca 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -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[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) }) }) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 252fdbb66fd..e11b9ac56f4 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -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, diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts index 3770028b2b3..580ff3972f4 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -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]) diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts index 3973460a4e6..a857803214c 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -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), diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 5afee16b891..35318d7e7f2 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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) + }) })