mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
* fix(mobile-native-chat): reland glued pending retirement without the two revert causes Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when two fast sends landed as one transcript row, but shipped two regressions; both are fixed here rather than re-applied and hoped for. 1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to `text.trimEnd()` at the top of `sendMessage` and then used that one value for both the bytes on the wire and the composer restore, so a rejection put back less than the user typed. The draft and the payload are now separate values: `draftText` is what the user typed and is what `clearDraftForSend` / `restoreRejectedDraft` see; only the transported `text` is trimmed. 2. Sends issued during hydration were stranded forever. #14665 persisted `glueBaselineTrusted: false` on any send captured while the transcript was still loading and never cleared it, so that send could never retire and stood as a permanent glue barrier for its neighbours. A hydration-time baseline is now a placeholder (`baselineResolved: false`) that the first authoritative read rebases onto real rows, ordinals included, instead of a permanent disqualification. That is STA-4492. The intended behavior is unchanged: one transcript user turn retires a run of 2+ adjacent text-only pending sends only when it exactly spells their normalized concatenation, every send is bounded by its OWN transcript tail, and exact landings, image echoes and unresolved tails stay barriers. No wire change: `baselineResolved` and the baseline tail are client-local React state in `pendingBySession` and are never exchanged with a host. The only client->host difference is trailing whitespace no longer being written onto the agent's input line, over the existing `terminal.send` params. Refs STA-4482, STA-4492. Original PR #14665, revert #14819. * fix(mobile-native-chat): let the untrimmed draft reach the send seam The composer sent `value.trimEnd()`, so the raw draft never reached `sendMessage` and a rejected send still handed back a trimmed composer — the split of `draftText` from the transported `text` had nothing to restore. Pass the draft through; the seam already owns the wire trim. Also pins the array-identity contract of `retireLandedMobileNativeChatPending`: the drafts effect early-outs on `next === current`, and nothing tested it. * docs(mobile-native-chat): name the hydration rebase's residual ambiguity * fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo Rebasing recounted the send's ordinal against the first authoritative read. That read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever exists now — so the ordinal landed one past anything the transcript could supply. The bubble never cleared, it stayed a live segment at the head of its run so no later pair could glue either, and `earlierOutstanding` carried the inflation onto the next send of the same text. Only the tail needs recovering; the ordinal was already counted against an empty transcript, which is right for "no history was known". A caption-less image echo keeps its captured tail, since it counts turns after it. `baselineResolved` also has to mean "captured against a settled read", not merely "not loading": a read that failed hands back an empty list that reads as an empty conversation, and the null tail then let any row the successful read finally brought glue-retire those sends. * test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable A held send sits as a live segment at the head of its run, so the cursor can never reach a later pair — the stuck bubble takes the whole feature down with it. Goes red against the ordinal recount. * fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness. * fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read The image-preview pass runs before the rebase, so a send captured with no boundary matched any image turn the settled read carried — binding the user's freshly attached photo to an old one and retiring the bubble through landedImagePendingIds, which short-circuits the retirement path entirely. Pinning the tail in the rebase could not help: the claim was already made. Such an entry now waits one tick and claims against a real tail. * fix(mobile-native-chat): never move a boundary the send already captured An unsettled read still shows this session's own retained history — a reconnect or a failed read keeps the conversation on screen rather than blanking it — so sends made across one already own a correct tail. The rebase overwrote it with the tail of the read that followed, which sits at or after their own glued row, so `turn.index <= segment.tail` rejected every turn and the pair stayed queued for the session, blocking every later pair in the run. Pin only a send that captured no tail at all. A captioned image echo is now left alone entirely: it binds its preview by an ordinal counted over the whole transcript, so supplying a tail without recounting left it matching nothing, forever. * fix(mobile-native-chat): supply a boundary only to a text-bearing send An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured. * fix(mobile-native-chat): stop one unmatchable send freezing glue for the session The match cursor only advanced on a hit, so a head that could never match — a pair whose glued row arrived with the read, or a send the count pass claimed against an older row — froze the run behind it and every later rapid pair became permanently unretirable. Two cases previously disclosed as bounded were not bounded at all. Slide past a non-matching head, keeping the cursor monotonic so a later turn can never take a send an earlier one claimed. The slide widens the search, so a span cap keeps the work linear in the run length instead of quadratic; the existing budget test now asserts that bound rather than the old one it silently broke. Re-fuzzed at 250k seeds: the boundary guarantee still holds. Also corrects a comment that claimed the preview-pass filter made a photo claim against a real tail. It does not — an image echo keeps whatever tail it captured, so a caption-less photo can still bind to an older photo turn, as on main. * fix(mobile-native-chat): stop the span cap stranding a long glued run Capping each match attempt at 8 segments did not truncate a longer glue, it rejected it outright: a row spelling 9+ sends exhausted the loop without reaching the end of the text and returned zero, so none of the nine retired — and each stuck send then inflated `earlierOutstanding` for the next send of the same text. Nothing bounds how many sends pile onto the agent's input line; accumulation ends when the agent accepts input again, not at any fixed count. One inspection budget now covers the whole slide instead. The first attempt spans the entire run and always fits, so a genuine glue is never truncated; only a run of identical prefix-matching sends can exhaust the budget, which is exactly the case that should be cheap. The in-flight attempt may overshoot the remainder — that is what makes the guarantee hold — so the budget test asserts the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
766 lines
27 KiB
TypeScript
766 lines
27 KiB
TypeScript
import { createElement } from 'react'
|
||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import type { RpcClient } from '../transport/rpc-client'
|
||
import type { ConnectionState } from '../transport/types'
|
||
|
||
const acceptSend = vi.fn()
|
||
const captureSendOrigin = vi.fn()
|
||
const clearDraftForSend = vi.fn()
|
||
const restoreRejectedDraft = vi.fn()
|
||
const holdUnconfirmedSend = vi.fn()
|
||
|
||
// Mutable stand-ins so the launch-draft wiring below can drive chat resolution
|
||
// and transcript state; defaults keep the send-seam tests unchanged.
|
||
const viewMode = { isTabChatView: (_tabId: string) => true }
|
||
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
|
||
const draftsArgs: Record<string, unknown>[] = []
|
||
const promptsState = {
|
||
permission: null as unknown,
|
||
question: null as unknown,
|
||
detectedAsk: null as unknown,
|
||
ask: null as unknown
|
||
}
|
||
|
||
// The controller composes many session hooks; each is mocked to a minimal shape
|
||
// so this test isolates the send seam (outcome -> drafts accounting).
|
||
vi.mock('./use-mobile-session-view-mode', () => ({
|
||
useMobileSessionViewMode: () => ({
|
||
isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId),
|
||
toggleTabChatView: vi.fn()
|
||
})
|
||
}))
|
||
vi.mock('./use-mobile-native-chat-session', () => ({
|
||
useMobileNativeChatSession: () => sessionState
|
||
}))
|
||
vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
|
||
draftsArgs.push(args)
|
||
return {
|
||
composerText: '',
|
||
setComposerText: vi.fn(),
|
||
pending: [],
|
||
imagePreviewsByMessageId: {},
|
||
captureSendOrigin,
|
||
readSeededLaunchDraft: () => null,
|
||
readSeededLaunchDraftSeed: () => null,
|
||
clearDraftForSend,
|
||
restoreRejectedDraft,
|
||
acceptSend,
|
||
holdUnconfirmedSend
|
||
}
|
||
}
|
||
}))
|
||
vi.mock('./use-mobile-native-chat-prompts', () => ({
|
||
useMobileNativeChatPrompts: () => promptsState
|
||
}))
|
||
const answerSendArgs: { streamIdentity?: string }[] = []
|
||
vi.mock('./use-mobile-native-chat-answer-send', () => ({
|
||
useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
|
||
answerSendArgs.push(args)
|
||
return { answerAsk: vi.fn(), cancelPending: vi.fn() }
|
||
}
|
||
}))
|
||
vi.mock('./mobile-native-chat-permission-send', () => ({
|
||
useMobileNativeChatPermissionSend: () => vi.fn()
|
||
}))
|
||
vi.mock('./use-mobile-native-chat-stop', () => ({
|
||
useMobileNativeChatStop: () => vi.fn()
|
||
}))
|
||
vi.mock('./use-mobile-native-chat-file-search', () => ({
|
||
useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() })
|
||
}))
|
||
// Partial: the stale-input heal reaches the real transport through image-send,
|
||
// which must read the REAL timeout constant, not a copy that can silently drift.
|
||
vi.mock('./mobile-native-chat-send', async (importOriginal) => ({
|
||
...(await importOriginal<typeof import('./mobile-native-chat-send')>()),
|
||
sendMobileNativeChatMessageWithOutcome: vi.fn()
|
||
}))
|
||
|
||
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
|
||
import {
|
||
isMobileNativeChatInputStale,
|
||
markMobileNativeChatInputStale,
|
||
resetMobileNativeChatStaleInputForTests
|
||
} from './mobile-native-chat-stale-input'
|
||
import {
|
||
useMobileNativeChatController,
|
||
type MobileNativeChatController
|
||
} from './use-mobile-native-chat-controller'
|
||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||
|
||
const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
|
||
|
||
const ORIGIN = {
|
||
draftKey: 'h\0w\0tab-1',
|
||
pendingKey: 'h\0w\0tab-1\0session-1',
|
||
normalizedText: 'look',
|
||
baselineOccurrences: 0,
|
||
baselineTailMessageId: null,
|
||
baselineResolved: true
|
||
}
|
||
|
||
describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
let controller: MobileNativeChatController | null = null
|
||
const onSendError = vi.fn()
|
||
const onSendResolved = vi.fn()
|
||
// Only the stale-input heal reaches the transport directly (the message send
|
||
// itself is mocked above).
|
||
const clientStub = { sendRequest: vi.fn() }
|
||
|
||
function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null {
|
||
controller = useMobileNativeChatController({
|
||
client: clientStub as unknown as RpcClient,
|
||
connState,
|
||
hostId: 'h',
|
||
worktreeId: 'w',
|
||
activeSessionTab: null,
|
||
activeSessionTabId: 'tab-1',
|
||
activeHandleRef: { current: 'term-1' },
|
||
deviceTokenRef: { current: null },
|
||
nativeChatTranscriptIsLocalReadable: true,
|
||
nativeChatInputLeaseReady: true,
|
||
onSendError,
|
||
onSendResolved
|
||
})
|
||
return null
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
resetMobileNativeChatStaleInputForTests()
|
||
captureSendOrigin.mockReturnValue(ORIGIN)
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
})
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
controller = null
|
||
})
|
||
|
||
it('clears an orphaned image paste before a question-card answer (#10228)', async () => {
|
||
// The chat overlay wires the question card straight to this send, bypassing
|
||
// the image hook that used to own the only heal.
|
||
markMobileNativeChatInputStale('term-1')
|
||
clientStub.sendRequest.mockResolvedValue({
|
||
id: 'send',
|
||
ok: true,
|
||
result: { send: { accepted: true } },
|
||
_meta: { runtimeId: 'r' }
|
||
})
|
||
sendWithOutcome.mockResolvedValue('accepted')
|
||
let accepted = false
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatSend('answer')
|
||
})
|
||
expect(accepted).toBe(true)
|
||
expect(clientStub.sendRequest).toHaveBeenCalledTimes(1)
|
||
expect(clientStub.sendRequest.mock.calls[0]?.[1]).toMatchObject({
|
||
terminal: 'term-1',
|
||
text: '\x15',
|
||
enter: false
|
||
})
|
||
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
|
||
})
|
||
|
||
it('does not send when the healing clear is rejected, keeping the marker', async () => {
|
||
markMobileNativeChatInputStale('term-1')
|
||
clientStub.sendRequest.mockResolvedValue({
|
||
id: 'send',
|
||
ok: true,
|
||
result: { send: { accepted: false } },
|
||
_meta: { runtimeId: 'r' }
|
||
})
|
||
let accepted = true
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatSend('answer')
|
||
})
|
||
expect(accepted).toBe(false)
|
||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||
})
|
||
|
||
it('keeps the marker when Escape cancels an ask, which never submits the composer', async () => {
|
||
markMobileNativeChatInputStale('term-1')
|
||
sendWithOutcome.mockResolvedValue('accepted')
|
||
let accepted = false
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatCancelAsk()
|
||
})
|
||
expect(accepted).toBe(true)
|
||
// The clear would be swallowed by the live overlay but still acked, burning
|
||
// the marker and leaving the paste to corrupt the next real message.
|
||
expect(clientStub.sendRequest).not.toHaveBeenCalled()
|
||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||
})
|
||
|
||
it('retires a held failure banner when a card action is accepted', async () => {
|
||
// The banner is route-owned and outlives the write that raised it, so an accepted
|
||
// answer or permission reply must clear it too — not just a composer send.
|
||
sendWithOutcome.mockResolvedValue('accepted')
|
||
await act(async () => {
|
||
await controller!.handleNativeChatCancelAsk()
|
||
})
|
||
expect(onSendResolved).toHaveBeenCalled()
|
||
|
||
onSendResolved.mockClear()
|
||
sendWithOutcome.mockResolvedValue('rejected')
|
||
await act(async () => {
|
||
await controller!.handleNativeChatCancelAsk()
|
||
})
|
||
expect(onSendResolved).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => {
|
||
sendWithOutcome.mockResolvedValue('accepted')
|
||
let accepted = false
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
|
||
})
|
||
expect(accepted).toBe(true)
|
||
expect(acceptSend).toHaveBeenCalledWith(ORIGIN, 'look', ['file:///a.jpg'])
|
||
// Optimistic clear happens at send time, never a restore on success.
|
||
expect(clearDraftForSend).toHaveBeenCalledWith(ORIGIN, 'look')
|
||
expect(restoreRejectedDraft).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('pre-clears the input line for a text-only send but never for an image send', async () => {
|
||
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
|
||
// this send; a second clear here wipes the image off the input line and the
|
||
// agent receives text alone while the echo bubble still shows the thumbnail.
|
||
sendWithOutcome.mockResolvedValue('accepted')
|
||
|
||
await act(async () => {
|
||
await controller!.handleNativeChatSend('answer')
|
||
})
|
||
expect(sendWithOutcome).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ text: 'answer', clearInputFirst: true })
|
||
)
|
||
|
||
await act(async () => {
|
||
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
|
||
})
|
||
expect(sendWithOutcome).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ text: 'look', clearInputFirst: false })
|
||
)
|
||
})
|
||
|
||
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
|
||
sendWithOutcome.mockResolvedValue('unknown')
|
||
let accepted = false
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
|
||
})
|
||
expect(accepted).toBe(true)
|
||
expect(acceptSend).not.toHaveBeenCalled()
|
||
expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
|
||
// Delivery-unknown usually means delivered — keep the composer clear.
|
||
expect(clearDraftForSend).toHaveBeenCalledWith(ORIGIN, 'look')
|
||
expect(restoreRejectedDraft).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('preserves the unknown outcome on the WithOutcome surface for paste-first callers', async () => {
|
||
sendWithOutcome.mockResolvedValue('unknown')
|
||
let outcome = 'accepted'
|
||
await act(async () => {
|
||
outcome = await controller!.handleNativeChatSendWithOutcome('look', ['file:///a.jpg'])
|
||
})
|
||
// Image sends heal a possibly-orphaned paste off this — 'unknown' must not
|
||
// collapse into the boolean 'sent' shape (#10228).
|
||
expect(outcome).toBe('unknown')
|
||
expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
|
||
})
|
||
|
||
it('fails a send fast while the socket is down, before spending the heal budget', async () => {
|
||
// The lease collapses a render after connState, so a question-card answer could
|
||
// otherwise sit in `sending` for the whole 15s heal+send budget.
|
||
markMobileNativeChatInputStale('term-1')
|
||
await act(async () => {
|
||
renderer?.update(createElement(Harness, { connState: 'connecting' }))
|
||
})
|
||
let accepted = true
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatSend('answer')
|
||
})
|
||
expect(accepted).toBe(false)
|
||
expect(clientStub.sendRequest).not.toHaveBeenCalled()
|
||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||
expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
|
||
})
|
||
|
||
it('reports a rejected send and posts no echo', async () => {
|
||
sendWithOutcome.mockResolvedValue('rejected')
|
||
let accepted = true
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
|
||
})
|
||
expect(accepted).toBe(false)
|
||
expect(acceptSend).not.toHaveBeenCalled()
|
||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||
// A definite rejection puts the optimistically-cleared text back.
|
||
expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, 'look')
|
||
})
|
||
|
||
it('does not restore a rejected question answer into the composer', async () => {
|
||
sendWithOutcome.mockResolvedValue('rejected')
|
||
let accepted = true
|
||
await act(async () => {
|
||
accepted = await controller!.handleNativeChatQuestionAnswer('1')
|
||
})
|
||
|
||
expect(accepted).toBe(false)
|
||
expect(clearDraftForSend).not.toHaveBeenCalled()
|
||
expect(restoreRejectedDraft).not.toHaveBeenCalled()
|
||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||
})
|
||
})
|
||
|
||
describe('useMobileNativeChatController launch-draft wiring', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
const clientStub = { sendRequest: vi.fn() }
|
||
|
||
const chatTab = {
|
||
type: 'terminal',
|
||
id: 'tab-1',
|
||
title: 'Claude',
|
||
terminal: 'term-1',
|
||
launchAgent: 'claude',
|
||
launchDraft: 'https://github.com/o/r/issues/12',
|
||
launchDraftCreatedAt: 7,
|
||
isActive: true
|
||
}
|
||
|
||
function Harness({ tab }: { tab: unknown }): null {
|
||
useMobileNativeChatController({
|
||
client: clientStub as unknown as RpcClient,
|
||
connState: 'connected',
|
||
hostId: 'h',
|
||
worktreeId: 'w',
|
||
activeSessionTab: tab as never,
|
||
activeSessionTabId: 'tab-1',
|
||
activeHandleRef: { current: 'term-1' },
|
||
deviceTokenRef: { current: null },
|
||
nativeChatTranscriptIsLocalReadable: true,
|
||
nativeChatInputLeaseReady: true,
|
||
onSendError: vi.fn(),
|
||
onSendResolved: vi.fn()
|
||
})
|
||
return null
|
||
}
|
||
|
||
function render(tab: unknown): void {
|
||
act(() => {
|
||
renderer = create(createElement(Harness, { tab }))
|
||
})
|
||
}
|
||
|
||
beforeEach(() => {
|
||
draftsArgs.length = 0
|
||
viewMode.isTabChatView = () => true
|
||
sessionState.messages = []
|
||
sessionState.status = 'ready'
|
||
sessionState.transcriptLoading = false
|
||
})
|
||
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
})
|
||
|
||
it('forwards the tab launch draft and chat-active flag for a chat-resolved tab', () => {
|
||
render(chatTab)
|
||
|
||
expect(draftsArgs.at(-1)).toMatchObject({
|
||
tabId: 'tab-1',
|
||
launchDraft: 'https://github.com/o/r/issues/12',
|
||
launchDraftCreatedAt: 7,
|
||
chatActive: true,
|
||
transcriptLoading: false
|
||
})
|
||
})
|
||
|
||
it('forwards the raw draft with chatActive false when the tab shows the terminal', () => {
|
||
// Nulling the draft off-chat is indistinguishable from a host retraction and
|
||
// permanently declines the prefill; the flag is what keeps them apart.
|
||
viewMode.isTabChatView = () => false
|
||
render(chatTab)
|
||
|
||
expect(draftsArgs.at(-1)).toMatchObject({
|
||
launchDraft: 'https://github.com/o/r/issues/12',
|
||
chatActive: false
|
||
})
|
||
})
|
||
|
||
it('forwards the session hook’s transcriptLoading, not its status', () => {
|
||
// 'working' masks 'loading' in status, so only the read-phase signal is honest.
|
||
sessionState.status = 'working'
|
||
sessionState.transcriptLoading = true
|
||
render(chatTab)
|
||
|
||
expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true })
|
||
})
|
||
|
||
it('forwards a null draft for a tab that publishes none', () => {
|
||
render({ ...chatTab, launchDraft: undefined })
|
||
|
||
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
|
||
})
|
||
})
|
||
|
||
describe('useMobileNativeChatController ask dismissal across a transcript reload', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
let controller: MobileNativeChatController | null = null
|
||
const clientStub = { sendRequest: vi.fn() }
|
||
const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] }
|
||
|
||
const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' }
|
||
/** Mutable so a test can move the user to another tab — or restart the agent
|
||
* into a new provider session on the same tab — mid-render. */
|
||
const activeTab = { id: 'tab-1', sessionId: 'session-1' }
|
||
|
||
function Harness(): null {
|
||
controller = useMobileNativeChatController({
|
||
client: clientStub as unknown as RpcClient,
|
||
connState: 'connected',
|
||
hostId: 'h',
|
||
worktreeId: 'w',
|
||
activeSessionTab: {
|
||
...chatTab,
|
||
id: activeTab.id,
|
||
agentStatus: { agentType: 'claude', providerSession: { id: activeTab.sessionId } }
|
||
} as never,
|
||
activeSessionTabId: activeTab.id,
|
||
activeHandleRef: { current: 'term-1' },
|
||
deviceTokenRef: { current: null },
|
||
nativeChatTranscriptIsLocalReadable: true,
|
||
nativeChatInputLeaseReady: true,
|
||
onSendError: vi.fn(),
|
||
onSendResolved: vi.fn()
|
||
})
|
||
return null
|
||
}
|
||
|
||
/** Re-render under the current viewMode/prompts/session stand-ins. */
|
||
function step(): void {
|
||
act(() => {
|
||
renderer?.update(createElement(Harness))
|
||
})
|
||
}
|
||
|
||
/** Drive the transcript stand-in the way the real hook couples its fields, so
|
||
* these tests can only express states the session hook can actually reach:
|
||
* `transcriptLoading` is exactly `status === 'loading'`, and `messages` is
|
||
* withheld until a read lands. `rows` is what the last landed read left
|
||
* behind — 0 means no read has ever landed for this identity. */
|
||
function setTranscript(status: MobileNativeChatStatus, rows = 1): void {
|
||
sessionState.status = status
|
||
sessionState.transcriptLoading = status === 'loading'
|
||
sessionState.messages =
|
||
status === 'ready' || status === 'error' ? Array.from({ length: rows }, () => ({})) : []
|
||
}
|
||
|
||
beforeEach(() => {
|
||
viewMode.isTabChatView = () => true
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
})
|
||
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
controller = null
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
setTranscript('ready', 0)
|
||
viewMode.isTabChatView = () => true
|
||
activeTab.id = 'tab-1'
|
||
activeTab.sessionId = 'session-1'
|
||
})
|
||
|
||
it('scopes the dismissal to the tab it was taken on', () => {
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
// Another tab's agent is parked on a byte-identical question; it is a
|
||
// different pending prompt and tab 1's answer must not hide it.
|
||
activeTab.id = 'tab-2'
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||
})
|
||
|
||
it('shows a restarted session’s identical first question on the same tab', () => {
|
||
// A restart, `/clear`, or resume swaps the provider session inside one tab,
|
||
// and the next session's first question is often byte-identical — same repo,
|
||
// same prompt, same template. Keyed by tab alone the old answer hides it, so
|
||
// the live card never renders and the turn sits blocked with nothing to act on.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
// The restart re-subscribes the transcript, so the read is in flight for a beat.
|
||
activeTab.sessionId = 'session-2'
|
||
setTranscript('loading')
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||
})
|
||
|
||
it('retires the dismissal only on the ungated prompt, not the gated one', () => {
|
||
// The paused gate hides the card whenever the agent is not waiting, but a
|
||
// hidden card is no evidence the sticky status prompt cleared. Feeding the
|
||
// gated `ask` in as the detected prompt would read that gap as "prompt gone",
|
||
// retire the dismissal, and bring the answered card back when it reopens.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
promptsState.ask = null
|
||
step()
|
||
|
||
promptsState.ask = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
})
|
||
|
||
it('keeps the dismissal while the re-subscribed transcript is still empty', () => {
|
||
// Toggling views re-subscribes the transcript, so a transcript-derived ask
|
||
// reads as null for a beat with chat already visible. Believing that null
|
||
// retires the dismissal and the answered card comes back (#12497).
|
||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
// Chat -> terminal.
|
||
viewMode.isTabChatView = () => false
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
// Terminal -> chat: observable again, but the transcript read is in flight.
|
||
viewMode.isTabChatView = () => true
|
||
setTranscript('loading')
|
||
step()
|
||
|
||
// The read lands and re-derives the same still-pending ask.
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
})
|
||
|
||
it('accepts an answer taken while the first transcript read is still in flight', () => {
|
||
// A status-derived ask renders before any transcript lands, so the load window
|
||
// must stay observable whenever a prompt is actually on screen — otherwise the
|
||
// dismissal is silently dropped and the answered card never goes away.
|
||
act(() => renderer?.unmount())
|
||
setTranscript('loading')
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
|
||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
})
|
||
|
||
it('still retires the dismissal once a settled transcript reports no prompt', () => {
|
||
// The load-window guard must not swallow the genuine reset: a prompt that
|
||
// clears with the read settled means the agent moved on.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||
})
|
||
|
||
it('keeps the dismissal while a dropped client empties the transcript', () => {
|
||
// `transcriptLoading` is only true for an in-flight read. A dropped client
|
||
// parks the session at 'idle', where the hook withholds `messages` with that
|
||
// flag already false — so the derived prompt reads null for a reason that
|
||
// says nothing about the agent, and the reset effect retired a live dismissal.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
setTranscript('idle')
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
// Reconnect: the read lands and the same question is still pending.
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
})
|
||
|
||
it('keeps the dismissal when the first read of a transcript errors', () => {
|
||
// The host forwards an initial-drain failure as an error frame carrying an
|
||
// EMPTY list, and that frame is not terminal — a real snapshot follows once
|
||
// the read recovers. Judging the prompt from that never-populated list
|
||
// retires the dismissal, and the recovered read brings the answered card
|
||
// back over the composer.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
setTranscript('error', 0)
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
// The read recovers with the same question still pending.
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
})
|
||
|
||
it('retires the dismissal when a failed read still reports the last transcript', () => {
|
||
// A read error that lands on top of rows from an earlier read leaves those
|
||
// rows in `messages`, so a prompt that clears under it is real evidence —
|
||
// unlike the never-read empty list of 'idle'/'waiting-session'. Treating
|
||
// every error as unobservable would freeze the dismissal and hide the next
|
||
// identical question for good.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
setTranscript('error')
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||
})
|
||
|
||
it('keeps the dismissal while the tab has no provider session yet', () => {
|
||
// 'waiting-session' withholds `messages` the same way, also with
|
||
// transcriptLoading false. The tab still shows chat (resolveMobileNativeChat
|
||
// resolves from `launchAgent` alone), so a live dismissal is on screen for it.
|
||
act(() => controller?.dismissNativeChatAsk())
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
|
||
setTranscript('waiting-session')
|
||
promptsState.ask = null
|
||
promptsState.detectedAsk = null
|
||
step()
|
||
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
step()
|
||
|
||
expect(controller?.nativeChatAsk).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('useMobileNativeChatController streaming scope', () => {
|
||
let renderer: ReactTestRenderer | null = null
|
||
let controller: MobileNativeChatController | null = null
|
||
const clientStub = { sendRequest: vi.fn() }
|
||
|
||
const workingTab = {
|
||
type: 'terminal',
|
||
id: 'tab-1',
|
||
terminal: 'term-1',
|
||
launchAgent: 'claude',
|
||
agentStatus: {
|
||
state: 'working',
|
||
agentType: 'claude',
|
||
providerSession: { id: 'session-1' }
|
||
},
|
||
isActive: true
|
||
}
|
||
|
||
function Harness(): null {
|
||
controller = useMobileNativeChatController({
|
||
client: clientStub as unknown as RpcClient,
|
||
connState: 'connected',
|
||
hostId: 'h',
|
||
worktreeId: 'w',
|
||
activeSessionTab: workingTab as never,
|
||
activeSessionTabId: 'tab-1',
|
||
activeHandleRef: { current: 'term-1' },
|
||
deviceTokenRef: { current: null },
|
||
nativeChatTranscriptIsLocalReadable: true,
|
||
nativeChatInputLeaseReady: true,
|
||
onSendError: vi.fn(),
|
||
onSendResolved: vi.fn()
|
||
})
|
||
return null
|
||
}
|
||
|
||
beforeEach(() => {
|
||
viewMode.isTabChatView = () => true
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
})
|
||
|
||
afterEach(() => {
|
||
act(() => renderer?.unmount())
|
||
renderer = null
|
||
controller = null
|
||
viewMode.isTabChatView = () => true
|
||
})
|
||
|
||
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
|
||
expect(controller?.showNativeChat).toBe(true)
|
||
const scopeKey = controller?.nativeChatStreamScopeKey
|
||
expect(scopeKey).toContain('session-1')
|
||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||
|
||
viewMode.isTabChatView = () => false
|
||
act(() => renderer?.update(createElement(Harness)))
|
||
|
||
expect(controller?.showNativeChat).toBe(false)
|
||
expect(controller?.nativeChatAgentWorking).toBe(false)
|
||
expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey)
|
||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||
})
|
||
|
||
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
|
||
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)
|
||
})
|
||
})
|