fix(native chat): retain transcript while reconnecting (STA-3333) (#12495)

* fix(mobile): keep the cached transcript visible while reconnecting

A manual retry closes the client and opens a fresh one, so the chat session
hook saw a new client under an unchanged identity, dropped its settled read,
and handed out an empty list — the transcript collapsed to a full-screen
spinner until the swapped client's snapshot landed.

Hold the last settled list per identity (captured post-commit) and keep
rendering it while the re-read is in flight. `transcriptLoading` still gates
consumers that decide from an empty transcript, so the launch-draft seed is
unaffected. The held list is keyed by a new `sourceIdentity` (host/workspace)
in addition to agent/session/transcript, so it can never serve another
source's messages.

Refs STA-3333.

* test(mobile): assert the whole reconnect window, not just its first frame

The re-subscribe lands a commit after the first render of the swap, so a
regression that cleared the held list there left frame 0 green and still
blanked the transcript. Verified: clearing the cache in the subscribe
cleanup now fails this test, where before only the view-toggle test caught it.

* fix(mobile): don't derive a tappable ask card from the held transcript

The cache this PR adds keeps the previous list rendered while a swapped
client re-reads. useMobileNativeChatPrompts was the one consumer reading
`messages` without honouring `transcriptLoading`, so an ask answered on
the terminal resurrected as a live, tappable card during that window.

Gating on `transcriptLoading` is exactly base behaviour: `setRead` only
ever stores 'ready'/'error', so status==='loading' implied an empty list
before this PR. The live `askFromStatus` path is untouched.

* chore: keep merge formatting scoped
This commit is contained in:
Brennan Benson 2026-08-05 18:06:36 -07:00 committed by GitHub
parent aa64ac9606
commit 79896cb9a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 430 additions and 31 deletions

View File

@ -6,6 +6,7 @@ import {
type MutableRefObject,
type SetStateAction
} from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@ -153,6 +154,7 @@ export function useMobileNativeChatController(args: {
const nativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
@ -203,7 +205,8 @@ export function useMobileNativeChatController(args: {
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
status: nativeChatStatus,
messages: nativeChatSession.messages
messages: nativeChatSession.messages,
transcriptLoading: nativeChatSession.transcriptLoading
})
// A never-read transcript cannot prove that a dismissed prompt cleared.
const nativeChatTranscriptSettled =

View File

@ -15,14 +15,16 @@ const ASK = JSON.stringify({
function promptsFor(
status: Partial<AgentStatusEntry> | null,
messages: NativeChatMessage[] = []
messages: NativeChatMessage[] = [],
transcriptLoading = false
): ReturnType<typeof useMobileNativeChatPrompts> {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
function Probe(): null {
captured = useMobileNativeChatPrompts({
enabled: true,
status: status as AgentStatusEntry | null,
messages
messages,
transcriptLoading
})
return null
}
@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => {
expect(promptsFor(null, askMessages).ask).not.toBeNull()
})
it('withholds retained transcript asks while the replacement read is unsettled', () => {
const prompts = promptsFor({ state: 'done' }, askMessages, true)
expect(prompts.ask).toBeNull()
expect(prompts.detectedAsk).toBeNull()
})
it('keeps a paused live status ask authoritative while the read is unsettled', () => {
const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true)
expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] })
expect(prompts.detectedAsk).not.toBeNull()
})
it('does not leak a paused-out sticky status prompt through the transcript fallback', () => {
// The post-answer window: the status still carries the prompt while flipping
// to `working`, and the transcript's tool-result row has not landed yet, so

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: {
enabled: boolean
status: AgentStatusEntry | null | undefined
messages: readonly NativeChatMessage[]
/** True while `messages` is an unsettled read (including the cached list held
* across a reconnect). Required: an ask derived from it may already be answered. */
transcriptLoading: boolean
}): MobileNativeChatPrompts {
const { enabled, status, messages } = args
const { enabled, status, messages, transcriptLoading } = args
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
// Both permission paths sit inside the paused gate: an approval envelope can
// outlive its answer (the host keeps it sticky), so only a waiting/blocked
@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: {
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
[status?.interactivePrompt, status?.toolName]
)
const askFromMessages = useMemo(
() => (askFromStatus ? null : extractPendingAsk(messages)),
[askFromStatus, messages]
const resolvedAsk = useMemo(
() =>
resolveNativeChatAsk({
liveAsk: askFromStatus,
messages,
transcriptSettled: !transcriptLoading
}),
[askFromStatus, transcriptLoading, messages]
)
const askFromMessages = askFromStatus ? null : resolvedAsk
const detectedAsk = askFromStatus ?? askFromMessages
return {

View File

@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => {
function Harness({ client }: { client: RpcClient | null }): null {
state = useMobileNativeChatSession({
client,
sourceIdentity: 'host-a\0workspace-a',
agent: 'claude',
sessionId: 'session',
transcriptPath: null
@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
function Harness({
client,
sessionId,
agent = 'claude'
agent = 'claude',
sourceIdentity = 'host-a\0workspace-a'
}: {
client: RpcClient | null
sessionId: string | null
agent?: string | null
sourceIdentity?: string
}): null {
const session = useMobileNativeChatSession({
client,
sourceIdentity,
agent,
sessionId,
transcriptPath: null
@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
// Leaving chat view nulls the agent, then returning restores the identity a
// settled read already matched — but its list was cleared, so trusting it
// would report 'ready' over an empty transcript.
// would report 'ready' over an empty transcript. The last settled list for
// this identity keeps rendering while the re-read is in flight.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
expect(renders[0]).toMatchObject({
status: 'loading',
transcriptLoading: true,
ids: ['a-1']
})
})
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
// A reconnect swaps the client without moving the identity; the effect
// re-subscribes and clears the list, so the old outcome must not stand.
it('keeps the last settled list rendered while a swapped client re-reads', async () => {
// A manual-retry reconnect swaps the client without moving the identity; the
// old outcome must not stand ('loading', not 'ready'), but the cached
// transcript keeps rendering instead of collapsing to a full-screen spinner.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
let emitFresh: (frame: unknown) => void = () => {}
const reconnected = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
emitFresh = onData
return () => {}
})
} as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
expect(renders[0]).toMatchObject({
status: 'loading',
transcriptLoading: true,
ids: ['a-1']
})
// Every commit of the window, not just the first: the re-subscribe lands a
// commit after it, so clearing the cache there blanks the transcript the
// user actually sees while leaving a first-frame assertion green.
expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1'])
// The fresh client's snapshot supersedes the held list.
await act(async () =>
emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false })
)
expect(renders.at(-1)).toMatchObject({
status: 'ready',
transcriptLoading: false,
ids: ['a-1', 'a-2']
})
})
it('never holds a cached list across a host/workspace source change', async () => {
const firstClient = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false })
return () => {}
})
} as unknown as RpcClient
await mountAt(firstClient, 'session-a')
const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(
createElement(Harness, {
client: secondClient,
sessionId: 'session-a',
sourceIdentity: 'host-b\0workspace-b'
})
)
)
expect(renders[0]).toMatchObject({ status: 'loading', ids: [] })
})
it('never hands out the previous sessions messages under the new session id', async () => {

View File

@ -1,4 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
createNativeChatTranscriptRetention,
encodeNativeChatTranscriptIdentity
} from '../../../src/shared/native-chat-transcript-retention'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
import type { RpcClient } from '../transport/rpc-client'
@ -28,9 +32,6 @@ export type MobileNativeChatSession = {
loadEarlier: () => void
}
// Stable empty reference so a not-yet-current read doesn't churn consumers.
const EMPTY_MESSAGES: NativeChatMessage[] = []
// Small first page for a fast first paint; grows by a page as the user scrolls.
const INITIAL_LIMIT = 40
const PAGE = 60
@ -45,13 +46,20 @@ type ReadSessionResult =
* an ordered tail); live appends merge by id so order stays stable. */
export function useMobileNativeChatSession(args: {
client: RpcClient | null
/** Stable host/workspace source; unlike `client`, it survives manual reconnect. */
sourceIdentity: string
agent: string | null
sessionId: string | null
transcriptPath: string | null
}): MobileNativeChatSession {
const { client, agent, sessionId, transcriptPath } = args
const { client, sourceIdentity, agent, sessionId, transcriptPath } = args
const [messages, setMessages] = useState<NativeChatMessage[]>([])
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
const identity = encodeNativeChatTranscriptIdentity([
sourceIdentity,
agent,
sessionId,
transcriptPath
])
// Pre-read status is a pure function of the props, so derive it rather than
// letting the effect write it a commit later.
const initialStatus: MobileNativeChatStatus =
@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: {
// Whether this subscription already delivered its base snapshot; later
// snapshots on the same subscription are reconnect replays, not fresh bases.
const snapshotSeenRef = useRef(false)
const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention())
const settledReady = settled?.status === 'ready'
useEffect(() => {
if (settledReady) {
transcriptRetentionRef.current.capture(identity, messages)
}
}, [identity, messages, settledReady])
// Replace the base list (read results are an ordered tail). Resets the merger
// cache so the index is rebuilt once over the new base.
@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: {
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
const visibleMessages = transcriptRetentionRef.current.visible({
identity,
messages,
settled: settledReady,
loading: status === 'loading'
})
return {
// Withheld until the settled read belongs to this identity: the effect that
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: settled ? messages : EMPTY_MESSAGES,
messages: visibleMessages,
status,
transcriptLoading: status === 'loading',
error,

View File

@ -48,13 +48,15 @@ function renderCard(canSend = true): ReturnType<typeof render> {
function cardElement(
canSend = true,
messages?: readonly NativeChatMessage[],
onShowingQuestionChange?: (showing: boolean) => void
onShowingQuestionChange?: (showing: boolean) => void,
transcriptSettled = true
): React.JSX.Element {
return (
<NativeChatInteractiveCard
paneKey="tab-1:leaf-1"
canSend={canSend}
messages={messages}
transcriptSettled={transcriptSettled}
onShowingQuestionChange={onShowingQuestionChange}
send={{
sendAnswer: mocks.sendAnswer,
@ -233,6 +235,12 @@ describe('NativeChatInteractiveCard transcript fallback', () => {
expect(onShowingQuestionChange).toHaveBeenCalledWith(true)
})
it('withholds a retained transcript ask while its replacement read is unsettled', () => {
render(cardElement(true, [askCallMessage('Stale transcript question?')], undefined, false))
expect(screen.queryByText('Stale transcript question?')).not.toBeInTheDocument()
})
it('prefers live status over the transcript when both carry a prompt', () => {
storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT
render(cardElement(true, [askCallMessage('Stale transcript question?')]))

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '../../store'
import { extractPendingAsk } from '../../../../shared/native-chat-ask'
import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
@ -33,6 +33,7 @@ export function NativeChatInteractiveCard({
send,
canSend,
messages,
transcriptSettled,
onShowingQuestionChange,
answerInputRef
}: {
@ -42,6 +43,7 @@ export function NativeChatInteractiveCard({
/** Transcript to fall back on when live status carries no prompt. Pass the
* command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
messages?: readonly NativeChatMessage[]
transcriptSettled: boolean
/** Reports whether a question card is on screen so the view can replace the
* composer with it (the card's free-text row is the answer input). */
onShowingQuestionChange?: (showing: boolean) => void
@ -59,12 +61,16 @@ export function NativeChatInteractiveCard({
const card = useMemo(() => {
const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined)
if (statusCard || !messages) {
if (statusCard?.kind === 'approval') {
return statusCard
}
const prompt = extractPendingAsk(messages)
const prompt = resolveNativeChatAsk({
liveAsk: statusCard?.prompt ?? null,
messages: messages ?? [],
transcriptSettled: transcriptSettled && messages != null
})
return prompt ? { kind: 'question' as const, prompt } : null
}, [interactivePrompt, interactiveToolName, messages])
}, [interactivePrompt, interactiveToolName, messages, transcriptSettled])
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
// A question answer is a paced multi-step write (body→Enter per question); keep

View File

@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
import { useNativeChatLiveSession } from './use-native-chat-live-session'
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
@ -131,7 +131,7 @@ function NativeChatResolvedView({
const runtimeEnvironmentId = useAppStore((s) =>
selectNativeChatRuntimeEnvironmentId(s, terminalTabId)
)
const session = useNativeChatLiveSession({
const session = useNativeChatRetainedSession({
paneKey,
agent,
sessionId,
@ -430,6 +430,7 @@ function NativeChatResolvedView({
send={interactiveSend}
canSend={canSend}
messages={sessionAfterCommandBoundaries.messages}
transcriptSettled={session.readPhase === 'ready'}
onShowingQuestionChange={setQuestionActive}
answerInputRef={questionAnswerInputRef}
/>

View File

@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type {
NativeChatLiveSession,
UseNativeChatLiveSessionArgs
} from './use-native-chat-live-session'
const { liveSession } = vi.hoisted(() => ({ liveSession: vi.fn() }))
vi.mock('./use-native-chat-live-session', () => ({ useNativeChatLiveSession: liveSession }))
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
const ARGS: UseNativeChatLiveSessionArgs = {
paneKey: 'tab:leaf',
agent: 'claude',
sessionId: 'session',
runtimeEnvironmentId: 'owner-a'
}
function message(id: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' }
}
function session(
readPhase: NativeChatLiveSession['readPhase'],
messages: NativeChatMessage[],
sessionId: string | null = 'session'
): NativeChatLiveSession {
return {
agent: 'claude',
sessionId,
messages,
status: readPhase === 'loading' ? 'loading' : 'ready',
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn(),
readPhase
}
}
describe('useNativeChatRetainedSession', () => {
let root: Root | null = null
let latest: NativeChatLiveSession | null = null
function Probe(props: UseNativeChatLiveSessionArgs): null {
latest = useNativeChatRetainedSession(props)
return null
}
async function render(props: UseNativeChatLiveSessionArgs): Promise<void> {
if (!root) {
root = createRoot(document.createElement('div'))
}
await act(async () => root?.render(createElement(Probe, props)))
}
afterEach(() => {
act(() => root?.unmount())
root = null
latest = null
liveSession.mockReset()
})
it('keeps settled messages during a same-identity rebind', async () => {
liveSession.mockReturnValue(session('ready', [message('settled')]))
await render(ARGS)
liveSession.mockReturnValue(session('loading', []))
await render(ARGS)
expect(latest?.readPhase).toBe('loading')
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
})
it('misses retained messages when the source owner changes', async () => {
liveSession.mockReturnValue(session('ready', [message('owner-a')]))
await render(ARGS)
liveSession.mockReturnValue(session('loading', []))
await render({ ...ARGS, runtimeEnvironmentId: 'owner-b' })
expect(latest?.readPhase).toBe('loading')
expect(latest?.messages).toEqual([])
})
it('does not overwrite retention with the session-less view', async () => {
liveSession.mockReturnValue(session('ready', [message('settled')]))
await render(ARGS)
liveSession.mockReturnValue(session('ready', [], null))
await render({ ...ARGS, sessionId: null })
liveSession.mockReturnValue(session('loading', []))
await render(ARGS)
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
})
})

View File

@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react'
import {
createNativeChatTranscriptRetention,
encodeNativeChatTranscriptIdentity
} from '../../../../shared/native-chat-transcript-retention'
import {
useNativeChatLiveSession,
type NativeChatLiveSession,
type UseNativeChatLiveSessionArgs
} from './use-native-chat-live-session'
/** Keeps one committed conversation visible while its exact source rebinds. */
export function useNativeChatRetainedSession(
args: UseNativeChatLiveSessionArgs
): NativeChatLiveSession {
const session = useNativeChatLiveSession(args)
const identity = encodeNativeChatTranscriptIdentity([
args.paneKey,
args.runtimeEnvironmentId ?? null,
args.agent,
args.sessionId,
args.transcriptPath ?? null
])
const activeIdentityRef = useRef(identity)
const retentionRef = useRef(createNativeChatTranscriptRetention())
const sessionMatchesIdentity = activeIdentityRef.current === identity
const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading'
useEffect(() => {
activeIdentityRef.current = identity
}, [identity])
useEffect(() => {
if (sessionMatchesIdentity && args.sessionId !== null && session.readPhase === 'ready') {
retentionRef.current.capture(identity, session.messages)
}
}, [args.sessionId, identity, session.messages, session.readPhase, sessionMatchesIdentity])
const messages = retentionRef.current.visible({
identity,
messages: session.messages,
settled: readPhase === 'ready',
loading: readPhase === 'loading'
})
if (messages === session.messages && readPhase === session.readPhase) {
return session
}
return {
...session,
messages,
readPhase,
...(sessionMatchesIdentity ? {} : { status: 'loading' as const, error: undefined })
}
}

View File

@ -4,7 +4,12 @@ import {
type NativeChatBlock,
type NativeChatMessage
} from './native-chat-types'
import { extractPendingAsk, nativeChatAskDismissKey, parseAskFromStatus } from './native-chat-ask'
import {
extractPendingAsk,
nativeChatAskDismissKey,
parseAskFromStatus,
resolveNativeChatAsk
} from './native-chat-ask'
function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' }
@ -178,3 +183,23 @@ describe('parseAskFromStatus', () => {
expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b'])
})
})
describe('resolveNativeChatAsk', () => {
const transcript = [message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)])]
it('withholds transcript state until the read settles', () => {
expect(
resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: false })
).toBeNull()
expect(
resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: true })
)?.toMatchObject(QUESTIONS_INPUT)
})
it('keeps a live ask authoritative while transcript history is unsettled', () => {
const liveAsk = { questions: [{ question: 'Live?', options: [], multiSelect: false }] }
expect(resolveNativeChatAsk({ liveAsk, messages: transcript, transcriptSettled: false })).toBe(
liveAsk
)
})
})

View File

@ -131,6 +131,15 @@ export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPr
return pending
}
/** Prefers live status and consults transcript history only after its read settles. */
export function resolveNativeChatAsk(args: {
liveAsk: AskPrompt | null
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
}): AskPrompt | null {
return args.liveAsk ?? (args.transcriptSettled ? extractPendingAsk(args.messages) : null)
}
/** One question's chosen answer, normalized for delivery: the selected option
* indices (in option order) plus any free-text "other" answer. Index-based (not
* label text) so the answer can be delivered by the selector's stable option

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from './native-chat-types'
import {
createNativeChatTranscriptRetention,
encodeNativeChatTranscriptIdentity
} from './native-chat-transcript-retention'
function message(id: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' }
}
describe('native chat transcript retention', () => {
it('holds only the latest settled transcript for the same identity while loading', () => {
const retention = createNativeChatTranscriptRetention()
const first = [message('first')]
const second = [message('second')]
retention.capture('source-a', first)
expect(
retention.visible({ identity: 'source-a', messages: [], settled: false, loading: true })
).toBe(first)
expect(
retention.visible({ identity: 'source-b', messages: [], settled: false, loading: true })
).toEqual([])
retention.capture('source-b', second)
expect(
retention.visible({ identity: 'source-a', messages: [], settled: false, loading: true })
).toEqual([])
expect(
retention.visible({ identity: 'source-b', messages: [], settled: false, loading: true })
).toBe(second)
})
it('never substitutes retained history for a settled or non-loading read', () => {
const retention = createNativeChatTranscriptRetention()
const retained = [message('retained')]
const fresh = [message('fresh')]
retention.capture('source', retained)
expect(
retention.visible({ identity: 'source', messages: fresh, settled: true, loading: false })
).toBe(fresh)
expect(
retention.visible({ identity: 'source', messages: [], settled: false, loading: false })
).toEqual([])
})
it('encodes identity components without delimiter collisions', () => {
expect(encodeNativeChatTranscriptIdentity(['host\0workspace', 'session'])).not.toBe(
encodeNativeChatTranscriptIdentity(['host', 'workspace\0session'])
)
})
})

View File

@ -0,0 +1,36 @@
import type { NativeChatMessage } from './native-chat-types'
export const EMPTY_NATIVE_CHAT_TRANSCRIPT: NativeChatMessage[] = []
export function encodeNativeChatTranscriptIdentity(parts: readonly (string | null)[]): string {
return JSON.stringify(parts)
}
export type NativeChatTranscriptRetention = {
capture: (identity: string, messages: NativeChatMessage[]) => void
visible: (args: {
identity: string
messages: NativeChatMessage[]
settled: boolean
loading: boolean
}) => NativeChatMessage[]
}
/** Retains one settled transcript so a same-source rebind does not blank the conversation. */
export function createNativeChatTranscriptRetention(): NativeChatTranscriptRetention {
let captured: { identity: string; messages: NativeChatMessage[] } | null = null
return {
capture(identity, messages) {
captured = { identity, messages }
},
visible({ identity, messages, settled, loading }) {
if (settled) {
return messages
}
return loading && captured?.identity === identity
? captured.messages
: EMPTY_NATIVE_CHAT_TRANSCRIPT
}
}
}