mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user