mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(native-chat): preserve accepted command outcomes across agent startup (#31605)
* fix(native-chat): a goal set while the agent rests clears the composer Setting a goal starts the agent when none is running, which moves the session's runtime fence before the host answers. The composer dropped that successful answer as stale, so the objective and Goal chip stayed. Keep the reply for goal changes as for the other writes that may start the agent. * fix(native-chat): settle each command independently of runtime changes * fix(native-chat): scope rewind timeout feedback to its issuing pane * test: use checked native chat command fixtures * test: complete native chat presentation fixtures * fix(native-chat): admit Stop before capability discovery * test(native-chat): consolidate overlapping startup coverage * test(native-chat): preserve IME composition on goal acceptance * fix(native-chat): capture each action before async preparation * fix(native-chat): preserve shared Stop failure reporting * test(native-chat): cover already-sent queue notices after disposal
This commit is contained in:
@@ -117,6 +117,7 @@ function composer(
|
||||
targetPtyId={null}
|
||||
agent="claude"
|
||||
structuredTransport={{
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
|
||||
@@ -94,6 +94,7 @@ const RESUME: CommandItem = {
|
||||
|
||||
function renderStructuredChat() {
|
||||
const transport: NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
|
||||
@@ -160,6 +160,15 @@ vi.mock('./use-native-chat-send-lifecycle', () => ({
|
||||
import { NativeChatComposer } from './NativeChatComposer'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
|
||||
function createOptionsSurface(): SessionOptionsSurface {
|
||||
return {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('NativeChatComposer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -288,12 +297,7 @@ describe('NativeChatComposer', () => {
|
||||
accepted: false,
|
||||
error: null
|
||||
}))
|
||||
const optionsSurface = {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
} satisfies SessionOptionsSurface
|
||||
const optionsSurface = createOptionsSurface()
|
||||
const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[]
|
||||
const composer = (sessionPending = false) => (
|
||||
<NativeChatComposer
|
||||
@@ -302,6 +306,7 @@ describe('NativeChatComposer', () => {
|
||||
targetPtyId={null}
|
||||
agent="codex"
|
||||
structuredTransport={{
|
||||
capturePresentation: () => () => true,
|
||||
send,
|
||||
dispatchCommand,
|
||||
optionsSurface,
|
||||
@@ -347,14 +352,10 @@ describe('NativeChatComposer', () => {
|
||||
targetPtyId={null}
|
||||
agent={agent}
|
||||
structuredTransport={{
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
optionsSurface: createOptionsSurface(),
|
||||
optionSnapshot: [],
|
||||
onError: vi.fn(),
|
||||
runtime: 'local',
|
||||
@@ -382,18 +383,14 @@ describe('NativeChatComposer', () => {
|
||||
targetPtyId={null}
|
||||
agent="codex"
|
||||
structuredTransport={{
|
||||
capturePresentation: () => () => true,
|
||||
send,
|
||||
dispatchCommand: vi.fn(async () => ({
|
||||
handled: false,
|
||||
accepted: false,
|
||||
error: null
|
||||
})),
|
||||
optionsSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
optionsSurface: createOptionsSurface(),
|
||||
optionSnapshot: [],
|
||||
worktreeId: 'wt-1',
|
||||
onError: vi.fn(),
|
||||
|
||||
@@ -135,6 +135,7 @@ function Harness(props: Props): React.JSX.Element {
|
||||
isWorking: props.isWorking ?? false,
|
||||
pending: props.pending ?? [],
|
||||
transport: props.transport ?? {
|
||||
capturePresentation: () => () => true,
|
||||
send: () => true,
|
||||
dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
|
||||
optionsSurface,
|
||||
@@ -388,6 +389,7 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde
|
||||
sendOptions: () => options
|
||||
})
|
||||
const transport: NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: () => () => true,
|
||||
send: (text, images, request) =>
|
||||
sends.send(
|
||||
text,
|
||||
@@ -514,6 +516,7 @@ it('keeps the notice view stable while Continue invokes the latest committed tra
|
||||
const first = vi.fn(() => true)
|
||||
const latest = vi.fn(() => true)
|
||||
const transport: NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: () => () => true,
|
||||
send: first,
|
||||
dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
|
||||
optionsSurface,
|
||||
|
||||
@@ -45,6 +45,8 @@ function renderHeldQueue(
|
||||
): void {
|
||||
function HeldQueue(): React.JSX.Element {
|
||||
const owner = useStructuredAgentSessionQueuedMessages({
|
||||
admit: () => ({ target: { kind: 'local' }, write: write, mutate: async () => null }),
|
||||
capturePresentation: () => () => true,
|
||||
enabled: true,
|
||||
queuedMessages,
|
||||
queuePause: { reason: 'stopped' },
|
||||
|
||||
@@ -118,6 +118,8 @@ function renderHeldQueue(
|
||||
const mutate = vi.fn(async (..._call: [string, string, Record<string, unknown>]) => null)
|
||||
function HeldQueue(): React.JSX.Element {
|
||||
const owner = useStructuredAgentSessionQueuedMessages({
|
||||
admit: () => null,
|
||||
capturePresentation: () => () => true,
|
||||
enabled: true,
|
||||
queuedMessages,
|
||||
queuePause,
|
||||
|
||||
@@ -70,6 +70,7 @@ function heldTransport(hostCommand = false): {
|
||||
})
|
||||
return {
|
||||
transport: {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(() => dispatched),
|
||||
optionsSurface: {
|
||||
|
||||
@@ -90,6 +90,7 @@ function renderComposer(prompts: string[]): HTMLElement {
|
||||
targetPtyId={null}
|
||||
agent="codex"
|
||||
structuredTransport={{
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CaptureStructuredAgentSessionPresentation } from './use-structured-agent-session-mutate'
|
||||
import type { StructuredAgentSessionSendRequest } from './structured-agent-session-send-slot'
|
||||
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
|
||||
import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command'
|
||||
@@ -33,6 +34,7 @@ export type NativeChatOptionPickerRequest = {
|
||||
}
|
||||
|
||||
export type NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: CaptureStructuredAgentSessionPresentation
|
||||
conversationCommands?: readonly AgentSessionConversationCommand[]
|
||||
send: (
|
||||
text: string,
|
||||
|
||||
@@ -124,6 +124,7 @@ function fileReferences(document: JSONContent | undefined): string[] {
|
||||
|
||||
function transport(send: NativeChatStructuredComposerTransport['send']) {
|
||||
const value: NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: () => () => true,
|
||||
send,
|
||||
dispatchCommand: async () => ({ handled: false, accepted: false, error: null }),
|
||||
optionsSurface: {
|
||||
|
||||
+14
-6
@@ -93,6 +93,7 @@ function transport(
|
||||
overrides: Partial<NativeChatStructuredComposerTransport> = {}
|
||||
): NativeChatStructuredComposerTransport {
|
||||
return {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => PASS_THROUGH),
|
||||
optionsSurface: {
|
||||
@@ -148,26 +149,33 @@ afterEach(() => cleanup())
|
||||
describe('structured send racing the next IME composition', () => {
|
||||
// The regression: the RPC's clear lands while the NEXT composition is live, so the DOM sync
|
||||
// drops it and settlement used to adopt the sent text back into the composer (#17359).
|
||||
it('does not resurrect the sent message when the clear lands mid-composition', async () => {
|
||||
it('preserves the next composition when a typed goal is accepted', async () => {
|
||||
const dispatch = deferred()
|
||||
const structured = transport({ dispatchCommand: vi.fn(() => dispatch.promise) })
|
||||
const structured = transport({
|
||||
dispatchCommand: vi.fn(async () => PASS_THROUGH).mockReturnValueOnce(dispatch.promise),
|
||||
threadGoal: { setObjective: vi.fn() }
|
||||
})
|
||||
renderComposer(structured)
|
||||
const pane = `tab-${paneCounter}:structured`
|
||||
const input = textarea()
|
||||
|
||||
changePrompt(input, '안녕')
|
||||
changePrompt(input, '/goal 안녕')
|
||||
pressEnter(input)
|
||||
expect(structured.dispatchCommand).toHaveBeenCalledWith('안녕')
|
||||
expect(structured.dispatchCommand).toHaveBeenCalledWith('/goal 안녕')
|
||||
|
||||
fireEvent.compositionStart(input)
|
||||
changePrompt(input, '안녕하')
|
||||
changePrompt(input, '/goal 안녕하')
|
||||
|
||||
await act(async () => {
|
||||
dispatch.resolve(PASS_THROUGH)
|
||||
dispatch.resolve({ handled: true, accepted: true, error: null })
|
||||
await dispatch.promise
|
||||
})
|
||||
expect(promptValue(input)).toBe('/goal 안녕하')
|
||||
expect(structured.send).not.toHaveBeenCalled()
|
||||
fireEvent.compositionEnd(input, { data: '하' })
|
||||
|
||||
expect(promptValue(input)).toBe('하')
|
||||
expect(readNativeChatDraftCache(pane)).toBe('하')
|
||||
|
||||
await act(async () => pressEnter(input))
|
||||
expect(structured.send).toHaveBeenLastCalledWith('하', [])
|
||||
|
||||
@@ -82,6 +82,7 @@ function transport(
|
||||
setOption: () => Promise<boolean>
|
||||
): NativeChatStructuredComposerTransport {
|
||||
return {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: (text) =>
|
||||
dispatchStructuredAgentSessionComposerCommand(text, {
|
||||
@@ -395,6 +396,7 @@ it('reveals a structured prompt answer at the press, before the host accepts it'
|
||||
const transcript = useReviewTranscript()
|
||||
const submits = useStructuredNativeChatSubmitReveal(
|
||||
{
|
||||
capturePresentation: () => () => true,
|
||||
respond: async () => host.promise,
|
||||
queuedMessages: {
|
||||
queueCapable: true,
|
||||
|
||||
+22
-21
@@ -1,4 +1,3 @@
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import {
|
||||
supportsStructuredAgentSessionQuestionAnswers,
|
||||
supportsStructuredAgentSessionPromptCancel
|
||||
@@ -9,55 +8,57 @@ import {
|
||||
} from '../../../../shared/agent-session-question-answer'
|
||||
import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire'
|
||||
import type { StructuredPromptItem } from './structured-agent-session-message-projection'
|
||||
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
|
||||
import type { StructuredAgentSessionAction } from './use-structured-agent-session-mutate'
|
||||
|
||||
export async function respondToStructuredAgentSessionPrompt({
|
||||
item,
|
||||
response,
|
||||
target,
|
||||
mutate
|
||||
action
|
||||
}: {
|
||||
item: StructuredPromptItem
|
||||
response: AgentSessionPromptResponse
|
||||
target: RuntimeClientTarget
|
||||
mutate: StructuredAgentSessionMutate
|
||||
action: StructuredAgentSessionAction | null
|
||||
}): Promise<AgentSessionPromptResult | null> {
|
||||
if (!action) {
|
||||
return null
|
||||
}
|
||||
const { body } = item
|
||||
const answers = response.kind === 'answers' ? response.answers : []
|
||||
const promptTarget = { itemId: item.itemId, expectedRevision: item.revision }
|
||||
let fields: Record<string, unknown>
|
||||
if (response.kind === 'option') {
|
||||
fields = { ...promptTarget, optionId: response.optionId }
|
||||
} else if (await supportsStructuredAgentSessionQuestionAnswers(target)) {
|
||||
} else if (await supportsStructuredAgentSessionQuestionAnswers(action.target)) {
|
||||
// Negotiated before mutate fingerprints the call: older hosts reject the strict field.
|
||||
fields = { ...promptTarget, answers: response.answers }
|
||||
fields = { ...promptTarget, answers }
|
||||
} else {
|
||||
const optionId =
|
||||
item.body.kind === 'question'
|
||||
? legacyAgentSessionSelectedOptionId(item.body, response.answers)
|
||||
: null
|
||||
body.kind === 'question' ? legacyAgentSessionSelectedOptionId(body, answers) : null
|
||||
if (optionId === null) {
|
||||
return null
|
||||
}
|
||||
fields = { ...promptTarget, optionId }
|
||||
}
|
||||
return mutate<AgentSessionPromptResult>(
|
||||
item.body.kind === 'approval'
|
||||
? 'agentSession.respondToApproval'
|
||||
: 'agentSession.respondToQuestion',
|
||||
`agentSession.respondTo:${item.body.kind}`,
|
||||
return action.mutate<AgentSessionPromptResult>(
|
||||
body.kind === 'approval' ? 'agentSession.respondToApproval' : 'agentSession.respondToQuestion',
|
||||
`agentSession.respondTo:${body.kind}`,
|
||||
fields
|
||||
)
|
||||
}
|
||||
|
||||
export async function cancelStructuredAgentSessionPrompt(
|
||||
target: RuntimeClientTarget,
|
||||
mutate: StructuredAgentSessionMutate,
|
||||
action: StructuredAgentSessionAction | null,
|
||||
turnId: string | undefined,
|
||||
prompt?: { itemId: string; expectedRevision: number }
|
||||
) {
|
||||
if (!action) {
|
||||
return null
|
||||
}
|
||||
const promptTarget = prompt && { ...prompt }
|
||||
const promptSupported =
|
||||
prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target))
|
||||
return mutate('agentSession.cancel', 'agentSession.cancel', {
|
||||
promptTarget !== undefined && (await supportsStructuredAgentSessionPromptCancel(action.target))
|
||||
return action.mutate('agentSession.cancel', 'agentSession.cancel', {
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(promptSupported ? { prompt } : {})
|
||||
...(promptSupported ? { prompt: promptTarget } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-compos
|
||||
|
||||
function transport(): NativeChatStructuredComposerTransport {
|
||||
return {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
|
||||
@@ -40,11 +40,13 @@ function harness(options: {
|
||||
imageAttachments?: NativeChatComposerImageAttachment[]
|
||||
/** The PTY lane has no structured transport at all. */
|
||||
lane?: 'pty'
|
||||
capturePresentation?: NativeChatStructuredComposerTransport['capturePresentation']
|
||||
}) {
|
||||
const onError = vi.fn()
|
||||
const onSubmitted = vi.fn()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: submit reads only threadGoal, onError and onSubmitted.
|
||||
const structuredTransport = {
|
||||
capturePresentation: options.capturePresentation ?? (() => () => true),
|
||||
onError,
|
||||
onSubmitted,
|
||||
...(options.threadGoal ? { threadGoal: options.threadGoal } : {})
|
||||
@@ -255,6 +257,26 @@ describe('composer goal mode', () => {
|
||||
expect(hook.result.current.goalMode.active).toBe(true)
|
||||
})
|
||||
|
||||
it('consumes an accepted captured goal after closure without touching local controls', async () => {
|
||||
const reply = Promise.withResolvers<boolean>()
|
||||
let current = true
|
||||
const { hook, calls, type, onError } = harness({
|
||||
draft: '/go',
|
||||
threadGoal: { setObjective: () => reply.promise },
|
||||
capturePresentation: () => () => current
|
||||
})
|
||||
act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM))
|
||||
type('Ship it', 0)
|
||||
act(() => hook.result.current.send())
|
||||
calls.setCaret.mockClear()
|
||||
hook.unmount()
|
||||
current = false
|
||||
await act(async () => reply.resolve(true))
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
expect(calls.setCaret).not.toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send while an image waits to be attached again', () => {
|
||||
const { hook, calls } = harness({
|
||||
draft: 'see the screenshot',
|
||||
|
||||
@@ -96,17 +96,20 @@ export function useNativeChatComposerSubmit(args: {
|
||||
return
|
||||
}
|
||||
structuredTransport.onSubmitted?.()
|
||||
const isCurrent = structuredTransport.capturePresentation()
|
||||
const submitted = readNativeChatComposerDraft(draftScopeKey)
|
||||
void threadGoal.setObjective(objective).then((accepted) => {
|
||||
if (!accepted) {
|
||||
return
|
||||
}
|
||||
structuredTransport.onError(null)
|
||||
if (!clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) {
|
||||
return
|
||||
}
|
||||
setCaret(0)
|
||||
setEntered(false)
|
||||
if (isCurrent()) {
|
||||
structuredTransport.onError(null)
|
||||
setCaret(0)
|
||||
setEntered(false)
|
||||
}
|
||||
})
|
||||
}, [draft, draftScopeKey, imageAttachments.length, setCaret, structuredTransport, threadGoal])
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const item = (
|
||||
function input(): Input & { send: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
sessionId: 'session',
|
||||
capturePresentation: () => () => true,
|
||||
composerScopeKey: 'pane',
|
||||
state: {
|
||||
...EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
@@ -208,6 +209,30 @@ describe('structured chat rewind', () => {
|
||||
expect(view.result.current.disabledReason).toBeNull()
|
||||
})
|
||||
|
||||
it.each([done, notDone(unknownRefusal)])(
|
||||
'returns captured content after closure for $kind',
|
||||
async (outcome) => {
|
||||
const props = input(),
|
||||
answer = deferred<typeof outcome>()
|
||||
props.send.mockReturnValue(answer.promise)
|
||||
const onMessageReturned = vi.fn(),
|
||||
view = render({ ...props, onMessageReturned })
|
||||
let request!: Promise<void>
|
||||
await act(async () => {
|
||||
request = view.result.current.request('user', async () => true)
|
||||
})
|
||||
view.unmount()
|
||||
writeNativeChatDraftCache('pane', 'new writing')
|
||||
await act(async () => {
|
||||
answer.resolve(outcome)
|
||||
await request
|
||||
})
|
||||
expect(readNativeChatDraftCache('pane')).toBe('new writing\n\ntext of user')
|
||||
expect(onMessageReturned).not.toHaveBeenCalled()
|
||||
expect(toastError).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not execute a confirmation after its pane unmounts', async () => {
|
||||
const props = input(),
|
||||
confirmation = deferred<boolean>()
|
||||
@@ -347,15 +372,23 @@ describe('structured chat rewind', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['hidden throughout', 'hidden part way'] as const)(
|
||||
'lets go silently when the pane was %s, since a hidden pane reads nothing',
|
||||
it.each(['hidden throughout', 'hidden part way', 'presentation replaced'] as const)(
|
||||
'lets go silently when the pane was %s',
|
||||
async (mode) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const props: Input = { ...input(), isVisible: mode !== 'hidden throughout' }
|
||||
let current = true
|
||||
const props: Input = {
|
||||
...input(),
|
||||
isVisible: mode !== 'hidden throughout',
|
||||
capturePresentation: () => () => current
|
||||
}
|
||||
const view = render(props)
|
||||
await act(() => view.result.current.request('user', async () => true))
|
||||
expect(view.result.current.blockedRef.current).toBe(true)
|
||||
if (mode === 'presentation replaced') {
|
||||
current = false
|
||||
}
|
||||
if (mode === 'hidden part way') {
|
||||
await act(() => vi.advanceTimersByTimeAsync(1_000))
|
||||
view.rerender({ ...props, isVisible: false })
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
nativeChatRewindTimeoutCopy,
|
||||
nativeChatRewindUnavailableCopy
|
||||
} from './native-chat-rewind-copy'
|
||||
import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate'
|
||||
import type {
|
||||
CaptureStructuredAgentSessionPresentation,
|
||||
StructuredAgentSessionWriteOutcome
|
||||
} from './use-structured-agent-session-mutate'
|
||||
import { returnMessageToComposer } from './structured-agent-session-message-hand-back'
|
||||
import { nativeChatRewindOffered } from './native-chat-rewind-eligibility'
|
||||
import {
|
||||
@@ -38,6 +41,7 @@ export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000
|
||||
|
||||
export type RewindInput = {
|
||||
sessionId: string
|
||||
capturePresentation: CaptureStructuredAgentSessionPresentation
|
||||
/** The composer the discarded message returns to, after whatever is typed there. */
|
||||
composerScopeKey?: string
|
||||
/** Called once the discarded message is back in the composer. */
|
||||
@@ -132,15 +136,13 @@ function returnTargetToComposer(input: RewindInput, itemId: string): boolean {
|
||||
|
||||
export function useNativeChatRewind(input: RewindInput) {
|
||||
const active = useRef(false)
|
||||
const latest = useRef(input)
|
||||
useLayoutEffect(() => {
|
||||
latest.current = input
|
||||
active.current = true
|
||||
return () => {
|
||||
active.current = false
|
||||
}
|
||||
}, [])
|
||||
const latest = useRef(input)
|
||||
useLayoutEffect(() => {
|
||||
latest.current = input
|
||||
}, [input])
|
||||
// Single-flight from the click on; only `sending` (after Confirm) blocks anything else.
|
||||
const inFlight = useRef(false)
|
||||
@@ -150,6 +152,7 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
sessionId: string
|
||||
epoch: string
|
||||
sequence?: number
|
||||
isCurrent: () => boolean
|
||||
} | null>(null)
|
||||
const awaitingReset =
|
||||
awaiting?.sessionId === input.sessionId &&
|
||||
@@ -159,9 +162,7 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
const hiddenWhileAwaiting = useRef(false)
|
||||
const visible = input.isVisible !== false
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) {
|
||||
hiddenWhileAwaiting.current = true
|
||||
}
|
||||
hiddenWhileAwaiting.current ||= !visible
|
||||
}, [visible])
|
||||
useEffect(() => {
|
||||
if (!awaitingReset) {
|
||||
@@ -170,12 +171,12 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
hiddenWhileAwaiting.current = latest.current.isVisible === false
|
||||
const timer = setTimeout(() => {
|
||||
setAwaiting(null)
|
||||
if (!hiddenWhileAwaiting.current) {
|
||||
if (!hiddenWhileAwaiting.current && awaiting?.isCurrent()) {
|
||||
toast.error(nativeChatRewindTimeoutCopy())
|
||||
}
|
||||
}, NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [awaitingReset])
|
||||
}, [awaiting, awaitingReset])
|
||||
const pending = sending || awaitingReset
|
||||
const disabledReason = pending ? nativeChatRewindPendingCopy() : blockedReason(input)
|
||||
const blockedRef = useRef(false)
|
||||
@@ -193,6 +194,7 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const isCurrent = captured.capturePresentation()
|
||||
const expectedEpoch = captured.state.epoch!
|
||||
inFlight.current = true
|
||||
let keepBlocked = false
|
||||
@@ -209,7 +211,12 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
cancelVariant: 'ghost'
|
||||
})
|
||||
const current = latest.current
|
||||
if (!confirmed || !active.current || current.sessionId !== captured.sessionId) {
|
||||
if (
|
||||
!confirmed ||
|
||||
!active.current ||
|
||||
!isCurrent() ||
|
||||
current.sessionId !== captured.sessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -227,17 +234,19 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
blockedRef.current = true
|
||||
setSending(true)
|
||||
const outcome = await current.send({ itemId, expectedEpoch })
|
||||
if (!active.current || latest.current.sessionId !== captured.sessionId) {
|
||||
const reason = outcome.kind === 'not-done' ? rewindFailureReason(outcome.failure) : undefined
|
||||
// Captured content belongs to the conversation even after its pane closes.
|
||||
const returned =
|
||||
(outcome.kind === 'done' || reason === 'outcome-unknown') &&
|
||||
returnTargetToComposer(captured, itemId)
|
||||
if (!active.current || !isCurrent() || latest.current.sessionId !== captured.sessionId) {
|
||||
return
|
||||
}
|
||||
const reset = latest.current.state.epoch !== expectedEpoch
|
||||
const giveBack = () => {
|
||||
if (returnTargetToComposer(captured, itemId)) {
|
||||
latest.current.onMessageReturned?.()
|
||||
}
|
||||
if (returned) {
|
||||
latest.current.onMessageReturned?.()
|
||||
}
|
||||
const reset = latest.current.state.epoch !== expectedEpoch
|
||||
if (outcome.kind === 'done') {
|
||||
giveBack()
|
||||
const caughtUp =
|
||||
reset ||
|
||||
(outcome.value.sequence !== undefined &&
|
||||
@@ -249,6 +258,7 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
: {
|
||||
sessionId: captured.sessionId,
|
||||
epoch: expectedEpoch,
|
||||
isCurrent,
|
||||
...(outcome.value.sequence !== undefined
|
||||
? { sequence: outcome.value.sequence }
|
||||
: {})
|
||||
@@ -259,11 +269,6 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
if (outcome.kind === 'dropped') {
|
||||
return
|
||||
}
|
||||
const reason = rewindFailureReason(outcome.failure)
|
||||
// A rewind that may have run must never lose the message; a duplicate draft is harmless.
|
||||
if (reason === 'outcome-unknown') {
|
||||
giveBack()
|
||||
}
|
||||
if (reset) {
|
||||
return
|
||||
}
|
||||
|
||||
+26
-2
@@ -25,7 +25,8 @@ function harness(
|
||||
threadGoal?: NativeChatStructuredComposerTransport['threadGoal'],
|
||||
onSubmitted?: () => void
|
||||
) {
|
||||
const structuredTransport = {
|
||||
const structuredTransport: NativeChatStructuredComposerTransport = {
|
||||
capturePresentation: () => () => true,
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: (text: string) =>
|
||||
dispatchStructuredAgentSessionComposerCommand(text, {
|
||||
@@ -36,12 +37,18 @@ function harness(
|
||||
conversationCommands: ['clear', 'compact'],
|
||||
runConversationCommand: async () => ({ accepted: true, error: null })
|
||||
}),
|
||||
optionsSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
optionSnapshot: [],
|
||||
onError: vi.fn(),
|
||||
runtime: 'local',
|
||||
sessionId: 'session-test',
|
||||
runtimeEnvironmentId: null
|
||||
} as unknown as NativeChatStructuredComposerTransport
|
||||
}
|
||||
if (threadGoal) {
|
||||
structuredTransport.threadGoal = threadGoal
|
||||
}
|
||||
@@ -201,6 +208,23 @@ describe("says a failed send in Orca's words, with the error it hit apart", () =
|
||||
})
|
||||
})
|
||||
|
||||
it('settles captured command text without publishing obsolete notices', async () => {
|
||||
const { send, structuredTransport } = harness('codex', { setObjective: async () => true })
|
||||
const reply =
|
||||
Promise.withResolvers<Awaited<ReturnType<typeof structuredTransport.dispatchCommand>>>()
|
||||
let current = true
|
||||
structuredTransport.capturePresentation = () => () => current
|
||||
structuredTransport.dispatchCommand = () => reply.promise
|
||||
updateNativeChatComposerDraft('tab-1:pane', { text: '/goal ship it' }, 'immediate')
|
||||
const pending = send('/goal ship it', [])
|
||||
current = false
|
||||
updateNativeChatComposerDraft('tab-1:pane', { text: '/goal ship it next' }, 'immediate')
|
||||
reply.resolve({ handled: true, accepted: true, error: null })
|
||||
await pending
|
||||
expect(readNativeChatComposerDraft('tab-1:pane').text).toBe(' next')
|
||||
expect(structuredTransport.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks both the picked command and its draft, and projects completed paths', async () => {
|
||||
const { send, structuredTransport } = harness('claude')
|
||||
const id = '00000000-0000-4000-8000-000000000000'
|
||||
|
||||
+20
-2
@@ -107,6 +107,7 @@ export function useNativeChatStructuredComposerSend({
|
||||
if (hostCommand && structuredAgentSessionCommandChangesConversation(text)) {
|
||||
structuredTransport.onSubmitted?.()
|
||||
}
|
||||
const isCurrent = structuredTransport.capturePresentation()
|
||||
const submitted = readNativeChatComposerDraft(draftScopeKey)
|
||||
await dispatchNativeChatStructuredComposerText(
|
||||
structuredTransport,
|
||||
@@ -115,11 +116,17 @@ export function useNativeChatStructuredComposerSend({
|
||||
promptSkillRefs(submitted.document)
|
||||
)
|
||||
.then(({ accepted, error, refusedWhile, revealsTranscript }) => {
|
||||
structuredTransport.onError(error, refusedWhile ? { refusedWhile } : undefined)
|
||||
if (!hostCommand || isCurrent()) {
|
||||
structuredTransport.onError(error, refusedWhile ? { refusedWhile } : undefined)
|
||||
}
|
||||
if (!accepted) {
|
||||
return
|
||||
}
|
||||
completeNativeChatStructuredUserAction(agent, structuredTransport, revealsTranscript)
|
||||
completeNativeChatStructuredUserAction(
|
||||
agent,
|
||||
structuredTransport,
|
||||
revealsTranscript && (!hostCommand || isCurrent())
|
||||
)
|
||||
// Why: the send settles after a round trip, while this or another composer of the same
|
||||
// conversation may have changed the draft; only what was sent leaves it.
|
||||
const left = nativeChatComposerDraftLeftAfterSend(
|
||||
@@ -129,6 +136,14 @@ export function useNativeChatStructuredComposerSend({
|
||||
if (!left) {
|
||||
return
|
||||
}
|
||||
if (hostCommand && !isCurrent()) {
|
||||
updateNativeChatComposerDraft(
|
||||
draftScopeKey,
|
||||
{ text: left.text, document: undefined, images: left.images },
|
||||
'immediate'
|
||||
)
|
||||
return
|
||||
}
|
||||
updateNativeChatComposerDraft(draftScopeKey, { images: left.images }, 'immediate')
|
||||
// A live composition owns the field, which keeps only what it composed once cleared.
|
||||
const composing = isComposing()
|
||||
@@ -137,6 +152,9 @@ export function useNativeChatStructuredComposerSend({
|
||||
clearSkillOrigin()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (hostCommand && !isCurrent()) {
|
||||
return
|
||||
}
|
||||
const notice = nativeChatNoticeFromError(
|
||||
error,
|
||||
error instanceof SkillRefLimitError
|
||||
|
||||
+1
@@ -66,6 +66,7 @@ export function useNativeChatStructuredComposerTransport(args: {
|
||||
: Promise.resolve(false)
|
||||
: null
|
||||
return {
|
||||
capturePresentation: controller.capturePresentation,
|
||||
send: (text, attachments, ...request) =>
|
||||
sendBlockedReason === null &&
|
||||
sendThroughLaunch(text, attachments.length > 0, () =>
|
||||
|
||||
+17
-65
@@ -236,27 +236,13 @@ describe('a conversation command whose reply lands after the fence moved', () =>
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a reply the pane stopped waiting on: it left the chat and came back', async () => {
|
||||
const answer = heldWrites()
|
||||
const { result, rerender } = renderPane()
|
||||
let sent: Promise<{ accepted: boolean; error: string | null }> = Promise.resolve({
|
||||
accepted: true,
|
||||
error: null
|
||||
})
|
||||
act(() => {
|
||||
sent = result.current.runConversationCommand('clear')
|
||||
})
|
||||
fence = 5
|
||||
rerender({ sessionId: 'session-2', transportEnabled: true })
|
||||
rerender({ sessionId: 'session-1', transportEnabled: true })
|
||||
await act(async () => {
|
||||
answer(commandReply('clear', NOT_SIGNED_IN))
|
||||
await sent
|
||||
})
|
||||
expect(await sent).toEqual({ accepted: false, error: null })
|
||||
})
|
||||
|
||||
it('drops a reply the pane stopped waiting on: a newer command replaced it', async () => {
|
||||
it.each([
|
||||
['agentSession.threadGoal', 'agentSession.threadGoal'],
|
||||
['agentSession.rewind', 'agentSession.threadGoal'],
|
||||
['agentSession.conversationCommand', 'agentSession.threadGoal'],
|
||||
['agentSession.setOption', 'agentSession.threadGoal'],
|
||||
['agentSession.conversationCommand', 'agentSession.conversationCommand']
|
||||
])('settles %s independently when %s overlaps startup', async (earlier, later) => {
|
||||
const answers: ((reply: unknown) => void)[] = []
|
||||
mocks.call.mockImplementation(
|
||||
() =>
|
||||
@@ -272,69 +258,34 @@ describe('a conversation command whose reply lands after the fence moved', () =>
|
||||
stateRef
|
||||
})
|
||||
)
|
||||
const command = (): Promise<unknown> =>
|
||||
result.current.write('agentSession.conversationCommand', 'agentSession.conversationCommand', {
|
||||
command: 'clear'
|
||||
})
|
||||
const older = command()
|
||||
const newer = command()
|
||||
const command = (name: string): Promise<unknown> => result.current.write(name, name, {})
|
||||
const older = command(earlier)
|
||||
const newer = command(later)
|
||||
stateRef.current.fence = 5
|
||||
await act(async () => {
|
||||
answers[0](commandReply('clear', NOT_SIGNED_IN))
|
||||
answers[1](commandReply('clear'))
|
||||
await Promise.all([older, newer])
|
||||
})
|
||||
expect(await older).toEqual({ kind: 'dropped' })
|
||||
expect(await older).toEqual({ kind: 'done', value: commandReply('clear', NOT_SIGNED_IN).value })
|
||||
expect(await newer).toEqual({ kind: 'done', value: commandReply('clear').value })
|
||||
})
|
||||
|
||||
it('drops a reply once the pane has moved to another chat or closed', async () => {
|
||||
for (const pane of [
|
||||
{ sessionId: 'session-2', transportEnabled: true },
|
||||
{ sessionId: 'session-1', transportEnabled: false }
|
||||
]) {
|
||||
fence = 3
|
||||
expect(
|
||||
await commandAcrossFenceMove('clear', commandReply('clear', NOT_SIGNED_IN), pane)
|
||||
).toEqual({ accepted: false, error: null })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('any other write across a fence move', () => {
|
||||
it.each([
|
||||
['a host without the quiet repeated Stop', false],
|
||||
['a host with it', true]
|
||||
])(
|
||||
'drops the result: it answered for the runtime this pane replaced (%s)',
|
||||
async (_host, quiet) => {
|
||||
mocks.quietRepeatedStop.mockResolvedValueOnce(quiet)
|
||||
const answer = heldWrites()
|
||||
const { result, rerender } = renderPane()
|
||||
let stopped: Promise<unknown> = Promise.resolve('unset')
|
||||
act(() => {
|
||||
stopped = result.current.cancel('turn-1')
|
||||
})
|
||||
fence = 5
|
||||
rerender({ sessionId: 'session-1', transportEnabled: true })
|
||||
await act(async () => {
|
||||
// The Stop reaches the host once the client knows the host's capabilities.
|
||||
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalled())
|
||||
answer({ ok: true, replayed: false, fence: 3, value: { cancelled: true } })
|
||||
await stopped
|
||||
})
|
||||
expect(await stopped).toBeNull()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a result the same fence answers', async () => {
|
||||
])('keeps the Stop result after the provider starts (%s)', async (_host, quiet) => {
|
||||
mocks.quietRepeatedStop.mockResolvedValueOnce(quiet)
|
||||
const answer = heldWrites()
|
||||
const { result } = renderPane()
|
||||
const { result, rerender } = renderPane()
|
||||
let stopped: Promise<unknown> = Promise.resolve('unset')
|
||||
act(() => {
|
||||
stopped = result.current.cancel('turn-1')
|
||||
})
|
||||
fence = 5
|
||||
rerender({ sessionId: 'session-1', transportEnabled: true })
|
||||
await act(async () => {
|
||||
// The Stop reaches the host once the client knows the host's capabilities.
|
||||
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalled())
|
||||
@@ -342,5 +293,6 @@ describe('any other write across a fence move', () => {
|
||||
await stopped
|
||||
})
|
||||
expect(await stopped).toEqual({ cancelled: true })
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,10 @@ type Answer =
|
||||
| { ok: false; refusal: { code: string; message: string } }
|
||||
|
||||
const LOST = new Error('the connection dropped before the host answered')
|
||||
const CONFLICT = {
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_conflict', message: 'The chat changed.' }
|
||||
} as const
|
||||
|
||||
/** The host's ledger: an id it already ran gets that answer again, and nothing runs. */
|
||||
function ledgerHost(run: (method: string, params: Params) => Answer) {
|
||||
@@ -203,6 +207,79 @@ function heldStops() {
|
||||
const stop = (current: ReturnType<typeof render>['current'], turnId = 'turn-1') =>
|
||||
current.mutate('agentSession.cancel', 'agentSession.cancel', { turnId })
|
||||
|
||||
it.each([
|
||||
[false, { turnId: 'turn-1' }],
|
||||
[true, { turnId: 'turn-1' }],
|
||||
[true, { turnId: 'background-tasks', scope: 'background-tasks', taskId: 'task-command' }]
|
||||
] as const)('keeps an admitted Stop after closure (quiet=%s, %j)', async (quiet, fields) => {
|
||||
const capability = Promise.withResolvers<boolean>()
|
||||
mocks.hostAnswersRepeatedStops.mockReturnValue(capability.promise)
|
||||
mocks.call.mockResolvedValue({ ok: true, value: { cancelled: true } })
|
||||
const stateRef = { current: { fence: 3 } }
|
||||
const target = { kind: 'environment', environmentId: 'host-A' } as const
|
||||
const view = renderHook(() =>
|
||||
useStructuredAgentSessionMutate({ sessionId: 'session-A', target, stateRef })
|
||||
)
|
||||
const isCurrent = view.result.current.capturePresentation()
|
||||
const pending = view.result.current.write('agentSession.cancel', 'agentSession.cancel', fields)
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
stateRef.current.fence = 4
|
||||
view.unmount()
|
||||
capability.resolve(quiet)
|
||||
expect(await pending).toEqual({ kind: 'done', value: { cancelled: true } })
|
||||
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.cancel', {
|
||||
...fields,
|
||||
envelope: {
|
||||
sessionId: 'session-A',
|
||||
clientOperationId: 'operation-1',
|
||||
expectedRuntimeFence: 3,
|
||||
payloadFingerprint: expect.any(String)
|
||||
}
|
||||
})
|
||||
expect(isCurrent()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])('reports only current host Stops (%s)', async (reportCurrent) => {
|
||||
const first = Promise.withResolvers<unknown>()
|
||||
mocks.call.mockReturnValueOnce(first.promise).mockResolvedValue({ ok: true, value: 'B' })
|
||||
const view = renderHook(
|
||||
({ environmentId }) =>
|
||||
useStructuredAgentSessionMutate({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'environment', environmentId },
|
||||
stateRef: { current: { fence: 3 } }
|
||||
}),
|
||||
{ initialProps: { environmentId: 'A' } }
|
||||
)
|
||||
const pendingA = stop(view.result.current)
|
||||
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1))
|
||||
view.rerender({ environmentId: 'B' })
|
||||
expect(await stop(view.result.current)).toBe('B')
|
||||
view.rerender({ environmentId: 'A' })
|
||||
const currentPresses = reportCurrent ? [stop(view.result.current), stop(view.result.current)] : []
|
||||
const raw = view.result.current.write('agentSession.cancel', 'agentSession.cancel', {
|
||||
turnId: 'turn-1'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.hostAnswersRepeatedStops).toHaveBeenCalledTimes(reportCurrent ? 5 : 3)
|
||||
)
|
||||
first.resolve(CONFLICT)
|
||||
expect(await Promise.all([pendingA, ...currentPresses])).toEqual(
|
||||
reportCurrent ? [null, null, null] : [null]
|
||||
)
|
||||
const outcome = await raw
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'not-done',
|
||||
notice: expect.any(String),
|
||||
failure: { kind: 'refused', code: 'agent_session_conflict' }
|
||||
})
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(reportCurrent ? 1 : 0)
|
||||
if (reportCurrent && outcome.kind === 'not-done') {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(outcome.notice)
|
||||
}
|
||||
expect(mocks.call.mock.calls.map(([target]) => target.environmentId)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
describe('a Stop pressed again, against a host without the quiet repeated Stop', () => {
|
||||
it('joins the one still in flight instead of sending a second', async () => {
|
||||
const host = heldStops()
|
||||
@@ -241,6 +318,27 @@ describe('a Stop pressed again, against a host without the quiet repeated Stop',
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps repeated raw failures truthful without reporting them', async () => {
|
||||
const failure = Promise.withResolvers<unknown>()
|
||||
mocks.call.mockReturnValue(failure.promise)
|
||||
const { current } = render()
|
||||
const writes = [
|
||||
current.write('agentSession.cancel', 'agentSession.cancel', { turnId: 'turn-1' }),
|
||||
current.write('agentSession.cancel', 'agentSession.cancel', { turnId: 'turn-1' })
|
||||
]
|
||||
await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
failure.resolve(CONFLICT)
|
||||
for (const outcome of await Promise.all(writes)) {
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'not-done',
|
||||
notice: expect.any(String),
|
||||
failure: { kind: 'refused', code: 'agent_session_conflict' }
|
||||
})
|
||||
}
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends a new Stop once the first has settled', async () => {
|
||||
const host = heldStops()
|
||||
const { current } = render()
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
// Every call is its own action with its own client operation id, so a press is never answered
|
||||
// from an earlier one. The host answers a second Stop of a named turn or task quietly; against an
|
||||
// older host that does not, this client joins one still on its way instead of stopping twice.
|
||||
// Every result is discarded unless the runtime fence it was issued against is still the current
|
||||
// one, except the reply to the conversation command or rewind a pane still waits on, since either
|
||||
// may move the fence itself. A write that did not happen is reported once, in the person's words, by
|
||||
// the caller that knows where to say it; nothing latches.
|
||||
// Command replies settle independently of provider generations; only late pane presentation expires.
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire'
|
||||
import {
|
||||
@@ -25,15 +22,20 @@ import {
|
||||
callStructuredAgentSession,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop
|
||||
} from '@/runtime/structured-agent-session-client'
|
||||
import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability'
|
||||
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
|
||||
|
||||
export type StructuredAgentSessionWriteOutcome<T> =
|
||||
| { kind: 'done'; value: T }
|
||||
/** Refused or failed, with what to tell the person and the fact it was worded from. */
|
||||
| { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure }
|
||||
/** Settled for an owner or session this pane no longer shows; there is nothing to say. */
|
||||
/** Unavailable at entry, so nothing was dispatched. */
|
||||
| { kind: 'dropped' }
|
||||
|
||||
type ExecutionResult<T> = StructuredAgentSessionWriteOutcome<T> & {
|
||||
reportFailure?: (notice: string) => void
|
||||
}
|
||||
|
||||
type WriteArgs = [method: string, fingerprintMethod: string, fields: Record<string, unknown>]
|
||||
|
||||
export type StructuredAgentSessionWrite = <T>(
|
||||
@@ -43,6 +45,16 @@ export type StructuredAgentSessionWrite = <T>(
|
||||
/** A write whose failure is shown as a toast; the control that sent it is the way to try again. */
|
||||
export type StructuredAgentSessionMutate = <T>(...args: WriteArgs) => Promise<T | null>
|
||||
|
||||
export type StructuredAgentSessionAction = {
|
||||
target: RuntimeClientTarget
|
||||
write: StructuredAgentSessionWrite
|
||||
mutate: StructuredAgentSessionMutate
|
||||
}
|
||||
export type AdmitStructuredAgentSessionAction = () => StructuredAgentSessionAction | null
|
||||
|
||||
/** Captures the issuing pane visit for late presentation, never command or draft settlement. */
|
||||
export type CaptureStructuredAgentSessionPresentation = () => () => boolean
|
||||
|
||||
/** A Stop naming its turn or task; one naming neither acts on whatever runs when the host reaches
|
||||
* it, so a second press is a Stop of its own. */
|
||||
function namesWhatItStops(fingerprintMethod: string, fields: Record<string, unknown>): boolean {
|
||||
@@ -59,7 +71,7 @@ function notDone(
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>,
|
||||
agentName: string | undefined
|
||||
): { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } {
|
||||
): ExecutionResult<never> {
|
||||
return {
|
||||
kind: 'not-done',
|
||||
notice: agentSessionWriteFailureText(
|
||||
@@ -67,7 +79,8 @@ function notDone(
|
||||
writeKind(fingerprintMethod, fields),
|
||||
agentName ? { agentName } : {}
|
||||
),
|
||||
failure
|
||||
failure,
|
||||
reportFailure: toast.error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,28 +88,30 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
sessionId: string
|
||||
target: RuntimeClientTarget
|
||||
enabled?: boolean
|
||||
/** Read at settle time, not at call time: the fence can move while a request
|
||||
* is in flight, and a result from the previous fence is not this session's. */
|
||||
/** Capture the original press fence for host admission, including older hosts. */
|
||||
stateRef: { current: { fence: number | null } }
|
||||
/** The chat's agent, which a refusal about it names. */
|
||||
agentName?: string
|
||||
}): {
|
||||
write: StructuredAgentSessionWrite
|
||||
mutate: StructuredAgentSessionMutate
|
||||
admit: AdmitStructuredAgentSessionAction
|
||||
capturePresentation: CaptureStructuredAgentSessionPresentation
|
||||
} {
|
||||
const { agentName, enabled = true, sessionId, stateRef, target } = args
|
||||
const inFlightStops = useRef(
|
||||
new Map<string, Promise<StructuredAgentSessionWriteOutcome<unknown>>>()
|
||||
)
|
||||
const enabledRef = useRef(enabled)
|
||||
// The conversation command or rewind this pane still waits on; only its reply may outlive a fence
|
||||
// move. A newer one replaces it, and disabling the pane forgets it.
|
||||
const commandInFlight = useRef<object | null>(null)
|
||||
useEffect(() => {
|
||||
// Why: update the gate after commit so render stays free of ref mutations.
|
||||
enabledRef.current = enabled
|
||||
commandInFlight.current = null
|
||||
}, [enabled, sessionId])
|
||||
const inFlightStops = useRef(new Map<string, Promise<ExecutionResult<unknown>>>())
|
||||
const presentation = useRef<object | null>(null)
|
||||
const hostKey = structuredAgentSessionHostKey(target)
|
||||
useLayoutEffect(() => {
|
||||
presentation.current = enabled ? {} : null
|
||||
return () => {
|
||||
presentation.current = null
|
||||
}
|
||||
}, [enabled, hostKey, sessionId])
|
||||
const capturePresentation = useCallback(() => {
|
||||
const visit = presentation.current
|
||||
return () => visit !== null && presentation.current === visit
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
async <T>(
|
||||
@@ -104,37 +119,15 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>,
|
||||
/** The fence when the press happened, read before any await, so a write never retargets. */
|
||||
pressedFence: number | null = stateRef.current.fence
|
||||
): Promise<StructuredAgentSessionWriteOutcome<T>> => {
|
||||
if (!enabled || !enabledRef.current || pressedFence === null) {
|
||||
return { kind: 'dropped' }
|
||||
}
|
||||
const targetFence = pressedFence
|
||||
const request = {}
|
||||
if (
|
||||
fingerprintMethod === 'agentSession.conversationCommand' ||
|
||||
fingerprintMethod === 'agentSession.rewind'
|
||||
) {
|
||||
commandInFlight.current = request
|
||||
}
|
||||
// Whether this call's answer is still for what the pane shows. Another fence's result is about
|
||||
// a runtime the pane replaced; a conversation command may itself start or stop the agent,
|
||||
// moving the fence under its own reply, so that reply is kept while this pane still waits on
|
||||
// it. Called once, when the call settles.
|
||||
const settlesHere = (): boolean => {
|
||||
const waitedOn = commandInFlight.current === request
|
||||
if (waitedOn) {
|
||||
commandInFlight.current = null
|
||||
}
|
||||
return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn)
|
||||
}
|
||||
pressedFence: number
|
||||
): Promise<ExecutionResult<T>> => {
|
||||
let result: AgentSessionMutationResult<T>
|
||||
try {
|
||||
result = await callStructuredAgentSession<AgentSessionMutationResult<T>>(target, method, {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: structuredSessionOperationId(),
|
||||
expectedRuntimeFence: targetFence,
|
||||
expectedRuntimeFence: pressedFence,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: fingerprintMethod,
|
||||
sessionId,
|
||||
@@ -144,43 +137,40 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
...fields
|
||||
})
|
||||
} catch (error) {
|
||||
return settlesHere()
|
||||
? notDone(
|
||||
agentSessionThrownFailure(
|
||||
error,
|
||||
error instanceof RuntimeRpcCallError ? error.code : undefined
|
||||
),
|
||||
fingerprintMethod,
|
||||
fields,
|
||||
agentName
|
||||
)
|
||||
: { kind: 'dropped' }
|
||||
return notDone(
|
||||
agentSessionThrownFailure(
|
||||
error,
|
||||
error instanceof RuntimeRpcCallError ? error.code : undefined
|
||||
),
|
||||
fingerprintMethod,
|
||||
fields,
|
||||
agentName
|
||||
)
|
||||
}
|
||||
if (!result.ok) {
|
||||
return settlesHere()
|
||||
? notDone(
|
||||
agentSessionRefusalFailure(result.refusal),
|
||||
fingerprintMethod,
|
||||
fields,
|
||||
agentName
|
||||
)
|
||||
: { kind: 'dropped' }
|
||||
}
|
||||
if (!settlesHere()) {
|
||||
return { kind: 'dropped' }
|
||||
return notDone(
|
||||
agentSessionRefusalFailure(result.refusal),
|
||||
fingerprintMethod,
|
||||
fields,
|
||||
agentName
|
||||
)
|
||||
}
|
||||
return { kind: 'done', value: result.value }
|
||||
},
|
||||
[agentName, enabled, sessionId, stateRef, target]
|
||||
[agentName, sessionId, target]
|
||||
)
|
||||
|
||||
const write = useCallback(
|
||||
async <T>(
|
||||
const admit = useCallback((): StructuredAgentSessionAction | null => {
|
||||
const pressedFence = stateRef.current.fence
|
||||
if (!enabled || presentation.current === null || pressedFence === null) {
|
||||
return null
|
||||
}
|
||||
const isCurrent = capturePresentation()
|
||||
const execute = async <T>(
|
||||
method: string,
|
||||
fingerprintMethod: string,
|
||||
fields: Record<string, unknown>
|
||||
): Promise<StructuredAgentSessionWriteOutcome<T>> => {
|
||||
const pressedFence = stateRef.current.fence
|
||||
): Promise<ExecutionResult<T>> => {
|
||||
if (
|
||||
!namesWhatItStops(fingerprintMethod, fields) ||
|
||||
(await supportsStructuredAgentSessionQuietRepeatedStop(target))
|
||||
@@ -188,16 +178,11 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
return send<T>(method, fingerprintMethod, fields, pressedFence)
|
||||
}
|
||||
// Temporary, for a host that predates the quiet repeated Stop: remove once none is supported.
|
||||
const key = `${sessionId}:${method}:${JSON.stringify(fields)}`
|
||||
const key = `${hostKey}:${sessionId}:${method}:${JSON.stringify(fields)}`
|
||||
const joined = inFlightStops.current.get(key)
|
||||
if (joined) {
|
||||
// The first press reports a failure; the one that joined it stays quiet.
|
||||
return joined.then((outcome) =>
|
||||
outcome.kind === 'not-done'
|
||||
? { kind: 'dropped' }
|
||||
: // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T.
|
||||
(outcome as StructuredAgentSessionWriteOutcome<T>)
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T.
|
||||
return joined as Promise<ExecutionResult<T>>
|
||||
}
|
||||
const stopping = send<T>(method, fingerprintMethod, fields, pressedFence)
|
||||
inFlightStops.current.set(key, stopping)
|
||||
@@ -208,20 +193,29 @@ export function useStructuredAgentSessionMutate(args: {
|
||||
}
|
||||
})
|
||||
return stopping
|
||||
},
|
||||
[send, sessionId, stateRef, target]
|
||||
)
|
||||
|
||||
const mutate = useCallback(
|
||||
async <T>(...writeArgs: WriteArgs): Promise<T | null> => {
|
||||
const outcome = await write<T>(...writeArgs)
|
||||
if (outcome.kind === 'not-done') {
|
||||
toast.error(outcome.notice)
|
||||
}
|
||||
const mutate: StructuredAgentSessionMutate = async <T>(...writeArgs: WriteArgs) => {
|
||||
const outcome = await execute<T>(...writeArgs)
|
||||
if (outcome.kind === 'not-done' && isCurrent()) {
|
||||
const report = outcome.reportFailure
|
||||
outcome.reportFailure = undefined
|
||||
report?.(outcome.notice)
|
||||
}
|
||||
return outcome.kind === 'done' ? outcome.value : null
|
||||
},
|
||||
[write]
|
||||
}
|
||||
return { target, write: execute, mutate }
|
||||
}, [capturePresentation, enabled, hostKey, send, sessionId, stateRef, target])
|
||||
|
||||
const write = useCallback(
|
||||
async <T>(...writeArgs: WriteArgs): Promise<StructuredAgentSessionWriteOutcome<T>> =>
|
||||
admit()?.write<T>(...writeArgs) ?? { kind: 'dropped' },
|
||||
[admit]
|
||||
)
|
||||
const mutate = useCallback(
|
||||
async <T>(...writeArgs: WriteArgs): Promise<T | null> =>
|
||||
admit()?.mutate<T>(...writeArgs) ?? null,
|
||||
[admit]
|
||||
)
|
||||
|
||||
return { write, mutate }
|
||||
return { write, mutate, admit, capturePresentation }
|
||||
}
|
||||
|
||||
+53
-17
@@ -5,20 +5,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
call: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
promptCancelSupported: vi.fn(),
|
||||
questionAnswersSupported: vi.fn(),
|
||||
operationId: vi.fn(() => 'operation-1')
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toast } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call,
|
||||
supportsStructuredAgentSessionQuietRepeatedStop: async () => true,
|
||||
supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported,
|
||||
supportsStructuredAgentSessionQuestionAnswers: mocks.questionAnswersSupported
|
||||
}))
|
||||
vi.mock('./use-structured-agent-session-read', () => ({
|
||||
useStructuredAgentSessionRead: () => ({
|
||||
state: {
|
||||
fence: 3,
|
||||
fence,
|
||||
items,
|
||||
submissions: [],
|
||||
status: 'ready',
|
||||
@@ -49,6 +52,7 @@ import {
|
||||
type StructuredPromptItem
|
||||
} from './use-structured-agent-session'
|
||||
|
||||
let fence = 3
|
||||
let items: AgentJournalRenderItem[] = []
|
||||
const target = { kind: 'local' } as const
|
||||
|
||||
@@ -99,28 +103,60 @@ function respondCall(): Record<string, unknown> | undefined {
|
||||
describe('desktop structured prompt answers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fence = 3
|
||||
items = [question, approval]
|
||||
mocks.call.mockResolvedValue({ ok: true, value: { itemId: 'question-1', revision: 3 } })
|
||||
})
|
||||
|
||||
it('sends structured answers to a host that takes them', async () => {
|
||||
mocks.questionAnswersSupported.mockResolvedValue(true)
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSession({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
agent: 'claude',
|
||||
isVisible: true
|
||||
})
|
||||
it.each(['answers', 'cancel'] as const)('owns %s through preparation', async (kind) => {
|
||||
const support = Promise.withResolvers<boolean>()
|
||||
mocks.questionAnswersSupported.mockReturnValue(support.promise)
|
||||
mocks.promptCancelSupported.mockReturnValue(support.promise)
|
||||
const original = { kind: 'environment' as const, environmentId: 'host-A' }
|
||||
const view = renderHook(
|
||||
({ target }) =>
|
||||
useStructuredAgentSession({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
agent: 'claude',
|
||||
isVisible: true
|
||||
}),
|
||||
{ initialProps: { target: original } }
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.respond(question, { kind: 'answers', answers })
|
||||
const prompt = { itemId: question.itemId, expectedRevision: question.revision }
|
||||
const pending =
|
||||
kind === 'answers'
|
||||
? view.result.current.respond(question, { kind: 'answers', answers })
|
||||
: view.result.current.cancel('turn-1', prompt)
|
||||
expect(
|
||||
mocks.call.mock.calls.filter(
|
||||
([, method]) =>
|
||||
method === (kind === 'answers' ? 'agentSession.respondToQuestion' : 'agentSession.cancel')
|
||||
)
|
||||
).toEqual([])
|
||||
fence = 4
|
||||
view.rerender({ target: { ...original, environmentId: 'host-B' } })
|
||||
view.unmount()
|
||||
if (kind === 'cancel') {
|
||||
mocks.call.mockResolvedValue({ ok: false, refusal: { code: 'agent_session_conflict' } })
|
||||
}
|
||||
support.resolve(true)
|
||||
expect(await pending).toEqual(kind === 'answers' ? { itemId: 'question-1', revision: 3 } : null)
|
||||
const calls = mocks.call.mock.calls.filter(
|
||||
([, method]) =>
|
||||
method === (kind === 'answers' ? 'agentSession.respondToQuestion' : 'agentSession.cancel')
|
||||
)
|
||||
expect(calls).toHaveLength(1)
|
||||
const [rpcTarget, method, params] = calls[0]
|
||||
expect(rpcTarget).toEqual(original)
|
||||
expect(method).toBe(
|
||||
kind === 'answers' ? 'agentSession.respondToQuestion' : 'agentSession.cancel'
|
||||
)
|
||||
expect(params).toEqual({
|
||||
envelope: expect.objectContaining({ sessionId: 'session-1', expectedRuntimeFence: 3 }),
|
||||
...(kind === 'answers' ? { ...prompt, answers } : { turnId: 'turn-1', prompt })
|
||||
})
|
||||
|
||||
expect(mocks.questionAnswersSupported).toHaveBeenCalledWith(target)
|
||||
expect(respondCall()).toMatchObject({ itemId: 'question-1', expectedRevision: 2, answers })
|
||||
expect(respondCall()).not.toHaveProperty('optionId')
|
||||
expect(mocks.toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('packs the answer into an option id for a host that predates structured answers', async () => {
|
||||
|
||||
+13
-4
@@ -63,7 +63,7 @@ function renderController(initialProps: ControllerInput = {}) {
|
||||
const stateRef = { current: { fence: 1 } }
|
||||
return renderHook(
|
||||
(input: ControllerInput) => {
|
||||
const { mutate, write } = useStructuredAgentSessionMutate({
|
||||
const { mutate, write, admit, capturePresentation } = useStructuredAgentSessionMutate({
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
stateRef
|
||||
@@ -77,6 +77,8 @@ function renderController(initialProps: ControllerInput = {}) {
|
||||
isWorking: false,
|
||||
composerScopeKey: undefined,
|
||||
mutate,
|
||||
admit,
|
||||
capturePresentation,
|
||||
editTransport: { ...NO_EDIT, write }
|
||||
})
|
||||
},
|
||||
@@ -193,12 +195,12 @@ function resumedResult(resumed = true) {
|
||||
|
||||
/** The queue controller as a pane builds it, over the real mutation. */
|
||||
function useQueue(sessionId = 'session-1', fence: number | null = 1) {
|
||||
const { mutate } = useStructuredAgentSessionMutate({
|
||||
const { mutate, admit, capturePresentation } = useStructuredAgentSessionMutate({
|
||||
sessionId,
|
||||
target: { kind: 'local' },
|
||||
stateRef: { current: { fence } }
|
||||
})
|
||||
return useStructuredAgentSessionQueuedMessages({
|
||||
const queue = useStructuredAgentSessionQueuedMessages({
|
||||
enabled: true,
|
||||
queuedMessages: [card('held')],
|
||||
queuePause: { reason: 'stopped' },
|
||||
@@ -207,8 +209,11 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) {
|
||||
isWorking: false,
|
||||
composerScopeKey: undefined,
|
||||
mutate,
|
||||
admit,
|
||||
capturePresentation,
|
||||
editTransport: NO_EDIT
|
||||
})
|
||||
return { ...queue, capturePresentation }
|
||||
}
|
||||
|
||||
function renderRevealingController(sessionId = 'session-1', fence: number | null = 1) {
|
||||
@@ -216,7 +221,11 @@ function renderRevealingController(sessionId = 'session-1', fence: number | null
|
||||
({ isVisible }) => {
|
||||
const queue = useQueue(sessionId, fence)
|
||||
const submits = useStructuredNativeChatSubmitReveal(
|
||||
{ queuedMessages: queue, respond: async () => null },
|
||||
{
|
||||
queuedMessages: queue,
|
||||
respond: async () => null,
|
||||
capturePresentation: queue.capturePresentation
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
const scrollRef = useRef(document.createElement('div'))
|
||||
|
||||
+84
-19
@@ -3,11 +3,17 @@
|
||||
// The queued-message controller: which RPC each card action issues, and that a host which cannot
|
||||
// edit in place gets no Edit at all, never the old copy-and-delete.
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionQueuedMessage,
|
||||
AgentSessionQueuedMessageDeleteResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
|
||||
|
||||
const rpc = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
@@ -16,6 +22,7 @@ import {
|
||||
readNativeChatDraftCache
|
||||
} from './native-chat-draft-cache'
|
||||
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
|
||||
import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
|
||||
import type {
|
||||
StructuredAgentSessionMutate,
|
||||
StructuredAgentSessionWrite
|
||||
@@ -40,24 +47,25 @@ function createHarness(
|
||||
enabled?: boolean
|
||||
composerScopeKey?: string | undefined
|
||||
mutateResult?: (call: MutateCall) => unknown
|
||||
clearPending?: Promise<void>
|
||||
sending?: StructuredAgentSessionPendingSend[]
|
||||
} = {}
|
||||
) {
|
||||
const mutateCalls: MutateCall[] = []
|
||||
const writeCalls: MutateCall[] = []
|
||||
const clearWrite: StructuredAgentSessionWrite = async <T,>(...call: MutateCall) => {
|
||||
writeCalls.push(call)
|
||||
await overrides.clearPending
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Clear calls this test writer only for queuedMessageDelete.
|
||||
return { kind: 'done', value: { deleted: true, messageId: call[2].messageId } as T }
|
||||
}
|
||||
const mutate = vi.fn(async (...call: MutateCall) => {
|
||||
mutateCalls.push(call)
|
||||
return overrides.mutateResult ? overrides.mutateResult(call) : null
|
||||
})
|
||||
const mutation = renderHook(() =>
|
||||
useStructuredAgentSessionMutate({
|
||||
sessionId: 'session',
|
||||
target: { kind: 'local' },
|
||||
stateRef: { current: { fence: 3 } }
|
||||
})
|
||||
)
|
||||
const rendered = renderHook(() =>
|
||||
useStructuredAgentSessionQueuedMessages({
|
||||
capturePresentation: mutation.result.current.capturePresentation,
|
||||
admit: mutation.result.current.admit,
|
||||
enabled: overrides.enabled ?? true,
|
||||
queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)],
|
||||
queuePause: null,
|
||||
@@ -72,11 +80,11 @@ function createHarness(
|
||||
target: { kind: 'local' },
|
||||
sessionId: 'session',
|
||||
capable: false,
|
||||
write: clearWrite
|
||||
write: mutation.result.current.write
|
||||
}
|
||||
})
|
||||
)
|
||||
return { ...rendered, mutate, mutateCalls, writeCalls, clearWrite }
|
||||
return { ...rendered, mutate, mutateCalls, dispose: mutation.unmount }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -88,11 +96,36 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('queued message actions', () => {
|
||||
it('Clear deletes eight at a time and a repeated Clear sends nothing more', async () => {
|
||||
const clearOutcomes = [
|
||||
'success',
|
||||
'current',
|
||||
'obsolete',
|
||||
'dispatched-current',
|
||||
'dispatched-obsolete'
|
||||
]
|
||||
it.each(clearOutcomes)('Clear owns ten deletes: %s', async (outcome) => {
|
||||
const ids = Array.from({ length: 10 }, (_, index) => `draft-${index + 1}`)
|
||||
const release = Promise.withResolvers<void>()
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
rpc.mockImplementation(async (_target, _method, params) => {
|
||||
maximum = Math.max(maximum, ++active)
|
||||
await release.promise
|
||||
active -= 1
|
||||
if (outcome === 'success' || params.messageId !== ids[9]) {
|
||||
return { ok: true, value: { deleted: true, messageId: params.messageId } }
|
||||
}
|
||||
return outcome.startsWith('dispatched')
|
||||
? {
|
||||
ok: true,
|
||||
value: { deleted: false, messageId: params.messageId, disposition: 'dispatched' }
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_conflict', message: 'The session moved on.' }
|
||||
}
|
||||
})
|
||||
const harness = createHarness({
|
||||
clearPending: release.promise,
|
||||
queuedMessages: ids.map((id, index) => draft(id, index + 1))
|
||||
})
|
||||
let pending: Promise<boolean> = Promise.resolve(false)
|
||||
@@ -102,12 +135,26 @@ describe('queued message actions', () => {
|
||||
expect(await harness.result.current.resume()).toBe(false)
|
||||
})
|
||||
expect(harness.mutateCalls).toEqual([])
|
||||
expect(harness.writeCalls.map(([, , fields]) => fields.messageId)).toEqual(ids.slice(0, 8))
|
||||
expect(rpc.mock.calls.map(([, , fields]) => fields.messageId)).toEqual(ids.slice(0, 8))
|
||||
expect(harness.result.current.queuePending).toBe('clear')
|
||||
if (outcome.endsWith('obsolete')) {
|
||||
harness.dispose()
|
||||
}
|
||||
release.resolve()
|
||||
await act(async () => expect(await pending).toBe(true))
|
||||
expect(harness.writeCalls).toHaveLength(ids.length)
|
||||
await act(async () => expect(await pending).toBe(outcome === 'success'))
|
||||
expect(rpc.mock.calls.map(([, , fields]) => fields.messageId)).toEqual(ids)
|
||||
expect(maximum).toBe(8)
|
||||
for (const [target, , fields] of rpc.mock.calls) {
|
||||
expect(target).toEqual({ kind: 'local' })
|
||||
expect(fields.envelope).toMatchObject({ sessionId: 'session', expectedRuntimeFence: 3 })
|
||||
}
|
||||
expect(ids.every((id) => harness.result.current.pending(id) === null)).toBe(true)
|
||||
expect(toast.error).toHaveBeenCalledTimes(outcome.endsWith('current') ? 1 : 0)
|
||||
if (outcome === 'dispatched-current') {
|
||||
expect(toast.error).toHaveBeenCalledWith('This message was already sent.')
|
||||
}
|
||||
expect(harness.result.current.queuePending).toBeNull()
|
||||
expect(harness.result.current.clearCapable).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps Clear all unavailable while a local send has no host card', async () => {
|
||||
@@ -154,14 +201,32 @@ describe('queued message actions', () => {
|
||||
expect(disabled.result.current.steerNewest()).toBe(false)
|
||||
})
|
||||
|
||||
it('Delete withdraws through queuedMessageDelete without touching the composer', async () => {
|
||||
const harness = createHarness()
|
||||
await act(() => harness.result.current.remove('draft-1'))
|
||||
it.each(['current', 'obsolete'])('Delete already sent: %s', async (presentation) => {
|
||||
const answer = Promise.withResolvers<AgentSessionQueuedMessageDeleteResult>()
|
||||
const harness = createHarness({ mutateResult: () => answer.promise })
|
||||
let pending: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
pending = harness.result.current.remove('draft-1')
|
||||
})
|
||||
expect(harness.result.current.pending('draft-1')).toBe('delete')
|
||||
expect(harness.result.current.clearCapable).toBe(false)
|
||||
expect(harness.mutate).toHaveBeenCalledWith(
|
||||
'agentSession.queuedMessageDelete',
|
||||
'agentSession.queuedMessageDelete',
|
||||
{ messageId: 'draft-1' }
|
||||
)
|
||||
if (presentation === 'obsolete') {
|
||||
harness.dispose()
|
||||
}
|
||||
await act(async () => {
|
||||
answer.resolve({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' })
|
||||
await pending
|
||||
})
|
||||
expect(vi.mocked(toast.error).mock.calls).toEqual(
|
||||
presentation === 'current' ? [['This message was already sent.']] : []
|
||||
)
|
||||
expect(harness.result.current.pending('draft-1')).toBeNull()
|
||||
expect(harness.result.current.clearCapable).toBe(true)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
})
|
||||
|
||||
|
||||
+20
-8
@@ -28,7 +28,11 @@ import {
|
||||
sendingQueuedMessageCards,
|
||||
type QueuedMessageCard
|
||||
} from './structured-agent-session-queued-cards'
|
||||
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
|
||||
import type {
|
||||
AdmitStructuredAgentSessionAction,
|
||||
CaptureStructuredAgentSessionPresentation,
|
||||
StructuredAgentSessionMutate
|
||||
} from './use-structured-agent-session-mutate'
|
||||
|
||||
export type StructuredAgentSessionQueuedMessagesController = {
|
||||
cards: QueuedMessageCard[]
|
||||
@@ -90,10 +94,12 @@ export function useStructuredAgentSessionQueuedMessages(args: {
|
||||
sending?: readonly StructuredAgentSessionPendingSend[]
|
||||
composerScopeKey: string | undefined
|
||||
mutate: StructuredAgentSessionMutate
|
||||
admit: AdmitStructuredAgentSessionAction
|
||||
capturePresentation: CaptureStructuredAgentSessionPresentation
|
||||
editTransport: QueuedEditTransport
|
||||
}): StructuredAgentSessionQueuedMessagesController {
|
||||
const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args
|
||||
const { isWorking, queuePause } = args
|
||||
const { admit, capturePresentation, isWorking, queuePause } = args
|
||||
const sending = args.sending ?? NO_SENDS
|
||||
|
||||
const cards = useMemo(
|
||||
@@ -162,18 +168,19 @@ export function useStructuredAgentSessionQueuedMessages(args: {
|
||||
|
||||
const remove = useCallback(
|
||||
async (messageId: string): Promise<void> => {
|
||||
const isCurrent = capturePresentation()
|
||||
await run([messageId], 'delete', async () => {
|
||||
const result = await mutate<AgentSessionQueuedMessageDeleteResult>(
|
||||
'agentSession.queuedMessageDelete',
|
||||
'agentSession.queuedMessageDelete',
|
||||
{ messageId }
|
||||
)
|
||||
if (result && !result.deleted && result.disposition === 'dispatched') {
|
||||
if (isCurrent() && result && !result.deleted && result.disposition === 'dispatched') {
|
||||
alreadySentNotice()
|
||||
}
|
||||
})
|
||||
},
|
||||
[mutate, run]
|
||||
[capturePresentation, mutate, run]
|
||||
)
|
||||
|
||||
const editing = useStructuredAgentSessionQueuedEdit({
|
||||
@@ -222,13 +229,18 @@ export function useStructuredAgentSessionQueuedMessages(args: {
|
||||
if (!clearCapable || messageIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
const isCurrent = capturePresentation()
|
||||
// Refused while any of these cards is in flight; each shows busy until the whole Clear settles.
|
||||
return run([QUEUE_KEY, ...messageIds], 'clear', async () => {
|
||||
const action = admit()
|
||||
if (!action) {
|
||||
return false
|
||||
}
|
||||
const results = await mapSettledWithConcurrency(
|
||||
messageIds,
|
||||
CLEAR_DELETE_CONCURRENCY,
|
||||
(messageId) =>
|
||||
args.editTransport.write<AgentSessionQueuedMessageDeleteResult>(
|
||||
action.write<AgentSessionQueuedMessageDeleteResult>(
|
||||
'agentSession.queuedMessageDelete',
|
||||
'agentSession.queuedMessageDelete',
|
||||
{ messageId }
|
||||
@@ -244,10 +256,10 @@ export function useStructuredAgentSessionQueuedMessages(args: {
|
||||
const failure = results.find(
|
||||
(result) => result.status === 'fulfilled' && result.value.kind === 'not-done'
|
||||
)
|
||||
if (failure?.status === 'fulfilled' && failure.value.kind === 'not-done') {
|
||||
if (isCurrent() && failure?.status === 'fulfilled' && failure.value.kind === 'not-done') {
|
||||
toast.error(failure.value.notice)
|
||||
}
|
||||
if (dispatched) {
|
||||
if (isCurrent() && dispatched) {
|
||||
alreadySentNotice()
|
||||
}
|
||||
return (
|
||||
@@ -255,7 +267,7 @@ export function useStructuredAgentSessionQueuedMessages(args: {
|
||||
results.every((result) => result.status === 'fulfilled' && result.value.kind === 'done')
|
||||
)
|
||||
})
|
||||
}, [args.editTransport, clearCapable, run])
|
||||
}, [admit, capturePresentation, clearCapable, run])
|
||||
|
||||
return {
|
||||
cards,
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useStructuredAgentSessionRewind(
|
||||
) {
|
||||
const {
|
||||
blocked,
|
||||
capturePresentation,
|
||||
composerScopeKey,
|
||||
contextFloor,
|
||||
hostBlockedReason,
|
||||
@@ -34,6 +35,7 @@ export function useStructuredAgentSessionRewind(
|
||||
const input = useMemo<RewindInput>(
|
||||
() => ({
|
||||
sessionId,
|
||||
capturePresentation,
|
||||
composerScopeKey,
|
||||
contextFloor,
|
||||
onMessageReturned,
|
||||
@@ -47,6 +49,7 @@ export function useStructuredAgentSessionRewind(
|
||||
}),
|
||||
[
|
||||
blocked,
|
||||
capturePresentation,
|
||||
composerScopeKey,
|
||||
contextFloor,
|
||||
hostBlockedReason,
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('useStructuredAgentSession options', () => {
|
||||
).toEqual([3, 4])
|
||||
})
|
||||
|
||||
it('ignores an option failure from a superseded fence', async () => {
|
||||
it('reports an option failure across startup while its pane remains current', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
const pending = new Promise<never>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise
|
||||
@@ -420,7 +420,7 @@ describe('useStructuredAgentSession options', () => {
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('includes one background task id in the cancel fingerprint and payload', async () => {
|
||||
|
||||
@@ -74,9 +74,7 @@ export function useStructuredAgentSession(args: {
|
||||
}) {
|
||||
const {
|
||||
agent,
|
||||
composerScopeKey,
|
||||
hostStopping = false,
|
||||
isVisible,
|
||||
launch,
|
||||
providerStarting = false,
|
||||
queueFollowUps = true,
|
||||
@@ -93,17 +91,20 @@ export function useStructuredAgentSession(args: {
|
||||
loadOlder,
|
||||
mutate,
|
||||
write,
|
||||
admit,
|
||||
capturePresentation,
|
||||
providerVisible
|
||||
} = useStructuredAgentSessionTransport({
|
||||
sessionId,
|
||||
target,
|
||||
isVisible,
|
||||
isVisible: args.isVisible,
|
||||
enabled: transportEnabled,
|
||||
agentName: structuredAgentLabel(agent)
|
||||
})
|
||||
const commandPending = useRef(false)
|
||||
const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId)
|
||||
const transportState = useStructuredAgentSessionTransportState(state, transportEnabled)
|
||||
const journalItems = transportState.journalItems
|
||||
const {
|
||||
conversationCommands,
|
||||
optionSnapshot,
|
||||
@@ -113,7 +114,7 @@ export function useStructuredAgentSession(args: {
|
||||
unavailable,
|
||||
threadGoal: threadGoalSupport,
|
||||
reportedThreadGoal,
|
||||
contextUsage: contextUsageSupport,
|
||||
contextUsage: usageSupport,
|
||||
rewind: rewindSupport,
|
||||
acceptsImages
|
||||
} = useStructuredAgentSessionOptions({
|
||||
@@ -121,7 +122,7 @@ export function useStructuredAgentSession(args: {
|
||||
sessionId,
|
||||
target,
|
||||
transportEnabled,
|
||||
isVisible,
|
||||
isVisible: args.isVisible,
|
||||
providerVisible,
|
||||
providerStarting,
|
||||
...(args.providerRunning ? { providerRunning: true } : {}),
|
||||
@@ -167,13 +168,9 @@ export function useStructuredAgentSession(args: {
|
||||
sendOptions
|
||||
})
|
||||
|
||||
const contextUsage = useStructuredAgentSessionContextUsage(
|
||||
transportState.journalItems,
|
||||
contextUsageSupport,
|
||||
state.epoch
|
||||
)
|
||||
const usage = useStructuredAgentSessionContextUsage(journalItems, usageSupport, state.epoch)
|
||||
|
||||
const contextFloor = contextUsageSupport?.contextFloor ?? threadGoalSupport?.contextFloor
|
||||
const contextFloor = usageSupport?.contextFloor ?? threadGoalSupport?.contextFloor
|
||||
const railOutline = useStructuredAgentSessionRailOutline({
|
||||
sessionId,
|
||||
target,
|
||||
@@ -191,25 +188,26 @@ export function useStructuredAgentSession(args: {
|
||||
const rewind = useStructuredAgentSessionRewind({
|
||||
sessionId,
|
||||
target,
|
||||
composerScopeKey,
|
||||
composerScopeKey: args.composerScopeKey,
|
||||
...args.rewind,
|
||||
state,
|
||||
support: transportEnabled ? rewindSupport : undefined,
|
||||
contextFloor,
|
||||
// The host also refuses a rewind behind its queued cards, paused ones included.
|
||||
blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0,
|
||||
write
|
||||
write,
|
||||
capturePresentation
|
||||
})
|
||||
// A queued send is a card, never a transcript bubble.
|
||||
const isWorking = transportState.isWorking || transportState.queueSendsNext
|
||||
// A message typed during a command queues behind it on the host.
|
||||
const send = (...input: Parameters<typeof sends.send>) =>
|
||||
// Legacy: an older host refuses sends while a command runs; removable once those hosts age out.
|
||||
(!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) &&
|
||||
(!commandPending.current || hostStatesTurnScopes(journalItems)) &&
|
||||
rewind.admitsSend() &&
|
||||
sends.send(...input)
|
||||
const threadGoal = useStructuredAgentSessionThreadGoal({
|
||||
journalItems: transportState.journalItems,
|
||||
journalItems,
|
||||
journalEpoch: state.epoch,
|
||||
support: threadGoalSupport,
|
||||
reported: reportedThreadGoal,
|
||||
@@ -220,7 +218,7 @@ export function useStructuredAgentSession(args: {
|
||||
() => pendingSendsOutsideQueuedCards(pending, queuedMessageIds, isWorking),
|
||||
[isWorking, pending, queuedMessageIds]
|
||||
)
|
||||
const contextClear = useNativeChatContextClearFloor(state.epoch, transportState.journalItems)
|
||||
const contextClear = useNativeChatContextClearFloor(state.epoch, journalItems)
|
||||
// What the transcript reads: the journal since the latest /clear, plus the one notice a cut turn
|
||||
// with no row gets.
|
||||
const transcriptItems = useMemo(
|
||||
@@ -246,9 +244,11 @@ export function useStructuredAgentSession(args: {
|
||||
isWorking,
|
||||
// Hidden from the transcript, a queue send on its way reads as sending among the cards.
|
||||
sending: pending,
|
||||
composerScopeKey,
|
||||
composerScopeKey: args.composerScopeKey,
|
||||
mutate,
|
||||
editTransport
|
||||
editTransport,
|
||||
admit,
|
||||
capturePresentation
|
||||
})
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
@@ -312,10 +312,12 @@ export function useStructuredAgentSession(args: {
|
||||
}),
|
||||
stopPressed: stopControl.pressed,
|
||||
queuedMessages: queuedController,
|
||||
capturePresentation,
|
||||
/** A send made now while the agent works is held as a queued card: the host queues, and this
|
||||
* send asks it to (the setting is on and no pending prompt blocks the queue). */
|
||||
sendsQueue: structuredAgentSessionNewSendsQueue(queue),
|
||||
cancel: cancelStructuredAgentSessionPrompt.bind(null, target, mutate),
|
||||
cancel: (turnId: string | undefined, prompt?: { itemId: string; expectedRevision: number }) =>
|
||||
cancelStructuredAgentSessionPrompt(admit(), turnId, prompt),
|
||||
stopBackgroundTask: (taskId?: string) =>
|
||||
mutate<AgentSessionCancelResult>('agentSession.cancel', 'agentSession.cancel', {
|
||||
turnId: 'background-tasks',
|
||||
@@ -323,7 +325,7 @@ export function useStructuredAgentSession(args: {
|
||||
...(taskId ? { taskId } : {})
|
||||
}),
|
||||
respond: (item: StructuredPromptItem, response: AgentSessionPromptResponse) =>
|
||||
respondToStructuredAgentSessionPrompt({ item, response, target, mutate }),
|
||||
respondToStructuredAgentSessionPrompt({ item, response, action: admit() }),
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined,
|
||||
@@ -332,7 +334,7 @@ export function useStructuredAgentSession(args: {
|
||||
/** Why the host's catalog probe says no chat can start here; the chat shows it as a notice. */
|
||||
unavailable,
|
||||
threadGoal,
|
||||
contextUsage,
|
||||
contextUsage: usage,
|
||||
/** Whether this session takes images, by its options report; undefined until it answers. */
|
||||
acceptsImages
|
||||
}
|
||||
|
||||
+6
-1
@@ -15,6 +15,7 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
it('reveals before launch Retry, but waits for a successful queue Resume', async () => {
|
||||
const order: string[] = []
|
||||
const controller = {
|
||||
capturePresentation: () => () => true,
|
||||
respond: vi.fn(async () => null),
|
||||
queuedMessages: {
|
||||
queueCapable: true,
|
||||
@@ -68,7 +69,11 @@ it('sends no queued card and retries no start on a host held for its permission
|
||||
editor: undefined,
|
||||
steerNewest: vi.fn(() => true)
|
||||
}
|
||||
const controller = { respond: vi.fn(async () => null), queuedMessages }
|
||||
const controller = {
|
||||
capturePresentation: () => () => true,
|
||||
respond: vi.fn(async () => null),
|
||||
queuedMessages
|
||||
}
|
||||
const retryLaunch = vi.fn()
|
||||
useAppStore.setState({
|
||||
settings: { ...useAppStore.getState().settings!, nativeChatPermissionMode: 'ask' }
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc
|
||||
type StructuredController = ReturnType<typeof useStructuredAgentSession>
|
||||
|
||||
export function useStructuredNativeChatSubmitReveal(
|
||||
controller: Pick<StructuredController, 'respond' | 'queuedMessages'>,
|
||||
controller: Pick<StructuredController, 'respond' | 'queuedMessages' | 'capturePresentation'>,
|
||||
/** Relaunches a start that failed. */
|
||||
retryLaunch: () => void,
|
||||
/** The host may take no turn: no card is sent now or resumed, and no failed start retried. */
|
||||
@@ -26,7 +26,7 @@ export function useStructuredNativeChatSubmitReveal(
|
||||
queuedMessages: StructuredAgentSessionQueuedMessagesController
|
||||
} {
|
||||
const { messageListRef, revealLatest } = useNativeChatRevealLatest()
|
||||
const { respond, queuedMessages } = controller
|
||||
const { respond, queuedMessages, capturePresentation } = controller
|
||||
const revealingRetryLaunch = useCallback(() => {
|
||||
if (startsHeld) {
|
||||
return
|
||||
@@ -44,8 +44,9 @@ export function useStructuredNativeChatSubmitReveal(
|
||||
const revealingQueue = useMemo<StructuredAgentSessionQueuedMessagesController>(() => {
|
||||
// Resume can be refused or find nothing paused, so it reveals only once the host lifts it.
|
||||
const revealing = (lift: () => Promise<boolean>) => async (): Promise<boolean> => {
|
||||
const isCurrent = capturePresentation()
|
||||
const resumed = await lift()
|
||||
if (resumed) {
|
||||
if (resumed && isCurrent()) {
|
||||
revealLatest()
|
||||
}
|
||||
return resumed
|
||||
@@ -73,7 +74,7 @@ export function useStructuredNativeChatSubmitReveal(
|
||||
return steered
|
||||
}
|
||||
}
|
||||
}, [queuedMessages, revealLatest, startsHeld])
|
||||
}, [capturePresentation, queuedMessages, revealLatest, startsHeld])
|
||||
return {
|
||||
messageListRef,
|
||||
revealLatest,
|
||||
|
||||
@@ -157,6 +157,7 @@ test('an older rewind-capable desktop releases its temporary same-epoch hold wit
|
||||
vi.useFakeTimers()
|
||||
const input: RewindInput = {
|
||||
sessionId: SESSION,
|
||||
capturePresentation: () => () => true,
|
||||
composerScopeKey: 'old-rewind-clear-compat',
|
||||
state: {
|
||||
...EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
|
||||
Reference in New Issue
Block a user