Keep cancelled async questions hidden and show new ones (#35870)

* fix(chat): show each repeated async question

* fix(chat): keep async question identity stable across readbacks

* test(chat): preserve async question fixture types

* Keep async question cancellation scoped to individual occurrences

* Use checked types in async question test fixtures

* Share sender-aware async question retirement across chat views
This commit is contained in:
Brennan Benson
2026-10-30 00:36:11 -07:00
committed by GitHub
parent 636164029c
commit ca84c49b35
33 changed files with 1847 additions and 111 deletions
@@ -1,6 +1,8 @@
import './mobile-prompt-render-mocks'
import { createElement } from 'react'
import { Pressable } from 'react-native'
import type { NativeChatMessage } from '../../../../src/shared/native-chat-types'
import type { MobileNativeChatSendOutcome } from '../native-chat/mobile-native-chat-send'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcResponse, ConnectionState } from '../../transport/types'
import type { MobileNativeChatTab } from '../native-chat/mobile-native-chat-eligibility'
@@ -14,8 +16,15 @@ import type { AskAnswerSelection } from '../../../../src/shared/native-chat-ask-
import { mobileNativeChatPromptDismissals } from '../native-chat/input/mobile-native-chat-prompt-dismissals'
import { setMobileNativeChatDraftText } from '../native-chat/composer/mobile-native-chat-draft-store'
export const visible = { value: true }
const session = { messages: [], folded: [], status: 'ready', transcriptLoading: false }
const structuredSession = {
const messages: NativeChatMessage[] = []
export const session = {
messages,
folded: [],
status: 'ready',
transcriptLoading: false
}
export const structuredSession = {
asyncQuestionSourceScope: 'journal/host/session/epoch',
session,
isWorking: false,
turnId: null,
@@ -29,7 +38,7 @@ const structuredSession = {
respondPermission: vi.fn(async () => true),
respondQuestion: vi.fn(async () => true),
cancelPrompt: vi.fn(async () => true),
sendWithOutcome: vi.fn(async () => 'accepted'),
sendWithOutcome: vi.fn<() => Promise<MobileNativeChatSendOutcome>>(async () => 'accepted'),
cancel: vi.fn(),
setStructuredOption: vi.fn(),
invokeStructuredOption: vi.fn()
@@ -124,12 +133,13 @@ type HarnessProps = {
tab?: MobileNativeChatTab
tabId?: string
connState?: ConnectionState
hostId?: string
}
function Harness(props: HarnessProps) {
const { tab = baseTab, tabId = 'tab-1', connState = 'connected' } = props
const { tab = baseTab, tabId = 'tab-1', connState = 'connected', hostId = 'host' } = props
const current = useMobileNativeChatController({
client,
hostId: 'host',
hostId,
worktreeId: 'folder:work',
activeSessionTab: tab,
activeSessionTabId: tabId,
@@ -197,6 +207,9 @@ export function reset(rpcClient: RpcClient) {
visible.value = true
handleRef.current = 'term-1'
lastProps = null
session.messages = []
structuredSession.asyncQuestionSourceScope = 'journal/host/session/epoch'
structuredSession.sendWithOutcome.mockReset().mockResolvedValue('accepted')
sendError.mockReset()
resolved.mockReset()
mobileNativeChatPromptDismissals.clearForTests()
@@ -1,10 +1,16 @@
import { act } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mobileNativeChatPromptDismissals } from './native-chat/input/mobile-native-chat-prompt-dismissals'
import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery'
import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture'
import type { RpcResponse } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { AskPrompt } from '../../../src/shared/native-chat/input/native-chat-ask-types'
import {
visible,
session,
structuredSession,
handleRef,
baseTab,
permissionTab,
@@ -286,3 +292,323 @@ describe('prompt cards through the production controller, send contract and view
vi.mock('./MobileQueuedMessageComposer', () => ({
MobileQueuedMessageComposer: 'QueuedMessageComposer'
}))
describe('async occurrences through the phone controller and rendered card actions', () => {
const row = (id: string, title = 'Q1?', callId?: string): NativeChatMessage => ({
id,
role: 'assistant',
timestamp: null,
source: 'transcript',
blocks: [
{
type: 'tool-call',
name: 'request_user_input_async',
callId,
input: { questions: [{ title, options: ['Red', 'Blue'] }] }
}
]
})
const structuredTab = {
type: 'agent-session',
id: 'tab-1',
title: 'Chat',
sessionId: 'session-1',
agent: 'codex',
isActive: true
}
const terminalTab = {
...baseTab,
launchAgent: 'codex',
agentStatus: { ...baseTab.agentStatus, agentType: 'codex' }
}
const titles = () => getController().nativeChatAsk?.questions.map((q) => q.question)
it.each(['structured', 'terminal'])(
'keeps an agent notice answerable and retires only human input through the %s view',
async (kind) => {
const tab = kind === 'structured' ? structuredTab : terminalTab
const q1 = row('one')
const notice: NativeChatMessage = {
id: 'mail',
role: 'user',
timestamp: 2,
source: 'transcript',
blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }],
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: {
message: 'mail-notice',
mailbox: 'term_b',
dispatchId: null,
messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }]
}
}
}
session.messages = [q1, notice]
await render({ tab })
expect(titles()).toEqual(['Q1?'])
await act(async () => {
await askAnswer([{ indices: [1] }])
})
if (kind === 'structured') {
expect(structuredSession.sendWithOutcome).toHaveBeenCalledWith('> Q1?\n\nBlue')
expect(titles()).toEqual(['Q1?'])
} else {
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
expect.objectContaining({
text: '\x1b[200~> Q1?\r\rBlue\x1b[201~',
enter: true,
requireWriteSettlement: true
}),
expect.any(Object)
)
}
const q2 = row('two', 'Q2?')
session.messages = [q1, notice, q2]
await render({ tab })
expect(titles()).toContain('Q2?')
session.messages = [q1, notice, q2, { ...notice, id: 'human', from: undefined }]
await render({ tab })
expect(titles()).toBeUndefined()
}
)
it.each(['structured', 'terminal'])(
'retains exact cancelled calls through edits, prefix/ID replay, paging and new equal rows (%s)',
async (kind) => {
const tab = kind === 'structured' ? structuredTab : terminalTab
const q1 = row('one', 'Q1?', 'raw-call')
session.messages = [q1]
await render({ tab })
await act(async () => {
await askCancel()
})
expect(titles()).toBeUndefined()
for (const callId of ['renumbered', undefined]) {
session.messages = [
{
...row('one', 'Edited Q1?', callId),
blocks: [
{ type: 'text', text: 'prefix' },
{ type: 'tool-call', name: 'Bash', input: {} },
...row('one', 'Edited Q1?', callId).blocks,
...row('one', 'Q2?').blocks
]
}
]
await render({ tab })
expect(titles()).toEqual(['Q2?'])
}
await act(async () => {
await askCancel()
})
session.messages = []
await render({ tab })
session.messages = [q1]
await render({ tab })
expect(titles()).toBeUndefined()
await unmount()
await render({ tab })
expect(titles()).toBeUndefined()
session.messages = [q1, row('new-equal-row')]
await render({ tab })
expect(titles()).toEqual(['Q1?'])
expect(client.sendRequest).not.toHaveBeenCalled()
expect(structuredSession.sendWithOutcome).not.toHaveBeenCalled()
}
)
it.each(['accepted', 'queued', 'unknown', 'rejected'] as const)(
'keeps structured %s admission separate from captured Cancel and delivered rows',
async (outcome) => {
let finish: (result: 'accepted' | 'queued' | 'unknown' | 'rejected') => void = () => {
throw new Error('not sent')
}
structuredSession.sendWithOutcome.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const q1 = row('one')
session.messages = [q1]
await render({ tab: structuredTab })
let answer: Promise<boolean> | undefined
act(() => {
answer = askAnswer([{ indices: [1] }])
})
session.messages = [q1, row('two', 'Q2?')]
await render({ tab: structuredTab })
expect(titles()).toEqual(['Q1?', 'Q2?'])
await act(async () => {
finish(outcome)
await answer
})
expect(titles()).toEqual(['Q1?', 'Q2?'])
session.messages = [
q1,
row('two', 'Q2?'),
{
id: 'delivered',
role: 'user',
timestamp: null,
source: 'transcript',
blocks: [{ type: 'text', text: 'Blue' }]
}
]
await render({ tab: structuredTab })
expect(titles()).toBeUndefined()
expect(structuredSession.sendWithOutcome).toHaveBeenCalledWith('> Q1?\n\nBlue')
}
)
it('settles the terminal answer for Q1 after Q2 arrives without hiding Q2', async () => {
let finish: (reply: RpcResponse) => void = () => {
throw new Error('not sent')
}
vi.mocked(client.sendRequest).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const q1 = row('one')
session.messages = [q1]
await render({ tab: terminalTab })
let answer: Promise<boolean> | undefined
await act(async () => {
answer = askAnswer([{ indices: [1] }])
})
session.messages = [q1, row('two', 'Q2?')]
await render({ tab: terminalTab })
await act(async () => {
finish(response())
await answer
})
expect(titles()).toEqual(['Q2?'])
})
it.each(['source', 'reset', 'view'])(
'fences a late captured structured answer after %s replacement',
async (replacement) => {
let finish: (outcome: 'accepted') => void = () => {
throw new Error('not sent')
}
structuredSession.sendWithOutcome.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
session.messages = [row('one')]
await render({ tab: structuredTab })
let answer: Promise<boolean> | undefined
act(() => {
answer = askAnswer([{ indices: [1] }])
})
if (replacement !== 'view') {
structuredSession.asyncQuestionSourceScope = `journal/${replacement}/session/epoch`
}
await render({ tab: structuredTab, tabId: replacement === 'view' ? 'tab-2' : 'tab-1' })
await act(async () => {
finish('accepted')
await answer
})
expect(titles()).toEqual(['Q1?'])
}
)
it.each(['pending', 'rejected', 'withdrawn'] as const)(
'does not turn real %s host-result conversion into an answered question',
async (kind) => {
const value = structuredSendResultFixture(
kind === 'pending' ? 'pending' : 'rejected',
kind === 'withdrawn'
? 'This message was withdrawn before the agent started it.'
: 'provider_write_failed: broken pipe'
)
if (kind === 'withdrawn' && 'submission' in value) {
value.submission.rejection = { kind: 'cancelled' }
}
const converted = mobileStructuredSendDelivery({ status: 'accepted', value })
expect(converted.outcome).toBe('accepted')
structuredSession.sendWithOutcome.mockResolvedValue(converted.outcome)
session.messages = [row('one')]
await render({ tab: structuredTab })
await act(async () => {
await expect(askAnswer([{ indices: [1] }])).resolves.toBe(true)
})
expect(titles()).toEqual(['Q1?'])
}
)
it('keeps later same-ID Cancel after eviction/reentry and old answer settlement', async () => {
let finish: (outcome: 'accepted') => void = () => {
throw new Error('not sent')
}
structuredSession.sendWithOutcome.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
session.messages = [row('one')]
await render({ tab: structuredTab })
let answer: Promise<boolean> | undefined
act(() => {
answer = askAnswer([{ indices: [1] }])
})
act(() => {
for (let index = 0; index < 128; index += 1) {
mobileNativeChatPromptDismissals.hideAsync(`other-${index}`, ['other'])
}
})
await unmount()
await render({ tab: structuredTab })
await act(async () => {
await askCancel()
})
await act(async () => {
finish('accepted')
await answer
})
expect(titles()).toBeUndefined()
})
it('keeps structured answer controls after a thrown send', async () => {
structuredSession.sendWithOutcome.mockRejectedValue(new Error('send failed'))
session.messages = [row('one')]
await render({ tab: structuredTab })
await act(async () => {
await expect(askAnswer([{ indices: [1] }])).rejects.toThrow('send failed')
})
expect(titles()).toEqual(['Q1?'])
})
it('isolates equal portable IDs on two hosts and rejects the old host callback', async () => {
session.messages = [row('one')]
await render({ tab: structuredTab, hostId: 'host-A' })
const ids = getController().nativeChatAsk?.occurrenceIds
const oldCancel = getController().dismissNativeChatAsk
await render({ tab: structuredTab, hostId: 'host-B' })
expect(getController().nativeChatAsk?.occurrenceIds).toEqual(ids)
act(oldCancel)
expect(titles()).toEqual(['Q1?'])
await act(async () => {
await askCancel()
})
await render({ tab: structuredTab, hostId: 'host-A' })
expect(titles()).toEqual(['Q1?'])
await act(async () => {
await askCancel()
})
await render({ tab: structuredTab, hostId: 'host-B' })
expect(titles()).toBeUndefined()
})
})
@@ -22,6 +22,7 @@ import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-ba
export type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions> &
ReturnType<typeof useMobileStructuredAgentTurnTiming> & {
session: MobileNativeChatSession
asyncQuestionSourceScope: string
isWorking: boolean
/** Desktop's Stop rule: a running turn, or conversation Stop while working or sending. */
canStop: boolean
@@ -1,7 +1,10 @@
import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types'
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { Pressable, TextInput } from 'react-native'
import { describe, expect, it, vi } from 'vitest'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { nativeChatAskDismissKey } from '../../../../../src/shared/native-chat-ask-dismiss-key'
import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard'
vi.mock('react-native', () => ({
@@ -88,4 +91,51 @@ describe('MobileNativeChatPromptCard collapse', () => {
expect(textInput(tree!.root).props.value).toBe('turbo')
act(() => tree!.unmount())
})
it('resets stale pending choices/text on definition revision', async () => {
let tree: ReactTestRenderer | null = null
const render = (question: string) => {
const prompt: AskPrompt = {
...ask,
replyAsMessage: true,
occurrenceIds: ['same-call'],
questions: [{ ...ask.questions[0]!, question }]
}
return createElement(MobileNativeChatPromptCard, {
ask: prompt,
askKey: nativeChatAskDismissKey(prompt)
})
}
await act(async () => {
tree = create(render('Q1?'))
})
act(() => tree!.root.findAll((node) => node.props.label === 'Other…')[0]!.props.onPress())
act(() => textInput(tree!.root).props.onChangeText('old answer'))
await act(async () => tree!.update(render('Edited Q1?')))
expect(tree!.root.findAllByType(TextInput)).toHaveLength(0)
expect(tree!.root.findAll((node) => node.props.selected === true)).toHaveLength(0)
act(() => tree!.unmount())
})
it('resets typed answers on a host/view owner change with the same portable prompt', async () => {
const prompt: AskPrompt = { ...ask, replyAsMessage: true, occurrenceIds: ['same-portable-id'] }
function Harness({ host }: { host: string }) {
const dismissal = useMobileNativeChatAskDismiss({
ask: prompt,
startedAt: null,
scopeKey: host,
sessionKey: 'view',
observing: true
})
return createElement(MobileNativeChatPromptCard, { ask: prompt, askKey: dismissal.askKey })
}
let tree: ReactTestRenderer | null = null
await act(async () => {
tree = create(createElement(Harness, { host: 'host-A' }))
})
act(() => tree!.root.findAll((node) => node.props.label === 'Other…')[0]!.props.onPress())
act(() => textInput(tree!.root).props.onChangeText('old host answer'))
await act(async () => tree!.update(createElement(Harness, { host: 'host-B' })))
expect(tree!.root.findAllByType(TextInput)).toHaveLength(0)
act(() => tree!.unmount())
})
})
@@ -77,7 +77,7 @@ export function MobileNativeChatPromptCard({
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
if (accepted && !ask.replyAsMessage) {
onDismissAsk?.()
}
return accepted
@@ -10,6 +10,7 @@ export function useMobileNativeChatAskDismiss(args: {
scopeKey: string | null
sessionKey: string | null
observing: boolean
dismissAsync?: () => void
}): {
askKey: string | null
showAsk: boolean
@@ -24,13 +25,16 @@ export function useMobileNativeChatAskDismiss(args: {
)
const dismiss = useMobileNativeChatPromptDismiss({ ...args, kind: 'ask', card })
return {
askKey: dismiss.content,
askKey: args.ask?.replyAsMessage
? JSON.stringify([args.scopeKey, args.sessionKey, dismiss.content])
: dismiss.content,
showAsk: dismiss.showPrompt,
collapsedAsk:
dismiss.collapsed && args.ask
? { title: args.ask.questions[0]?.question ?? '', expand: dismiss.expandPrompt }
: null,
dismissAsk: dismiss.dismissPrompt,
dismissAsk:
args.ask?.replyAsMessage && args.dismissAsync ? args.dismissAsync : dismiss.dismissPrompt,
collapseAsk: dismiss.collapsePrompt
}
}
@@ -51,6 +51,8 @@ export function useMobileNativeChatPromptDismiss({
}, [sessionKey, occurrenceKey, scopeKey])
const dismissed = useSyncExternalStore(dismissals.subscribe, () => dismissals.read(storeKey))
const matches =
(!(card?.kind === 'question' && card.prompt.replyAsMessage) ||
dismissed?.state === 'collapsed') &&
dismissed?.sessionKey === sessionKey &&
nativeChatPromptDismissalMatches(dismissed, content, startedAt)
useLayoutEffect(() => {
@@ -79,3 +81,29 @@ export function useMobileNativeChatPromptDismiss({
expandPrompt: () => dismissals.forget(storeKey)
}
}
/** Keep addressed hides in the existing scoped owner, independently of the loaded definition. */
export function useMobileAsyncQuestionHides(
scopeKey: string | null,
sourceKey: string,
viewKey: string
) {
const storeKey = JSON.stringify(['async', scopeKey, sourceKey])
const owner = useMemo(() => ({ storeKey, viewKey }), [storeKey, viewKey])
const active = useRef<object | null>(null)
useLayoutEffect(() => {
active.current = owner
return () => {
active.current = null
}
}, [owner])
const hidden = useSyncExternalStore(dismissals.subscribe, () => dismissals.readAsync(storeKey))
return {
hidden,
capture: (ids: readonly string[]) => () => {
if (active.current === owner) {
dismissals.hideAsync(storeKey, ids)
}
}
}
}
@@ -1,8 +1,11 @@
import { asyncQuestionSourceScope } from '../../../../../src/shared/native-chat/input/native-chat-ask'
import { createElement } from 'react'
import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types'
import { useMobileAsyncQuestionHides } from './use-mobile-native-chat-prompt-dismiss'
import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
const renderers: ReactTestRenderer[] = []
@@ -31,6 +34,8 @@ function promptsFor(
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
function Probe(): null {
captured = useMobileNativeChatPrompts({
asyncSourceScope: 'host/session/epoch',
hiddenAsyncIds: new Set(),
enabled: true,
status: status as AgentStatusEntry | null,
messages,
@@ -171,6 +176,8 @@ describe('useMobileNativeChatPrompts in a structured chat', () => {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
function Probe(): null {
captured = useMobileNativeChatPrompts({
asyncSourceScope: 'host/session/epoch',
hiddenAsyncIds: new Set(),
enabled: true,
structured: { permission: null, question: null, sendWithOutcome },
status: null,
@@ -211,3 +218,80 @@ describe('useMobileNativeChatPrompts in a structured chat', () => {
}
)
})
describe('addressed phone hide subscriptions', () => {
it('keeps unrelated scopes still and reuses scanner output at N/10N', () => {
for (const count of [8, 80]) {
mobileNativeChatPromptDismissals.clearForTests()
let scans = 0
const messages: NativeChatMessage[] = [
{
...askMessages[0]!,
get blocks(): NativeChatMessage['blocks'] {
scans += 1
return [
{
type: 'tool-call',
name: 'request_user_input_async',
input: { questions: [{ title: 'Q1?' }] }
}
]
}
}
]
const renders = Array<number>(count).fill(0)
const actions: (() => void)[] = []
function Probe({ index }: { index: number }): null {
renders[index] = (renders[index] ?? 0) + 1
const sourceKey = asyncQuestionSourceScope({
kind: 'journal',
agent: 'codex',
sessionId: `session-${Math.floor(index / 2)}`,
epoch: 'epoch'
})
const hides = useMobileAsyncQuestionHides(
`host-${index % 2}/pane-${Math.floor(index / 2)}`,
sourceKey,
'view'
)
const prompts = useMobileNativeChatPrompts({
enabled: true,
status: null,
messages,
transcriptLoading: false,
asyncSourceScope: sourceKey,
hiddenAsyncIds: hides.hidden
})
actions[index] = hides.capture(prompts.ask?.occurrenceIds ?? [])
return null
}
let renderer: ReactTestRenderer
TestRenderer.act(() => {
renderer = TestRenderer.create(
createElement(
'Probes',
null,
Array.from({ length: count }, (_, index) => createElement(Probe, { key: index, index }))
)
)
renderers.push(renderer)
})
const initialScans = scans
expect(initialScans).toBeGreaterThanOrEqual(count)
expect(initialScans).toBeLessThanOrEqual(2 * count)
renders.fill(0)
TestRenderer.act(() => actions[0]!())
expect(renders).toEqual([1, ...Array<number>(count - 1).fill(0)])
expect(scans).toBe(initialScans)
renders.fill(0)
TestRenderer.act(() => actions[1]!())
expect(renders).toEqual([0, 1, ...Array<number>(count - 2).fill(0)])
expect(scans).toBe(initialScans)
renders.fill(0)
TestRenderer.act(() => actions[0]!())
expect(renders.every((value) => value === 0)).toBe(true)
TestRenderer.act(() => renderer.unmount())
renderers.pop()
}
})
})
@@ -1,3 +1,7 @@
import {
aggregateAsyncQuestions,
scanPendingAsks
} from '../../../../../src/shared/native-chat/input/native-chat-ask'
import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types'
@@ -30,8 +34,20 @@ export function useMobileNativeChatPrompts(args: {
messages: readonly NativeChatMessage[]
// Unsettled/retained reconnect history may contain answered asks and cannot supply current prompt cards.
transcriptLoading: boolean
asyncSourceScope: string
hiddenAsyncIds: ReadonlySet<string>
}): MobileNativeChatPrompts {
const { enabled, structured = null, status, messages, transcriptLoading } = args
const scan = useMemo(
() => (!transcriptLoading ? scanPendingAsks(messages) : null),
[messages, transcriptLoading]
)
const pendingAsk = useMemo(
() =>
aggregateAsyncQuestions(scan?.contributors ?? [], args.asyncSourceScope, args.hiddenAsyncIds),
[scan, args.asyncSourceScope, args.hiddenAsyncIds]
)
const transcriptAsk = scan?.blocking ?? pendingAsk
const interactivePrompt = status?.interactivePrompt
const toolName = status?.toolName
const agent = status?.agentType
@@ -42,13 +58,14 @@ export function useMobileNativeChatPrompts(args: {
toolName,
agent,
messages,
transcriptAsk,
transcriptSettled: !transcriptLoading
}),
[interactivePrompt, toolName, agent, messages, transcriptLoading]
[interactivePrompt, toolName, agent, messages, transcriptLoading, transcriptAsk]
)
const structuredAsk = useMobileStructuredAsyncQuestion({
structured,
messages,
pendingAsk,
transcriptLoading
})
if (structured) {
@@ -91,6 +91,7 @@ vi.mock('./use-mobile-native-chat-session', () => ({
}))
vi.mock('./use-mobile-structured-agent-session', () => ({
useMobileStructuredAgentSession: () => ({
asyncQuestionSourceScope: 'journal/host/session/epoch',
session: structuredSessionState,
...structuredActivity,
queued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn() },
@@ -1,3 +1,4 @@
import { asyncQuestionSourceScope } from '../../../src/shared/native-chat/input/native-chat-ask'
import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -134,6 +135,14 @@ export function useMobileNativeChatController(args: {
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
sessionId: activeChatSessionId,
streamIdentity,
asyncSourceScope: activeChatStructured
? structuredNativeChat.asyncQuestionSourceScope
: asyncQuestionSourceScope({
kind: 'transcript',
agent: activeChatAgent,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
}),
visible: showNativeChat,
terminal: bridgePromptWrites,
onSendResolved
@@ -1,3 +1,6 @@
import { useMobileAsyncQuestionHides } from './native-chat/input/use-mobile-native-chat-prompt-dismiss'
import type { AskPrompt } from '../../../src/shared/native-chat/input/native-chat-ask-types'
import type { AskAnswerSelection } from '../../../src/shared/native-chat-ask-answer'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import type { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes'
@@ -17,6 +20,7 @@ export function useMobileNativeChatPromptController({
scopeKey,
sessionId,
streamIdentity,
asyncSourceScope,
visible,
terminal,
onSendResolved
@@ -31,6 +35,7 @@ export function useMobileNativeChatPromptController({
scopeKey: string | null
sessionId: string | null
streamIdentity: string
asyncSourceScope: string
visible: boolean
/** Terminal-bridge writes; a structured chat answers through its own session. */
terminal: Pick<
@@ -55,6 +60,7 @@ export function useMobileNativeChatPromptController({
| 'handleNativeChatAnswerAsk'
| 'handleNativeChatCancelAsk'
> & { hasPromptCard: boolean } {
const asyncHides = useMobileAsyncQuestionHides(scopeKey, asyncSourceScope, streamIdentity)
const {
permission: legacyNativeChatPermission,
waitStartedAt: nativeChatPromptStartedAt,
@@ -65,20 +71,31 @@ export function useMobileNativeChatPromptController({
structured,
status,
messages: session.messages,
transcriptLoading: session.transcriptLoading
transcriptLoading: session.transcriptLoading,
asyncSourceScope,
hiddenAsyncIds: asyncHides.hidden
})
// A never-read transcript cannot prove that a dismissed prompt cleared.
const nativeChatTranscriptSettled =
session.status === 'ready' || (session.status === 'error' && session.messages.length > 0)
const askDismissal = useMobileNativeChatAskDismiss({
ask: nativeChatAskPrompt,
dismissAsync: asyncHides.capture(nativeChatAskPrompt?.occurrenceIds ?? []),
startedAt: nativeChatPromptStartedAt,
scopeKey,
sessionKey: sessionId,
sessionKey: nativeChatAskPrompt?.replyAsMessage ? streamIdentity : sessionId,
observing: visible && (nativeChatAskPrompt != null || nativeChatTranscriptSettled)
})
const answerAsk = useNativeChatAcceptedAction(
structuredAnswer ?? terminal.answerAsk,
async (prompt: AskPrompt, selections: AskAnswerSelection[]) => {
const hide = asyncHides.capture(prompt.occurrenceIds ?? [])
const accepted = await (structuredAnswer ?? terminal.answerAsk)(prompt, selections)
// Only terminal writes acknowledge delivery; structured admission leaves retirement to rows.
if (accepted && prompt.replyAsMessage && !structuredAnswer) {
hide()
}
return accepted
},
onSendResolved
)
const cancelAsk = useNativeChatAcceptedAction(terminal.cancelAsk, onSendResolved)
@@ -1,3 +1,4 @@
import { asyncQuestionSourceScope as questionSourceScope } from '../../../src/shared/native-chat/input/native-chat-ask'
import { useCallback, useMemo, useRef } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat/transcript/native-chat-transcript-retention'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
@@ -206,7 +207,7 @@ export function useMobileStructuredAgentSession(
sendSelection: options.sendSelection,
takesSkills: hostSupport?.sendSkills === true
})
const { items, currentRequests, actionablePromptIds, status } = state
const { items, currentRequests, actionablePromptIds, status, epoch } = state
// Prefer host actionability: ended-agent prompts must receive no answer.
const [approvalPrompt, questionPrompt] = useMemo(() => {
const prompts = { items, currentRequests, actionablePromptIds }
@@ -273,6 +274,7 @@ export function useMobileStructuredAgentSession(
return {
...options,
visualSource,
asyncQuestionSourceScope: questionSourceScope({ kind: 'journal', agent, sessionId, epoch }),
imagePreviewsByMessageId: sends.imagePreviewsByMessageId,
session: {
messages,
@@ -1,11 +1,9 @@
import { useCallback, useMemo } from 'react'
import { extractPendingMessageReplyAsk } from '../../../src/shared/native-chat/input/native-chat-ask'
import { useCallback } from 'react'
import {
formatAskAnswer,
type AskAnswerSelection
} from '../../../src/shared/native-chat-ask-answer'
import type { AskPrompt } from '../../../src/shared/native-chat/input/native-chat-ask-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
export type MobileStructuredAskSession = Pick<
@@ -16,20 +14,17 @@ export type MobileStructuredAskSession = Pick<
// Keep nonblocking structured questions pending until the next user message; answer by ordinary send without touching the draft.
export function useMobileStructuredAsyncQuestion(args: {
structured: MobileStructuredAskSession | null
messages: readonly NativeChatMessage[]
pendingAsk: AskPrompt | null
transcriptLoading: boolean
}): {
ask: AskPrompt | null
answer: ((prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>) | null
} {
const { structured, messages, transcriptLoading } = args
const { structured, pendingAsk, transcriptLoading } = args
// A journal prompt takes the card slot first, as on desktop.
const readable =
structured !== null && !structured.permission && !structured.question && !transcriptLoading
const ask = useMemo(
() => (readable ? extractPendingMessageReplyAsk(messages) : null),
[readable, messages]
)
const ask = readable ? pendingAsk : null
const sendWithOutcome = structured?.sendWithOutcome
const answer = useCallback(
async (prompt: AskPrompt, selections: AskAnswerSelection[]) =>
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest'
import { extractPendingAsk } from '../../shared/native-chat/input/native-chat-ask'
import { codexItemBody } from './codex-structured-item-translation'
import {
extractPendingAsk,
scanPendingAsks,
asyncQuestionOccurrenceId
} from '../../shared/native-chat/input/native-chat-ask'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import {
codexItemBody,
codexItemIdentity,
CodexTurnOrdinals
} from './codex-structured-item-translation'
describe('Codex async question items', () => {
it('carries an async question on its message so clients can answer it', () => {
@@ -34,4 +44,41 @@ describe('Codex async question items', () => {
]
})
})
it('keeps a resumed async item dismissed when provider IDs renumber, but identifies later rows', () => {
function project(rawId: string, ordinals: CodexTurnOrdinals): NativeChatMessage {
const item = {
type: 'agentMessage',
id: rawId,
text: 'Which color?',
delivery: 'async',
questions: [{ title: 'Which color?', options: ['Red', 'Blue'] }]
}
const body = codexItemBody(item)
if (body?.kind !== 'message') {
throw new Error('Expected an async message')
}
return {
id: agentJournalItemKey(
codexItemIdentity({ threadId: 'thread', turnId: 'turn', item, ordinals })
),
role: 'assistant',
blocks: body.blocks,
timestamp: null,
source: 'transcript'
}
}
const liveOrdinals = new CodexTurnOrdinals()
const live = project('live-call-async', liveOrdinals)
const resumed = project('item-0', new CodexTurnOrdinals())
const key = (row: NativeChatMessage) =>
asyncQuestionOccurrenceId(
'journal/host/thread/epoch',
scanPendingAsks([row]).contributors[0]!
)
expect(live.id).toBe(resumed.id)
expect(key(live)).not.toBeNull()
expect(key(resumed)).toBe(key(live))
expect(key(project('later-call-async', liveOrdinals))).not.toBe(key(live))
})
})
@@ -39,6 +39,8 @@ import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import { asyncQuestionSourceScope } from '../../../../shared/native-chat/input/native-chat-ask'
import { useNativeChatAsyncQuestions } from './use-native-chat-async-questions'
import { nativeChatPromptDismissals } from './native-chat-prompt-dismissals'
// Answered occurrences outlive a view by design; each test starts with none.
@@ -117,29 +119,50 @@ function CardHarness({
canSend,
messages,
transcriptSettled,
targetPtyId = 'pty-1'
targetPtyId = 'pty-1',
sourceKey = 'transcript/host/session'
}: {
canSend: boolean
messages?: readonly NativeChatMessage[]
transcriptSettled: boolean
targetPtyId?: string
sourceKey?: string
}): React.JSX.Element {
const asyncQuestions = useNativeChatAsyncQuestions({
paneKey: 'tab-1:leaf-1',
sourceKey,
sourceScope: asyncQuestionSourceScope({
kind: 'transcript',
agent: 'codex',
sessionId: 'session',
transcriptPath: null
}),
viewKey: targetPtyId,
messages: messages ?? NO_MESSAGES,
settled: transcriptSettled && messages !== undefined
})
const card = useNativeChatInteractivePromptCard({
paneKey: 'tab-1:leaf-1',
messages: messages ?? NO_MESSAGES,
transcriptAsk: asyncQuestions.blocking ?? asyncQuestions.prompt,
transcriptSettled: transcriptSettled && messages !== undefined
})
const presentation = useNativeChatPromptCardPresentation({
paneKey: 'tab-1:leaf-1',
targetPtyId,
card,
dismissAsync: asyncQuestions.dismiss,
canSend
})
return (
<TooltipProvider>
{presentation.card ? (
<NativeChatInteractiveCard
key={presentation.occurrenceKey ?? 'prompt'}
key={
presentation.card.kind === 'question' && presentation.card.prompt.replyAsMessage
? asyncQuestions.ownerKey
: (presentation.occurrenceKey ?? 'prompt')
}
card={presentation.card}
onDismiss={presentation.dismiss}
onCollapse={presentation.collapse}
@@ -589,3 +612,136 @@ describe('NativeChatInteractiveCard transcript fallback', () => {
expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument()
})
})
describe('terminal async contributors through the production hooks and card', () => {
const row = (id: string, title: string): NativeChatMessage => ({
id,
role: 'assistant',
timestamp: null,
source: 'transcript',
blocks: [
{
type: 'tool-call',
name: 'request_user_input_async',
input: { questions: [{ title, options: ['Tabs', 'Spaces'] }] }
}
]
})
beforeEach(() => {
vi.clearAllMocks()
paneStatus.interactivePrompt = undefined
paneStatus.toolName = undefined
paneStatus.state = 'done'
})
afterEach(cleanup)
it('retains terminal choices across an agent notice until legacy human input', () => {
const q1 = row('one', 'Q1?')
const notice: NativeChatMessage = {
id: 'mail',
role: 'user',
timestamp: 2,
source: 'transcript',
blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }],
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: {
message: 'mail-notice',
mailbox: 'term_b',
dispatchId: null,
messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }]
}
}
}
const view = render(cardElement(true, [q1]))
fireEvent.click(screen.getByRole('button', { name: /Spaces/ }))
view.rerender(cardElement(true, [q1, notice]))
expect(screen.getByText('Q1?')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'true')
view.rerender(cardElement(true, [q1, notice, { ...notice, id: 'human', from: undefined }]))
expect(composerShown()).toBe(true)
expect(mocks.sendAnswer).not.toHaveBeenCalled()
})
it('filters Q1 before grouping Q2 and never writes Cancel, including window movement', () => {
const q1 = row('one', 'Q1?')
const q2 = row('two', 'Q2?')
const view = render(cardElement(true, [q1]))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
view.rerender(cardElement(true, [q1, q2]))
expect(screen.queryByText('Q1?')).not.toBeInTheDocument()
expect(screen.getByText('Q2?')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
view.rerender(cardElement(true, []))
view.rerender(cardElement(true, [row('one', 'Edited Q1?'), q2]))
expect(composerShown()).toBe(true)
expect(mocks.cancelAsk).not.toHaveBeenCalled()
expect(mocks.cancel).not.toHaveBeenCalled()
expect(mocks.sendRawVerified).not.toHaveBeenCalled()
// Async hides cannot refuse a live blocking prompt with the same definition.
paneStatus.interactivePrompt = JSON.stringify({
questions: [{ question: 'Q1?', options: ['Tabs', 'Spaces'] }]
})
view.rerender(cardElement(true, [q1, q2]))
expect(screen.getByText('Q1?')).toBeInTheDocument()
})
it.each(['same-owner', 'source', 'PTY'])(
'captures answered Q1 IDs while Q2 arrives (%s)',
(replacement) => {
let finish: (delivered: boolean) => void = () => {
throw new Error('not submitted')
}
mocks.sendAnswer.mockImplementation((_prompt, _selections, done) => {
finish = done!
return { settleAfterMs: 500 }
})
const q1 = row('one', 'Q1?')
const q2 = row('two', 'Q2?')
const element = (
messages: NativeChatMessage[],
sourceKey = 'transcript/host/session',
targetPtyId = 'pty-1'
) => (
<CardHarness
canSend
transcriptSettled
messages={messages}
sourceKey={sourceKey}
targetPtyId={targetPtyId}
/>
)
const view = render(element([q1]))
chooseSpacesAndSubmit()
view.rerender(
element(
[q1, q2],
replacement === 'source' ? 'transcript/other/session' : undefined,
replacement === 'PTY' ? 'pty-2' : undefined
)
)
act(() => finish(true))
if (replacement === 'same-owner') {
expect(screen.getByText('Q2?')).toBeInTheDocument()
expect(screen.queryByText('Q1?')).not.toBeInTheDocument()
} else {
expect(screen.getByText('Q1?')).toBeInTheDocument()
}
}
)
it('resets an edited pending form without unmounting its answer owner', () => {
const view = render(cardElement(true, [row('one', 'Q1?')]))
fireEvent.click(screen.getByRole('button', { name: /Spaces/ }))
expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'true')
view.rerender(cardElement(true, [row('one', 'Revised Q1?')]))
expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'false')
expect(mocks.cancelPending).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,4 @@
import { nativeChatAskDismissKey } from '../../../../shared/native-chat-ask-dismiss-key'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import type { InteractivePromptCard } from '../../../../shared/native-chat-interactive-prompt'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
@@ -81,6 +82,7 @@ export function NativeChatInteractiveCard({
}
return (
<NativeChatQuestionCard
key={nativeChatAskDismissKey(card.prompt)}
prompt={card.prompt}
isSubmitting={submitting}
isCancelling={cancelling}
@@ -1,3 +1,4 @@
import type * as PromptCardModule from './use-native-chat-interactive-prompt-card'
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -43,9 +44,20 @@ vi.mock('./use-native-chat-interactive-send', () => ({
})
}))
// A pending question, so the pane mounts its card with the send it hands it.
vi.mock('./use-native-chat-interactive-prompt-card', () => ({
useNativeChatInteractivePromptCard: () => ({ kind: 'question', prompt })
}))
vi.mock('./use-native-chat-interactive-prompt-card', async (importOriginal) => {
const original = await importOriginal<typeof PromptCardModule>()
const { useNativeChatPromptCardPresentation } =
await import('./use-native-chat-prompt-card-presentation')
return {
useNativeChatTerminalPromptCard: (
args: Parameters<typeof original.useNativeChatTerminalPromptCard>[0]
) => {
const card = { kind: 'question' as const, prompt }
const presentation = useNativeChatPromptCardPresentation({ ...args, card })
return { promptCard: card, presentation, actionKey: presentation.occurrenceKey ?? 'prompt' }
}
}
})
vi.mock('./NativeChatInteractiveCard', async (importOriginal) => {
const original = await importOriginal<typeof InteractiveCardModule>()
return {
@@ -20,8 +20,7 @@ import { NativeChatFindBar } from './NativeChatFindBar'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { nativeChatAskRequiresTerminal } from '../../../../shared/native-chat-agent-support'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import { useNativeChatTerminalPromptCard } from './use-native-chat-interactive-prompt-card'
import { NativeChatPromptStrip } from './NativeChatPromptCollapse'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
@@ -188,18 +187,20 @@ export function NativeChatResolvedView({
: new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices]),
[launchPromptDeliveryNotices, delivery.notices]
)
const promptCard = useNativeChatInteractivePromptCard({
paneKey,
messages: sessionAfterCommandBoundaries.messages,
transcriptSettled: session.readPhase === 'ready'
})
// A prompt card and composer must never share the input region: composer text would answer the agent’s selector.
const promptCardPresentation = useNativeChatPromptCardPresentation({
const {
promptCard,
presentation: promptCardPresentation,
actionKey
} = useNativeChatTerminalPromptCard({
paneKey,
targetPtyId,
card: promptCard,
canSend,
transcriptSettled: session.readPhase === 'ready'
runtimeEnvironmentId,
agent,
sessionId,
transcriptPath: transcriptPath ?? null,
messages: sessionAfterCommandBoundaries.messages,
transcriptSettled: session.readPhase === 'ready',
canSend
})
const { card: shownPromptCard, collapsedCard } = promptCardPresentation
const mountedPromptCard = shownPromptCard ?? collapsedCard
@@ -378,7 +379,7 @@ export function NativeChatResolvedView({
{mountedPromptCard ? (
<div hidden={collapsedCard !== null} inert={collapsedCard !== null} className="contents">
<NativeChatInteractiveCard
key={promptCardPresentation.occurrenceKey ?? 'prompt'}
key={actionKey}
card={mountedPromptCard}
send={interactiveSend}
requiresTerminal={nativeChatAskRequiresTerminal(agent)}
@@ -2,13 +2,18 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { asyncQuestionSourceScope } from '../../../../shared/native-chat/input/native-chat-ask'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
import { NativeChatStructuredAsyncQuestion } from './NativeChatStructuredAsyncQuestion'
import { useNativeChatAsyncQuestions } from './use-native-chat-async-questions'
import { nativeChatPromptDismissals } from './native-chat-prompt-dismissals'
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
nativeChatPromptDismissals.clearForTests()
vi.useFakeTimers()
container = document.createElement('div')
document.body.appendChild(container)
@@ -45,6 +50,8 @@ describe('NativeChatStructuredAsyncQuestion', () => {
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
sourceScope="journal/session/epoch"
sourceKey="journal/host/session/epoch"
session={{ messages, readPhase: 'ready' }}
send={send}
/>
@@ -104,7 +111,7 @@ describe('NativeChatStructuredAsyncQuestion', () => {
act(() => vi.runAllTimers())
expect(send).toHaveBeenCalledWith('> Which color?\n\nBlue', [])
expect(container.textContent?.includes('Which color?')).toBe(!accepted)
expect(container.textContent).toContain('Which color?')
render([
asked,
notice,
@@ -119,4 +126,327 @@ describe('NativeChatStructuredAsyncQuestion', () => {
expect(container.textContent).not.toContain('Which color?')
}
)
it.each([undefined, 'reused-call'])(
'shows an identical replacement in one ready-to-ready update (call ID: %s)',
(callId) => {
const send = vi.fn(() => true)
const first = {
...asked,
blocks: asked.blocks.map((block) =>
block.type === 'tool-call' ? { ...block, callId } : block
)
}
const render = (messages: NativeChatMessage[]) =>
act(() =>
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
sourceScope="journal/session/epoch"
sourceKey="journal/host/session/epoch"
session={{ messages, readPhase: 'ready' }}
send={send}
/>
)
)
render([first])
act(() =>
container
.querySelector('button[aria-label="Cancel"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
expect(container.textContent).not.toContain('Which color?')
expect(send).not.toHaveBeenCalled()
// Same canonical row stays dismissed through renamed/removed IDs and block reshaping.
for (const readbackId of ['resumed-call', undefined]) {
render([
{
...first,
blocks: [
{ type: 'text', text: 'More context' },
...first.blocks.map((block) =>
block.type === 'tool-call' ? { ...block, callId: readbackId } : block
)
]
}
])
expect(container.textContent).not.toContain('Which color?')
}
const answer: NativeChatMessage = {
id: 'delivered-answer',
role: 'user',
blocks: [{ type: 'text', text: 'Blue' }],
timestamp: null,
source: 'transcript'
}
render([first, answer, { ...first, id: 'ask-again' }])
expect(container.textContent).toContain('Which color?')
const blue = [...container.querySelectorAll('button[aria-pressed]')].find((node) =>
node.textContent?.includes('Blue')
)
act(() => blue?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
act(() => vi.runAllTimers())
expect(send).toHaveBeenCalledWith('> Which color?\n\nBlue', [])
expect(container.textContent).toContain('Which color?')
render([
first,
answer,
{ ...first, id: 'ask-again' },
{ ...answer, id: 'second-answer' },
{ ...first, id: 'third-ask' }
])
expect(container.textContent).toContain('Which color?')
}
)
it('keeps cancelled contributors hidden across edits, new siblings, paging and blocking', () => {
const send = vi.fn(() => true)
const render = (
messages: NativeChatMessage[],
blocked = false,
sourceKey = 'journal/host/session/epoch'
) =>
act(() =>
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
sourceScope={sourceKey}
sourceKey={sourceKey}
session={{ messages, readPhase: 'ready' }}
blocked={blocked}
send={send}
/>
)
)
const cancel = () =>
act(() =>
container
.querySelector('button[aria-label="Cancel"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
render([asked])
cancel()
const edited = {
...asked,
blocks: [
{
type: 'tool-call' as const,
name: 'request_user_input_async',
input: { questions: [{ title: 'Edited Q1?' }] }
}
]
}
const sibling = {
type: 'tool-call' as const,
name: 'request_user_input_async',
input: { questions: [{ title: 'Q2?' }] }
}
render([{ ...edited, blocks: [...edited.blocks, sibling] }])
expect(container.textContent).toContain('Q2?')
expect(container.textContent).not.toContain('Edited Q1?')
cancel()
render([])
render([asked], true)
render([{ ...edited, blocks: [...edited.blocks, sibling] }])
expect(container.textContent).toBe('')
render([asked, { ...asked, id: 'new-equal-text-row' }])
expect(container.textContent).toContain('Which color?')
cancel()
expect(send).not.toHaveBeenCalled()
render([asked], false, 'journal/host/session/reset')
expect(container.textContent).toContain('Which color?')
})
it('resets pending selections when a definition changes without cancelling it', () => {
const send = vi.fn(() => true)
const render = (title: string, option: string) =>
act(() =>
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
sourceScope="journal/session/epoch"
sourceKey="journal/host/session/epoch"
session={{
readPhase: 'ready',
messages: [
{
...asked,
blocks: [
{
type: 'tool-call',
name: 'request_user_input_async',
input: { questions: [{ title, options: [option, 'Blue'] }] }
}
]
}
]
}}
send={send}
/>
)
)
render('Q1?', 'Red')
const red = [...container.querySelectorAll('button[aria-pressed]')].find((node) =>
node.textContent?.includes('Red')
)!
act(() => red.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(red.getAttribute('aria-pressed')).toBe('true')
render('Revised Q1?', 'Green')
expect(container.textContent).toContain('Revised Q1?')
expect([...container.querySelectorAll('button[aria-pressed="true"]')]).toHaveLength(0)
expect(send).not.toHaveBeenCalled()
})
it('isolates addressed hide subscriptions and reuses the scan at N/10N panes', () => {
for (const count of [8, 80]) {
let scans = 0
const messages = [
{
...asked,
get blocks() {
scans += 1
return asked.blocks
}
}
]
const renders = Array<number>(count).fill(0)
const actions: (() => void)[] = []
function Probe({ index }: { index: number }): null {
renders[index] = (renders[index] ?? 0) + 1
const projection = useNativeChatAsyncQuestions({
paneKey: `pane-${count}-${index}`,
sourceKey: `host-${index % 2}/session-${Math.floor(index / 2)}/epoch`,
sourceScope: asyncQuestionSourceScope({
kind: 'journal',
agent: 'codex',
sessionId: `session-${Math.floor(index / 2)}`,
epoch: 'epoch'
}),
viewKey: 'pty',
messages,
settled: true
})
actions[index] = projection.dismiss
return null
}
act(() =>
root.render(
<>
{Array.from({ length: count }, (_, index) => (
<Probe key={index} index={index} />
))}
</>
)
)
const initialScans = scans
expect(initialScans).toBeGreaterThanOrEqual(count)
expect(initialScans).toBeLessThanOrEqual(2 * count)
renders.fill(0)
act(() => actions[0]!())
expect(renders).toEqual([1, ...Array<number>(count - 1).fill(0)])
expect(scans).toBe(initialScans)
renders.fill(0)
act(() => actions[1]!())
expect(renders).toEqual([0, 1, ...Array<number>(count - 2).fill(0)])
expect(scans).toBe(initialScans)
renders.fill(0)
act(() => actions[0]!())
expect(renders.every((value) => value === 0)).toBe(true)
act(() => root.render(null))
}
})
it('preserves later same-ID Cancel through eviction without depending on answer settlement', () => {
let settle = () => {}
const send = vi.fn<NativeChatStructuredComposerTransport['send']>((_text, _images, request) => {
settle = () => request?.onSettled?.('unconfirmed')
return 'queued'
})
const render = () =>
act(() =>
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
sourceKey="journal/host/session/epoch"
sourceScope="journal/session/epoch"
session={{ messages: [asked], readPhase: 'ready' }}
send={send}
/>
)
)
render()
act(() =>
[...container.querySelectorAll('button[aria-pressed]')]
.find((node) => node.textContent?.includes('Blue'))!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
act(() => vi.runAllTimers())
expect(container.textContent).toContain('Which color?')
act(() => {
for (let index = 0; index < 128; index += 1) {
nativeChatPromptDismissals.hideAsync(`other-${index}`, ['other'])
}
})
act(() =>
container
.querySelector('button[aria-label="Cancel"]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
act(settle)
render()
expect(container.textContent).not.toContain('Which color?')
expect(send).toHaveBeenCalledWith('> Which color?\n\nBlue', [])
})
it.each([false, true, 'queued'] as const)(
'does not make %s admission a retained hide',
(admission) => {
const send = vi.fn<NativeChatStructuredComposerTransport['send']>(() => admission)
act(() =>
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
sourceKey="host"
sourceScope="journal/session/epoch"
session={{ messages: [asked], readPhase: 'ready' }}
send={send}
/>
)
)
act(() =>
[...container.querySelectorAll('button[aria-pressed]')]
.find((node) => node.textContent?.includes('Blue'))!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
act(() => vi.runAllTimers())
expect(container.textContent).toContain('Which color?')
}
)
it('resets pending choices when host ownership changes with the same portable definition', () => {
const send = vi.fn(() => true)
const render = (host: string) =>
act(() =>
root.render(
<NativeChatStructuredAsyncQuestion
paneKey="same-pane"
sourceKey={host}
sourceScope="same-portable-source"
session={{ messages: [asked], readPhase: 'ready' }}
send={send}
/>
)
)
render('host-A')
act(() =>
[...container.querySelectorAll('button[aria-pressed]')]
.find((node) => node.textContent?.includes('Blue'))!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
expect(container.querySelectorAll('button[aria-pressed="true"]')).toHaveLength(1)
render('host-B')
expect(container.querySelectorAll('button[aria-pressed="true"]')).toHaveLength(0)
act(() => vi.runAllTimers())
expect(send).not.toHaveBeenCalled()
})
})
@@ -1,46 +1,77 @@
import { useMemo } from 'react'
import { extractPendingMessageReplyAsk } from '../../../../shared/native-chat/input/native-chat-ask'
import { asyncQuestionSourceScope } from '../../../../shared/native-chat/input/native-chat-ask'
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
import { structuredAgentSessionHostKey } from '../../runtime/structured-agent-session-host-capability'
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
import type { useStructuredAgentSession } from './use-structured-agent-session'
import { nativeChatAskDismissKey } from '../../../../shared/native-chat-ask-dismiss-key'
import { formatAskAnswer } from '../../../../shared/native-chat-ask-answer'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import { useNativeChatAsyncQuestions } from './use-native-chat-async-questions'
/** Async questions leave the composer usable; the next ordinary send answers them and Cancel only hides them. */
export function NativeChatStructuredAsyncQuestion({
paneKey,
session,
sourceKey,
sourceScope,
blocked = false,
send
}: {
paneKey: string
sourceKey: string
sourceScope: string
blocked?: boolean
session: Pick<NativeChatLiveSession, 'messages' | 'readPhase'>
send: NativeChatStructuredComposerTransport['send']
}): React.JSX.Element | null {
const settled = session.readPhase === 'ready'
const pending = useMemo(() => {
const prompt = settled ? extractPendingMessageReplyAsk(session.messages) : null
return prompt ? { kind: 'question' as const, prompt } : null
}, [session.messages, settled])
const presentation = useNativeChatPromptCardPresentation({
const { prompt, dismiss, ownerKey } = useNativeChatAsyncQuestions({
paneKey,
card: pending,
canSend: true,
transcriptSettled: settled
sourceKey,
sourceScope,
viewKey: paneKey,
messages: session.messages,
settled: session.readPhase === 'ready' && !blocked
})
const { card } = presentation
if (card?.kind !== 'question') {
if (!prompt) {
return null
}
return (
<NativeChatQuestionCard
key={presentation.occurrenceKey ?? 'async-question'}
prompt={card.prompt}
key={JSON.stringify([ownerKey, nativeChatAskDismissKey(prompt)])}
prompt={prompt}
onAnswer={(selections) => {
if (send(formatAskAnswer(card.prompt, selections), []) !== false) {
presentation.dismiss()
}
// Send admission can precede delivery; canonical transcript rows retire questions.
send(formatAskAnswer(prompt, selections), [])
}}
onCancel={presentation.dismiss}
onCancel={dismiss}
/>
)
}
export function nativeChatStructuredAsyncQuestion(
props: Pick<NativeChatStructuredViewProps, 'target' | 'agent' | 'sessionId' | 'tabId'>,
controller: Pick<ReturnType<typeof useStructuredAgentSession>, 'epoch' | 'prompts'>,
session: Pick<NativeChatLiveSession, 'messages' | 'readPhase'>,
send: NativeChatStructuredComposerTransport['send']
): React.JSX.Element {
const { agent, sessionId } = props
const { epoch } = controller
return (
<NativeChatStructuredAsyncQuestion
paneKey={structuredAgentSessionPaneKey(props.tabId, sessionId)}
session={session}
send={send}
blocked={controller.prompts[0] != null}
sourceKey={JSON.stringify([
'journal',
structuredAgentSessionHostKey(props.target),
agent,
sessionId,
epoch
])}
sourceScope={asyncQuestionSourceScope({ kind: 'journal', agent, sessionId, epoch })}
/>
)
}
@@ -18,7 +18,7 @@ import { NativeChatContextClearFade } from './NativeChatContextClearFade'
import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal'
import { useStructuredPromptResponseHold } from './use-structured-prompt-response-hold'
import { NativeChatStructuredQuestionCard } from './NativeChatStructuredQuestionCard'
import { NativeChatStructuredAsyncQuestion } from './NativeChatStructuredAsyncQuestion'
import { nativeChatStructuredAsyncQuestion } from './NativeChatStructuredAsyncQuestion'
import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state'
import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing'
import { useNativeChatFontSize } from './use-native-chat-font-size'
@@ -219,7 +219,6 @@ export function NativeChatStructuredSession(
onSubmitted: submits.revealLatest,
sendBlockedReason: hostSendGate.sendBlockedReason ?? install.sendBlockedReason
})
const asyncQuestion = { paneKey, session, send: transport.send }
const continuation = useNativeChatInterruptedContinuation({
...controller,
target: props.target,
@@ -386,7 +385,7 @@ export function NativeChatStructuredSession(
answerInputRef={questionAnswerInputRef}
/>
) : null}
{prompt ? null : <NativeChatStructuredAsyncQuestion {...asyncQuestion} />}
{nativeChatStructuredAsyncQuestion(props, controller, session, transport.send)}
{composerShown && <NativeChatInstallSlot slot={install.slot} agentLabel={agentLabel} />}
{composerShown ? (
<NativeChatComposer
@@ -0,0 +1,51 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import {
aggregateAsyncQuestions,
scanPendingAsks
} from '../../../../shared/native-chat/input/native-chat-ask'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { nativeChatPromptDismissals as dismissals } from './native-chat-prompt-dismissals'
export function useNativeChatAsyncQuestions({
paneKey,
sourceKey,
sourceScope,
viewKey,
messages,
settled
}: {
paneKey: string
sourceKey: string
sourceScope: string
viewKey: string | null
messages: readonly NativeChatMessage[]
settled: boolean
}) {
const scopeKey = `${paneKey}:async:${sourceKey}`
const owner = useMemo(() => ({ scopeKey, viewKey }), [scopeKey, viewKey])
const active = useRef<object | null>(null)
useLayoutEffect(() => {
active.current = owner
return () => {
active.current = null
}
}, [owner])
useLayoutEffect(() => () => dismissals.forgetAsync(scopeKey), [scopeKey])
const hidden = useSyncExternalStore(dismissals.subscribe, () => dismissals.readAsync(scopeKey))
const scan = useMemo(() => (settled ? scanPendingAsks(messages) : null), [messages, settled])
const prompt = useMemo(
() => aggregateAsyncQuestions(scan?.contributors ?? [], sourceScope, hidden),
[scan, sourceScope, hidden]
)
const dismiss = useCallback(() => {
if (active.current === owner) {
dismissals.hideAsync(scopeKey, prompt?.occurrenceIds ?? [])
}
}, [owner, scopeKey, prompt])
return {
prompt,
blocking: scan?.blocking ?? null,
dismiss,
ownerKey: JSON.stringify([scopeKey, viewKey])
}
}
@@ -1,5 +1,9 @@
import { asyncQuestionSourceScope } from '../../../../shared/native-chat/input/native-chat-ask'
import { useNativeChatAsyncQuestions } from './use-native-chat-async-questions'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import { useMemo } from 'react'
import { useAppStore } from '../../store'
import type { AskPrompt } from '../../../../shared/native-chat/input/native-chat-ask-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { resolveNativeChatInteractivePromptCard } from '../../../../shared/native-chat-interactive-prompt'
import { sayNativeChatApprovalTranslated } from './native-chat-interactive-prompt'
@@ -8,12 +12,14 @@ import type { InteractivePromptCard } from '../../../../shared/native-chat-inter
export function useNativeChatInteractivePromptCard({
paneKey,
messages,
transcriptSettled
transcriptSettled,
transcriptAsk
}: {
paneKey: string
/** Pass the command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
transcriptAsk?: AskPrompt | null
}): InteractivePromptCard {
const interactivePrompt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
@@ -32,8 +38,67 @@ export function useNativeChatInteractivePromptCard({
agent,
messages,
transcriptSettled,
transcriptAsk,
say: sayNativeChatApprovalTranslated
}),
[interactivePrompt, interactiveToolName, agent, messages, transcriptSettled]
[interactivePrompt, interactiveToolName, agent, messages, transcriptSettled, transcriptAsk]
)
}
export function useNativeChatTerminalPromptCard(args: {
paneKey: string
targetPtyId: string | null
runtimeEnvironmentId: string | null | undefined
agent: string
sessionId: string | null
transcriptPath: string | null
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
canSend: boolean
}) {
const {
paneKey,
targetPtyId,
runtimeEnvironmentId,
agent,
sessionId,
transcriptPath,
messages,
transcriptSettled,
canSend
} = args
const asyncQuestions = useNativeChatAsyncQuestions({
paneKey,
viewKey: targetPtyId,
sourceKey: JSON.stringify([
'transcript',
runtimeEnvironmentId ?? null,
agent,
sessionId,
transcriptPath
]),
sourceScope: asyncQuestionSourceScope({ kind: 'transcript', agent, sessionId, transcriptPath }),
messages,
settled: transcriptSettled
})
const promptCard = useNativeChatInteractivePromptCard({
paneKey,
messages,
transcriptSettled,
transcriptAsk: asyncQuestions.blocking ?? asyncQuestions.prompt
})
const presentation = useNativeChatPromptCardPresentation({
paneKey,
targetPtyId,
card: promptCard,
dismissAsync: asyncQuestions.dismiss,
canSend,
transcriptSettled
})
const mounted = presentation.card ?? presentation.collapsedCard
const actionKey =
mounted?.kind === 'question' && mounted.prompt.replyAsMessage
? asyncQuestions.ownerKey
: (presentation.occurrenceKey ?? 'prompt')
return { promptCard, presentation, actionKey }
}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '../../store'
import { useNativeChatPromptCardPresentation } from './use-native-chat-prompt-card-presentation'
import {
@@ -47,6 +47,8 @@ beforeEach(() => {
useAppStore.setState({ agentStatusByPaneKey: {} })
})
afterEach(cleanup)
describe('dismissed prompt occurrences outlive the view but not the prompt', () => {
it('keeps a status-backed answer hidden for a remounted view', () => {
waitFromStatus(10)
@@ -31,7 +31,8 @@ export function useNativeChatPromptCardPresentation({
targetPtyId = null,
card,
canSend,
transcriptSettled = true
transcriptSettled = true,
dismissAsync
}: {
paneKey: string
targetPtyId?: string | null
@@ -40,6 +41,7 @@ export function useNativeChatPromptCardPresentation({
canSend: boolean
/** While the transcript loads, an absent transcript card is unknown, not cleared. */
transcriptSettled?: boolean
dismissAsync?: () => void
}): NativeChatPromptCardPresentation {
const startedAt = useAppStore((s) =>
nativeChatPromptWaitStartedAt(card, s.agentStatusByPaneKey[paneKey])
@@ -57,7 +59,10 @@ export function useNativeChatPromptCardPresentation({
}
}, [occurrence])
const dismissal = useSyncExternalStore(dismissals.subscribe, () => dismissals.read(paneKey))
const matches = nativeChatPromptDismissalMatches(dismissal, content, startedAt)
const isAsync = card?.kind === 'question' && card.prompt.replyAsMessage === true
const matches =
(!isAsync || dismissal?.state === 'collapsed') &&
nativeChatPromptDismissalMatches(dismissal, content, startedAt)
// Retire cleared/changed dismissals so later identical prompts can appear.
useLayoutEffect(() => {
if (dismissal !== undefined && !matches && (content !== null || transcriptSettled)) {
@@ -81,7 +86,13 @@ export function useNativeChatPromptCardPresentation({
},
[occurrence, canSend, content, startedAt, paneKey]
)
const dismiss = useCallback(() => record('answered'), [record])
const dismiss = useCallback(() => {
if (isAsync && dismissAsync) {
dismissAsync()
} else {
record('answered')
}
}, [isAsync, dismissAsync, record])
const collapse = useCallback(() => record('collapsed'), [record])
const expand = useCallback(() => dismissals.forget(paneKey), [paneKey])
const shown = card !== null && canSend
+4 -1
View File
@@ -7,11 +7,14 @@ function askDismissKeyText(text: string | undefined): string | undefined {
return text?.slice(0, ASK_DISMISS_KEY_TEXT_PREFIX)
}
/** Stable cross-platform identity for one canonical question prompt. */
/** Async form revisions include the full definition; blocking handoff keeps its content key. */
export function nativeChatAskDismissKey(prompt: AskPrompt | null): string | null {
if (!prompt) {
return null
}
if (prompt.replyAsMessage) {
return `async-question:${JSON.stringify([prompt.occurrenceIds ?? [], prompt.questions])}`
}
const questions = prompt.questions.map((question) => ({
...question,
question: askDismissKeyText(question.question),
+3 -1
View File
@@ -108,6 +108,7 @@ export function resolveNativeChatInteractivePromptCard(args: {
/** Pass the command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
transcriptAsk?: AskPrompt | null
say?: NativeChatApprovalSay
}): InteractivePromptCard {
const statusCard = parseInteractivePrompt(
@@ -122,7 +123,8 @@ export function resolveNativeChatInteractivePromptCard(args: {
const prompt = resolveNativeChatAsk({
liveAsk: statusCard?.prompt ?? null,
messages: args.messages,
transcriptSettled: args.transcriptSettled
transcriptSettled: args.transcriptSettled,
transcriptAsk: args.transcriptAsk
})
return prompt ? { kind: 'question', prompt } : null
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { nativeChatCardDismissKey } from './native-chat-prompt-dismiss-key'
import type { AskQuestion } from './native-chat/input/native-chat-ask-types'
describe('nativeChatCardDismissKey', () => {
it('distinguishes prompts whose later questions or options changed', () => {
@@ -17,4 +18,32 @@ describe('nativeChatCardDismissKey', () => {
expect(card('Old second', 'A')).not.toBe(card('New second', 'A'))
expect(card('Old second', 'A')).not.toBe(card('Old second', 'B'))
})
it('includes full normalized async content when the contributing rows are unchanged', () => {
const text = 'x'.repeat(210)
const first = {
question: `${text}A`,
header: `${text}A`,
multiSelect: false,
options: [{ label: 'One', description: `${text}A` }, { label: 'Two' }]
}
const second = { question: 'Second?', multiSelect: false, options: [] }
const key = (questions: AskQuestion[]) =>
nativeChatCardDismissKey({
kind: 'question',
prompt: { questions, replyAsMessage: true, occurrenceIds: ['addressed-call'] }
})
const original = key([first, second])
for (const changed of [
{ ...first, question: `${text}B` },
{ ...first, header: `${text}B` },
{ ...first, multiSelect: true },
{ ...first, options: [{ label: 'Changed', description: `${text}A` }, { label: 'Two' }] },
{ ...first, options: [{ label: 'One', description: `${text}B` }, { label: 'Two' }] },
{ ...first, options: first.options.toReversed() }
]) {
expect(key([changed, second])).not.toBe(original)
}
expect(key([second, first])).not.toBe(original)
})
})
@@ -11,6 +11,8 @@ export type AskPrompt = {
questions: AskQuestion[]
/** The agent kept working after asking, so the answer goes back as an ordinary message. */
replyAsMessage?: true
/** The individually addressed calls displayed by this local projection. */
occurrenceIds?: readonly string[]
}
export type InteractiveQuestionParser = (input: unknown) => AskPrompt | null
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
@@ -8,9 +8,16 @@ import {
readAgentMessageSource,
type AgentMessageSource
} from '../../agent-session/agent-session-message-source'
import { nativeChatAskDismissKey } from '../../native-chat-ask-dismiss-key'
import {
extractPendingAsk,
extractPendingMessageReplyAsk,
aggregateAsyncQuestions,
asyncQuestionOccurrenceId,
asyncQuestionSourceScope,
scanPendingAsks,
createPendingAskScanner,
isDeliveredNativeChatUserMessage,
resolveNativeChatAsk
} from './native-chat-ask'
@@ -148,22 +155,208 @@ describe('async questions', () => {
role: 'user',
...flags
})
const titles = (messages: NativeChatMessage[]): string[] | undefined =>
extractPendingAsk(messages)?.questions.map((question) => question.question)
it('keeps every unanswered async question, once per call', () => {
const first = message('a', [asyncCall('call-a', 'A?'), result()])
expect(titles([first, first, message('b', [asyncCall('call-b', 'B?'), result()])])).toEqual([
'A?',
'B?'
it('addresses accepted calls independently of definition, provider IDs and unrelated blocks', () => {
const first = message('a', [
call('request_user_input_async', { questions: [{ title: 'A?' }, { title: 'B?' }] }),
asyncCall('call-c', 'C?'),
result()
])
const scan = scanPendingAsks([first, message('b', [asyncCall('call-c', 'duplicate')])])
expect(scan.contributors).toMatchObject([
{
sourceRowId: 'a',
callSlot: 0,
timestamp: 1,
responseMode: 'message',
questions: [{ question: 'A?' }, { question: 'B?' }]
},
{ sourceRowId: 'a', callSlot: 1, questions: [{ question: 'C?' }] }
])
expect(scan.contributors).toHaveLength(2)
const pending = aggregateAsyncQuestions(scan.contributors, 'host/session/epoch')!
const ids = pending.occurrenceIds!
expect(new Set(ids).size).toBe(2)
expect(
aggregateAsyncQuestions(
scan.contributors,
'host/session/epoch',
new Set([ids[0]!])
)?.questions.map((q) => q.question)
).toEqual(['C?'])
expect(
aggregateAsyncQuestions(
scan.contributors,
'host/session/epoch',
new Set([ids[1]!])
)?.questions.map((q) => q.question)
).toEqual(['A?', 'B?'])
const replay = scanPendingAsks([
{
...first,
timestamp: null,
blocks: [
{ type: 'text', text: 'prefix' },
call('Bash', {}),
call('request_user_input_async', { questions: [{ title: 'Edited?' }] }),
asyncCall('renumbered', 'C?')
]
}
])
expect(replay.contributors[0]?.timestamp).toBeNull()
expect(
aggregateAsyncQuestions(replay.contributors, 'host/session/epoch')?.occurrenceIds
).toEqual(ids)
expect(
aggregateAsyncQuestions(replay.contributors, 'host/session/epoch', new Set(ids))
).toBeNull()
for (const scope of [
'other-host/session/epoch',
'host/other-session/epoch',
'host/session/reset'
]) {
expect(asyncQuestionOccurrenceId(scope, scan.contributors[0]!)).not.toBe(ids[0])
}
const later = scanPendingAsks([message('later', [asyncCall('renumbered', 'A?')])])
expect(asyncQuestionOccurrenceId('host/session/epoch', later.contributors[0]!)).not.toBe(ids[0])
const repeated = scanPendingAsks([
message('a', [call('request_user_input_async', { questions: [{ title: 'A?' }] })]),
message('a', [call('request_user_input_async', { questions: [{ title: 'A?' }] })])
])
expect(repeated.contributors.map((c) => c.callSlot)).toEqual([0, 1])
})
it('shares portable descriptor spelling while distinguishing source/session/reset/path', () => {
const contributor = scanPendingAsks([message('same-row', [asyncCall('A', 'QA')])])
.contributors[0]!
const journal = {
kind: 'journal' as const,
agent: 'codex',
sessionId: 'session',
epoch: 'epoch'
}
const id = (source: Parameters<typeof asyncQuestionSourceScope>[0]) =>
asyncQuestionOccurrenceId(asyncQuestionSourceScope(source), contributor)
expect(id({ ...journal })).toBe(id(journal))
expect(asyncQuestionSourceScope(journal)).toBe(
JSON.stringify(['journal', 'codex', 'session', 'epoch'])
)
for (const source of [
{ ...journal, agent: 'claude' },
{ ...journal, sessionId: 'another' },
{ ...journal, epoch: 'reset' },
{ kind: 'transcript' as const, agent: 'codex', sessionId: 'session', transcriptPath: null },
{
kind: 'transcript' as const,
agent: 'codex',
sessionId: 'session',
transcriptPath: '/explicit/path'
}
]) {
expect(id(source)).not.toBe(id(journal))
}
const terminal = {
kind: 'transcript' as const,
agent: 'codex',
sessionId: 'session',
transcriptPath: null
}
expect(id(terminal)).not.toBe(id({ ...terminal, transcriptPath: '/explicit/path' }))
})
it('keeps a surviving sibling addressed when a deduping prefix leaves the loaded window', () => {
const prefix = message('P', [asyncCall('A', 'QA')])
const row = message('R', [asyncCall('A', 'QA'), asyncCall('B', 'QB')])
const before = aggregateAsyncQuestions(scanPendingAsks([prefix, row]).contributors, 'source')!
const hidden = new Set([before.occurrenceIds![1]!])
const after = aggregateAsyncQuestions(scanPendingAsks([row]).contributors, 'source', hidden)!
expect(after.questions.map((q) => q.question)).toEqual(['QA'])
expect(after.occurrenceIds).not.toContain(before.occurrenceIds![1])
})
it('exposes exactly the existing chronological interrupt without consuming call slots', () => {
const prefix = message('R1', [
call('Bash', {}),
asyncCall('X', 'Q1'),
call('AskUserQuestion', QUESTIONS_INPUT)
])
const suffix = [
message('R2', [asyncCall('X', 'Q2'), result()]),
message('R1', [asyncCall('Y', 'Q3'), result()]),
message('blocking', [call('AskUserQuestion', QUESTIONS_INPUT), result()])
]
const direct = createPendingAskScanner()
const emitted = direct.append(prefix)
const borrowed = direct.current().contributors
expect(direct.current().blocking).not.toBeNull()
direct.interrupt()
direct.interrupt()
expect(direct.current()).toEqual({ blocking: null, contributors: [] })
for (const row of suffix) {
direct.append(row)
}
const fromRow = scanPendingAsks([prefix, interrupted('boundary'), ...suffix])
expect(direct.current()).toEqual(fromRow)
expect(fromRow.blocking).toBeNull()
expect(fromRow.contributors.map((c) => [c.sourceRowId, c.callSlot])).toEqual([
['R2', 0],
['R1', 1]
])
expect(emitted).toEqual(borrowed)
expect(emitted.map((c) => c.questions[0]?.question)).toEqual(['Q1'])
})
it('folds once and reports accepted calls even when a later delivery retires them', () => {
const scanner = createPendingAskScanner()
const first = scanner.append(message('a', [asyncCall('A', 'QA')]))
const borrowed = scanner.current().contributors
expect(first).toHaveLength(1)
expect(scanner.append(message('noise', [{ type: 'text', text: 'working' }]))).toEqual([])
expect(scanner.current().contributors).toBe(borrowed)
expect(isDeliveredNativeChatUserMessage(userRow('queued', { queued: true }))).toBe(false)
expect(isDeliveredNativeChatUserMessage(message('assistant', []))).toBe(false)
expect(isDeliveredNativeChatUserMessage(userRow('delivered'))).toBe(true)
scanner.append(userRow('delivered'))
expect(scanner.current().contributors).toEqual([])
expect(first[0]?.questions[0]?.question).toBe('QA')
expect(scanner.append(message('b', [asyncCall('A', 'QA')]))).toHaveLength(1)
})
it('keeps notice replay and dedupe separate from the blocking and human boundaries', () => {
const scanner = createPendingAskScanner()
scanner.append(
message('asked', [asyncCall('same-call', 'A?'), call('AskUserQuestion', QUESTIONS_INPUT)])
)
const pending = scanner.current().contributors
expect(scanner.current().blocking).not.toBeNull()
const notice = userRow('notice', {
from: { kind: 'agent', senders: [], orchestration: null },
blocks: [{ type: 'text', text: 'Blue' }]
})
expect(isDeliveredNativeChatUserMessage(notice)).toBe(false)
scanner.append(notice)
expect(scanner.current().contributors).toBe(pending)
expect(scanner.current().blocking).toBeNull()
expect(scanner.append(message('replay', [asyncCall('same-call', 'duplicate')]))).toEqual([])
scanner.append({ ...notice, id: 'legacy-human', from: undefined })
expect(scanner.current().contributors).toEqual([])
expect(scanner.append(message('new', [asyncCall('same-call', 'new question')]))).toHaveLength(1)
expect(
scanPendingAsks([message('asked', [asyncCall('same-call', 'A?')]), notice]).contributors
).toEqual(pending)
})
it.each(['queued', 'unsent', 'stoppedBeforeStart', 'awaitsRetry', 'sentWhileStopping'] as const)(
'stays open while a human row is marked %s',
(flag) => {
const asked = message('a', [asyncCall('call-a', 'A?'), result()])
expect(titles([asked, userRow('waiting', { [flag]: true })])).toEqual(['A?'])
const waiting = userRow('waiting', { [flag]: true })
expect(extractPendingAsk([asked, waiting])?.questions.map((q) => q.question)).toEqual(['A?'])
const scanner = createPendingAskScanner()
scanner.append(asked)
const pending = scanner.current().contributors
scanner.append(waiting)
expect(scanner.current().contributors).toBe(pending)
expect(isDeliveredNativeChatUserMessage(waiting)).toBe(false)
expect(extractPendingAsk([asked, userRow('sent')])).toBeNull()
}
)
@@ -206,4 +399,111 @@ describe('async questions', () => {
}
}
})
it('bounds real scan, hide filtering and form serialization at N/10N without replay accumulation', () => {
function measure(callCount: number, titleLength = 32) {
let blockReads = 0
const rows = Array.from({ length: callCount }, (_, index) => {
const id = String(index).padStart(4, '0')
return message(`row-${id}`, [
{
get type() {
blockReads += 1
return 'tool-call' as const
},
name: 'request_user_input_async',
callId: `call-${id}`,
input: { questions: [{ title: 'é'.repeat(titleLength) }] }
}
])
})
const stringify = JSON.stringify
function read(messages: NativeChatMessage[], hideAll = false) {
blockReads = 0
let serializedBytes = 0
let serializations = 0
// Observe real serialization, including any intermediate occurrence strings.
const spy = vi.spyOn(JSON, 'stringify').mockImplementation((value) => {
const encoded = stringify(value)
serializedBytes += new TextEncoder().encode(encoded).byteLength
serializations += 1
return encoded
})
try {
let filterChecks = 0
const hidden = new Set<string>()
hidden.has = () => {
filterChecks += 1
return hideAll
}
const scan = scanPendingAsks(messages)
const key = nativeChatAskDismissKey(
aggregateAsyncQuestions(scan.contributors, 'source/session/epoch', hidden)
)
return { key, blockReads, filterChecks, serializedBytes, serializations }
} finally {
spy.mockRestore()
}
}
const first = read(rows)
expect(first.key).not.toBeNull()
expect(first.blockReads).toBeGreaterThanOrEqual(callCount)
expect(first.blockReads).toBeLessThanOrEqual(2 * callCount)
expect(first.filterChecks).toBe(callCount)
expect(first.serializations).toBeLessThanOrEqual(callCount + 1)
expect(first.serializedBytes).toBeGreaterThan(callCount * titleLength * 2)
expect(first.serializedBytes).toBeLessThanOrEqual(callCount * (768 + 4 * titleLength))
for (let readback = 0; readback < 3; readback += 1) {
expect(read(rows.map((row) => ({ ...row, blocks: [...row.blocks] })))).toEqual(first)
}
const noAsync = rows.map((row) =>
message(
row.id,
row.blocks.map(() => ({
get type() {
blockReads += 1
return 'tool-call' as const
},
name: 'Bash',
input: {}
}))
)
)
expect(read(noAsync)).toEqual({
key: null,
blockReads: first.blockReads,
filterChecks: 0,
serializedBytes: 0,
serializations: 0
})
expect(read([...rows, userRow('delivered')])).toMatchObject({
key: null,
blockReads: first.blockReads,
filterChecks: 0,
serializedBytes: 0,
serializations: 0
})
expect(read(rows, true)).toMatchObject({
key: null,
filterChecks: callCount,
serializations: callCount
})
const replacement = message('new-0000', [asyncCall('call-0000', 'é'.repeat(titleLength))])
const replaced = read([...rows, userRow('delivered'), replacement])
expect(replaced.key).not.toBe(first.key)
expect(replaced.serializedBytes).toBe(read([replacement]).serializedBytes)
return first
}
const small = measure(24)
const large = measure(240)
expect(large.blockReads).toBeLessThanOrEqual(10 * small.blockReads)
expect(large.serializedBytes).toBeLessThanOrEqual(10 * small.serializedBytes + 16)
// Grow text independently of call count: the bound includes full UTF-8 question content.
const longQuestions = measure(24, 320)
expect(longQuestions.serializedBytes).toBeGreaterThan(2 * small.serializedBytes)
expect(longQuestions.serializedBytes).toBeLessThanOrEqual(10 * small.serializedBytes + 16)
})
})
+128 -21
View File
@@ -130,7 +130,7 @@ export function parseAskFromToolInput(
/** Newest question lacking its FIFO result; share live registered-parser/canonical-shape fallback so reconnects cannot drop visible asks. */
export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPrompt | null {
const scan = scanPendingAsks(messages)
return scan.blocking ?? scan.replyAsMessage
return scan.blocking ?? aggregateAsyncQuestions(scan.contributors)
}
/** A structured chat's pending question from its messages: blocking ones arrive as journal
@@ -138,12 +138,14 @@ export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPr
export function extractPendingMessageReplyAsk(
messages: readonly NativeChatMessage[]
): AskPrompt | null {
return scanPendingAsks(messages).replyAsMessage
return aggregateAsyncQuestions(scanPendingAsks(messages).contributors)
}
/** A user row the agent received; a queued, unsent or withdrawn send answers nothing yet. */
function reachedAgent(message: NativeChatMessage): boolean {
/** Delivered human input only; agent notices and pending sends answer no async question. */
export function isDeliveredNativeChatUserMessage(message: NativeChatMessage): boolean {
return (
message.role === 'user' &&
message.from?.kind !== 'agent' &&
message.queued !== true &&
message.unsent !== true &&
message.stoppedBeforeStart !== true &&
@@ -152,28 +154,117 @@ function reachedAgent(message: NativeChatMessage): boolean {
)
}
function scanPendingAsks(messages: readonly NativeChatMessage[]): {
export type AsyncQuestionContributor = {
sourceRowId: string
/** Parsed async-call slot before dedupe, independent of unrelated blocks and question text. */
callSlot: number
timestamp: number | null
questions: AskQuestion[]
responseMode: 'message'
}
export type AsyncQuestionSourceScope =
| { kind: 'journal'; agent: string | null; sessionId: string | null; epoch: string | null }
| {
kind: 'transcript'
agent: string | null
sessionId: string | null
transcriptPath: string | null
}
/** Shared source fields only; host/view routing still owns each local dismissal cache. */
export function asyncQuestionSourceScope(source: AsyncQuestionSourceScope): string {
return source.kind === 'journal'
? JSON.stringify(['journal', source.agent, source.sessionId, source.epoch])
: JSON.stringify(['transcript', source.agent, source.sessionId, source.transcriptPath])
}
// Canonical rows survive provider renumbering; definition edits do not create a new occurrence.
export function asyncQuestionOccurrenceId(
sourceScope: string,
contributor: AsyncQuestionContributor
): string {
return JSON.stringify([
'async-question',
sourceScope,
contributor.sourceRowId,
contributor.callSlot
])
}
/** Filter addressed calls before grouping; one call's complete definition stays together. */
export function aggregateAsyncQuestions(
contributors: readonly AsyncQuestionContributor[],
sourceScope?: string,
hiddenIds?: ReadonlySet<string>
): AskPrompt | null {
const questions: AskQuestion[] = []
const occurrenceIds: string[] = []
for (const contributor of contributors) {
const id =
sourceScope === undefined ? undefined : asyncQuestionOccurrenceId(sourceScope, contributor)
if (id !== undefined && hiddenIds?.has(id)) {
continue
}
if (id !== undefined) {
occurrenceIds.push(id)
}
for (const question of contributor.questions) {
questions.push(question)
}
}
return questions.length > 0
? { questions, replyAsMessage: true, ...(sourceScope === undefined ? {} : { occurrenceIds }) }
: null
}
export type PendingAskScan = {
blocking: AskPrompt | null
replyAsMessage: AskPrompt | null
contributors: AsyncQuestionContributor[]
}
export function scanPendingAsks(messages: readonly NativeChatMessage[]): PendingAskScan {
const scanner = createPendingAskScanner()
for (const message of messages) {
scanner.append(message)
}
return scanner.current()
}
/** One source pass; current borrows the pending array without copying definitions on every append. */
export function createPendingAskScanner(): {
append: (message: NativeChatMessage) => readonly AsyncQuestionContributor[]
current: () => PendingAskScan
interrupt: () => void
} {
let pending: AskPrompt | null = null
// Two counters track pending’s FIFO depth without retaining a queue entry per call.
let outstanding = 0
let pendingDepth = -1
// Async questions await the person's message, not an orchestration notice from another agent.
let asyncQuestions: AskQuestion[] = []
let contributors: AsyncQuestionContributor[] = []
const asyncSlots = new Map<string, number>()
const asyncCallIds = new Set<string>()
for (const message of messages) {
const resetBlocking = (): void => {
outstanding = 0
pendingDepth = -1
pending = null
}
const interrupt = (): void => {
resetBlocking()
contributors = []
asyncCallIds.clear()
}
const append = (message: NativeChatMessage): readonly AsyncQuestionContributor[] => {
const accepted: AsyncQuestionContributor[] = []
// New user turns/interrupts abandon unresolved calls. Decoding drops tool_use_id, so orphan results cannot match by id;
// reset the FIFO or one orphan strands later answered asks forever (#11761). Claude tool results decode as role tool.
const interrupted = isInterruptedStatusMessage(message)
if (message.role === 'user' || interrupted) {
outstanding = 0
pendingDepth = -1
pending = null
if (interrupted || (reachedAgent(message) && message.from?.kind !== 'agent')) {
asyncQuestions = []
asyncCallIds.clear()
if (interrupted || isDeliveredNativeChatUserMessage(message)) {
interrupt()
} else {
resetBlocking()
}
}
for (const block of message.blocks) {
@@ -182,8 +273,19 @@ function scanPendingAsks(messages: readonly NativeChatMessage[]): {
? parseAskFromToolInput(block.name, block.input)
: parseToolInput(block.name, block.input)
if (parsed?.replyAsMessage) {
// A duplicate outside the loaded window must not renumber this row's surviving siblings.
const callSlot = asyncSlots.get(message.id) ?? 0
asyncSlots.set(message.id, callSlot + 1)
if (block.callId === undefined || !asyncCallIds.has(block.callId)) {
asyncQuestions = [...asyncQuestions, ...parsed.questions]
const contributor: AsyncQuestionContributor = {
sourceRowId: message.id,
callSlot,
timestamp: message.timestamp,
questions: parsed.questions,
responseMode: 'message'
}
contributors.push(contributor)
accepted.push(contributor)
}
if (block.callId !== undefined) {
asyncCallIds.add(block.callId)
@@ -203,12 +305,9 @@ function scanPendingAsks(messages: readonly NativeChatMessage[]): {
}
}
}
return accepted
}
return {
blocking: pending,
replyAsMessage:
asyncQuestions.length > 0 ? { questions: asyncQuestions, replyAsMessage: true } : null
}
return { append, interrupt, current: () => ({ blocking: pending, contributors }) }
}
/** Prefers live status and consults transcript history only after its read settles. */
@@ -216,6 +315,14 @@ export function resolveNativeChatAsk(args: {
liveAsk: AskPrompt | null
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
transcriptAsk?: AskPrompt | null
}): AskPrompt | null {
return args.liveAsk ?? (args.transcriptSettled ? extractPendingAsk(args.messages) : null)
return (
args.liveAsk ??
(args.transcriptSettled
? args.transcriptAsk === undefined
? extractPendingAsk(args.messages)
: args.transcriptAsk
: null)
)
}
+43 -4
View File
@@ -7,11 +7,16 @@ export type PromptDismissalStore<T> = {
write: (scopeKey: string, dismissal: T) => void
forget: (scopeKey: string) => void
forgetWhere: (match: (scopeKey: string) => boolean) => void
readAsync: (scopeKey: string) => ReadonlySet<string>
hideAsync: (scopeKey: string, ids: readonly string[]) => void
forgetAsync: (scopeKey: string) => void
clearForTests: () => void
}
const EMPTY_HIDDEN_IDS: ReadonlySet<string> = new Set()
export function createPromptDismissalStore<T>(): PromptDismissalStore<T> {
const dismissals = new Map<string, T>()
const dismissals = new Map<string, { prompt?: T; hidden?: ReadonlySet<string> }>()
const listeners = new Set<() => void>()
const notify = (): void => {
for (const listener of listeners) {
@@ -25,13 +30,22 @@ export function createPromptDismissalStore<T>(): PromptDismissalStore<T> {
listeners.delete(listener)
}
},
read: (scopeKey) => dismissals.get(scopeKey),
read: (scopeKey) => dismissals.get(scopeKey)?.prompt,
write: (scopeKey, dismissal) => {
setBoundedScopeCacheEntry(dismissals, scopeKey, dismissal)
setBoundedScopeCacheEntry(dismissals, scopeKey, {
...dismissals.get(scopeKey),
prompt: dismissal
})
notify()
},
forget: (scopeKey) => {
if (dismissals.delete(scopeKey)) {
const entry = dismissals.get(scopeKey)
if (entry?.prompt !== undefined) {
if (entry.hidden) {
dismissals.set(scopeKey, { hidden: entry.hidden })
} else {
dismissals.delete(scopeKey)
}
notify()
}
},
@@ -44,6 +58,31 @@ export function createPromptDismissalStore<T>(): PromptDismissalStore<T> {
notify()
}
},
readAsync: (scopeKey) => dismissals.get(scopeKey)?.hidden ?? EMPTY_HIDDEN_IDS,
hideAsync: (scopeKey, ids) => {
const entry = dismissals.get(scopeKey)
const hidden = new Set(entry?.hidden)
for (const id of ids) {
hidden.add(id)
}
if (hidden.size === (entry?.hidden?.size ?? 0)) {
return
}
setBoundedScopeCacheEntry(dismissals, scopeKey, { ...entry, hidden })
notify()
},
forgetAsync: (scopeKey) => {
const entry = dismissals.get(scopeKey)
if (!entry?.hidden) {
return
}
if (entry.prompt !== undefined) {
dismissals.set(scopeKey, { prompt: entry.prompt })
} else {
dismissals.delete(scopeKey)
}
notify()
},
clearForTests: () => dismissals.clear()
}
}