mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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}
|
||||
|
||||
+15
-3
@@ -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)}
|
||||
|
||||
+331
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user