mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
* fix(mobile): keep native chat ask dismissals tab-scoped and gated
Dismissal state lived in the chat view subtree, which unmounts on a
chat<->terminal toggle, so an answered ask card came back on return. It
also had no tab scope and no waiting/blocked gate.
- move dismissal into the controller, keyed per session tab
- gate ask cards on waiting/blocked like the permission path already is,
and retire a dismissal off the ungated detected prompt so a working/done
status can't be mistaken for the prompt clearing
- ignore a dismissal that settles after its prompt cleared or was replaced
Refs STA-3333.
* fix(mobile): keep an ask dismissal through the transcript re-subscribe
A view toggle or tab switch re-subscribes the native-chat transcript, and
useMobileNativeChatSession withholds `messages` until that read settles. A
transcript-derived ask therefore reads as null while the chat surface is
already visible, so the reset effect took it as "the agent moved on" and
retired a live dismissal — the answered card came back, which is the bug
the off-chat guard was meant to close.
Treat an unobserved null as unobserved: `observing` now also requires the
read to have settled. A prompt that is already detected stays observable on
its own, so a status-derived ask still registers on first paint and an
answer taken during that first load is still accepted.
* fix(mobile): keep the transcript-derived ask outside the paused gate
A hook row idle past AGENT_STATUS_STALE_AFTER_MS (30m) projects to `done`
with no interactivePrompt, so the transcript fallback is the only source
left for a still-pending question. Gating it behind waiting/blocked made
that question unanswerable from mobile. Only the sticky status payload
needs the gate; `extractPendingAsk` clears itself on the tool result.
Also pins the load-window clause in the ask-observability guard, which
was behaviourally load-bearing but killed no test.
* fix(mobile): treat a never-read transcript as unobserved, not as "no ask"
The ask-observability guard only excused `transcriptLoading`, which is true
for an in-flight read alone. useMobileNativeChatSession also withholds
`messages` when the client is gone ('idle') or the tab has not reported a
provider session yet ('waiting-session') — both leave the flag false over an
empty list that was never read. The derived prompt then read as null, the
reset effect took that as "the agent moved on", and a live dismissal was
retired; when the read landed with the question still pending the answered
card came back — the resurfacing bug this guard exists to close.
Gate on the read having actually settled instead. 'error' still counts: it
keeps the last successful read in `messages`, so a prompt that clears under
it is real evidence, unlike a list that was never populated.
Also locks three guards that killed no test: the sticky-status suppression
of the transcript fallback (which is what makes the new paused gate hold in
the post-answer window), the reset effect's identity bail-out, and showAsk's
empty-prompt case. The transcript stand-in now derives `transcriptLoading`
from `status` the way the real hook couples them, so these tests can only
express states the session hook can reach.
Refs STA-3333.
* test(mobile): pin the ask dismissal's tab scope and ungated retirement input
Both wirings were unpinned: swapping `scopeKey` to a constant or feeding the
gated `ask` in as `detectedAsk` left the whole mobile suite green.
* fix(mobile): require a landed read before an errored transcript retires a dismissal
`status === 'error'` was treated as settled on the claim that an error keeps
the last successful read in `messages`. That only holds for an error that lands
on top of an earlier read. The host forwards an initial-drain failure as an
error frame carrying an EMPTY list (transcript-watch-error.test.ts), the mobile
frame applier checks `frame.error` before the messages array so those rows are
discarded, and the session hook's error path never calls `setMessages` — so a
first-read error leaves `messages` at the `[]` the identity-change effect wrote.
That frame is also not terminal: the watcher keeps `initialDrain` true and a
real snapshot follows once the read recovers. So a re-subscribe whose first
read errors made the never-populated list read as "no ask", retired the live
dismissal, and the recovered snapshot brought the answered card back over the
composer — the exact resurfacing this guard exists to close, and most likely on
remote/SSH transcript reads.
Require rows for the error case. Rows can only be present once a read landed,
so the predicate is never wrong in the resurfacing direction; it only declines
to retire a dismissal when the transcript was never observed.
Also drop the dismiss hook's `detectedAsk = ask` default and make both prompts
required. That default silently fed the gated prompt in as the detected one,
which is the pre-fix behavior: a paused-out card would read as "prompt gone"
and retire the dismissal. tsc now enforces the ungated payload at every call
site instead of leaving a trap for the next caller.
* fix(mobile): scope the ask dismissal to the provider session, not the tab
A restart, /clear, or resume swaps the provider session inside one tab. The
next session's first question is often byte-identical, so a tab-keyed dismissal
hid the live card and left the turn blocked with nothing to act on.
* chore: restore upstream formatting
812 lines
28 KiB
TypeScript
812 lines
28 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: [],
|
||
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
|
||
}
|
||
|
||
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(() => {
|
||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||
vi.clearAllMocks()
|
||
resetMobileNativeChatStaleInputForTests()
|
||
captureSendOrigin.mockReturnValue(ORIGIN)
|
||
const original = console.error
|
||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||
return
|
||
}
|
||
original(...a)
|
||
})
|
||
try {
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
} finally {
|
||
spy.mockRestore()
|
||
}
|
||
})
|
||
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 {
|
||
const original = console.error
|
||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||
return
|
||
}
|
||
original(...a)
|
||
})
|
||
try {
|
||
act(() => {
|
||
renderer = create(createElement(Harness, { tab }))
|
||
})
|
||
} finally {
|
||
spy.mockRestore()
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||
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(() => {
|
||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||
viewMode.isTabChatView = () => true
|
||
setTranscript('ready')
|
||
promptsState.ask = PROMPT
|
||
promptsState.detectedAsk = PROMPT
|
||
const original = console.error
|
||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||
return
|
||
}
|
||
original(...a)
|
||
})
|
||
try {
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
} finally {
|
||
spy.mockRestore()
|
||
}
|
||
})
|
||
|
||
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(() => {
|
||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||
viewMode.isTabChatView = () => true
|
||
const original = console.error
|
||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||
return
|
||
}
|
||
original(...a)
|
||
})
|
||
try {
|
||
act(() => {
|
||
renderer = create(createElement(Harness))
|
||
})
|
||
} finally {
|
||
spy.mockRestore()
|
||
}
|
||
})
|
||
|
||
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)
|
||
})
|
||
})
|