diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 267a5f5ef785..56d944f2e78b 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -124,6 +124,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts', diff --git a/mobile/src/session/mobile-structured-actionable-prompt.ts b/mobile/src/session/mobile-structured-actionable-prompt.ts new file mode 100644 index 000000000000..478927a9088e --- /dev/null +++ b/mobile/src/session/mobile-structured-actionable-prompt.ts @@ -0,0 +1,33 @@ +import { isActionableStructuredAgentSessionPrompt } from '../../../src/shared/structured-agent-session-live-turn' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { + pendingStructuredApproval, + pendingStructuredQuestion, + type StructuredApprovalItem, + type StructuredQuestionItem +} from './mobile-structured-agent-prompts' + +type PromptState = Pick + +/** The approval a person can answer: pending, and named by the host as waiting when it names them + * (`isActionableStructuredAgentSessionPrompt`): one an agent that ended raised is not. */ +export function actionableStructuredApproval(state: PromptState): StructuredApprovalItem | null { + return ( + state.items.find( + (item): item is StructuredApprovalItem => + pendingStructuredApproval(item) && + isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds) + ) ?? null + ) +} + +/** The question a person can answer, by the same rule. */ +export function actionableStructuredQuestion(state: PromptState): StructuredQuestionItem | null { + return ( + state.items.find( + (item): item is StructuredQuestionItem => + pendingStructuredQuestion(item) && + isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds) + ) ?? null + ) +} diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts index 9077c0238eef..74ba327a1ad9 100644 --- a/mobile/src/session/mobile-structured-agent-session-cancel.ts +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -1,7 +1,9 @@ import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' -import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' -import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { + isActionableStructuredAgentSessionPrompt, + runningStructuredAgentSessionTurnId +} from '../../../src/shared/structured-agent-session-live-turn' import type { RpcClient } from '../transport/rpc-client' import { requestStructuredAgentSessionMutation, @@ -10,12 +12,14 @@ import { type PromptIdentity = { itemId: string; expectedRevision: number } +/** The prompt a Stop cancels: one the host says still waits on the person, when it says. */ export function pendingStructuredPromptIdentity( - items: readonly AgentJournalRenderItem[] + state: Pick ): PromptIdentity | undefined { - const prompt = items.find((item) => + const prompt = state.items.find((item) => item.body.kind === 'approval' || item.body.kind === 'question' - ? item.body.resolution.state === 'pending' + ? item.body.resolution.state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds) : false ) return prompt ? { itemId: prompt.itemId, expectedRevision: prompt.revision } : undefined diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7b2da967d56c..4802042435b1 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -127,6 +127,8 @@ export async function requestStructuredAgentSessionMutation(args: { fields: Record clientOperationId?: string timeoutMs?: number + /** The chat's agent, which a refusal about it names. */ + agentName?: string }): Promise> { const { client, @@ -136,8 +138,10 @@ export async function requestStructuredAgentSessionMutation(args: { expectedRuntimeFence, fields, clientOperationId, - timeoutMs + timeoutMs, + agentName } = args + const context = agentName ? { agentName } : {} try { const result = await callAgentSession>( client, @@ -171,7 +175,8 @@ export async function requestStructuredAgentSessionMutation(args: { code: result.refusal.code, message: agentSessionRefusalNotice( result.refusal, - phoneWriteKind(fingerprintMethod, fields) + phoneWriteKind(fingerprintMethod, fields), + context ) } } catch (error) { @@ -184,7 +189,7 @@ export async function requestStructuredAgentSessionMutation(args: { return { status: 'failed', message: agentSessionWriteNoticeEnglish( - agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields)) + agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields), context) ) } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index ed93bda5924a..f75782b657aa 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -145,7 +145,8 @@ export function useMobileNativeChatTurnDisclosure({ rows, turnJournal?.items, stopping, - turnJournal?.submissions + turnJournal?.submissions, + turnJournal ?? {} ) : null if (!ids?.size) { diff --git a/mobile/src/session/use-mobile-structured-agent-mutation.ts b/mobile/src/session/use-mobile-structured-agent-mutation.ts index 4eb8a203121b..d8a6a30fb7bf 100644 --- a/mobile/src/session/use-mobile-structured-agent-mutation.ts +++ b/mobile/src/session/use-mobile-structured-agent-mutation.ts @@ -22,8 +22,10 @@ export function useMobileStructuredAgentMutate(args: { enabled: boolean stateRef: { readonly current: StructuredAgentSessionState } onSendError: (message: string) => void + /** The chat's agent, which a refusal about it names. */ + agentName?: string }): MobileStructuredAgentMutate { - const { client, enabled, onSendError, sessionId, stateRef } = args + const { agentName, client, enabled, onSendError, sessionId, stateRef } = args return useCallback( async ( method: string, @@ -41,7 +43,8 @@ export function useMobileStructuredAgentMutate(args: { fingerprintMethod, sessionId, expectedRuntimeFence: targetFence, - fields + fields, + ...(agentName ? { agentName } : {}) }) if (result.status === 'accepted') { return { @@ -56,6 +59,6 @@ export function useMobileStructuredAgentMutate(args: { onSendError(result.message) return { status: 'rejected' } }, - [client, enabled, onSendError, sessionId, stateRef] + [agentName, client, enabled, onSendError, sessionId, stateRef] ) } diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index cd8ffcc68338..0ced391a53f7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -268,6 +268,22 @@ describe('mobile structured prompt cancellation', () => { expect(hook.turnId).toBeNull() }) + it("reads Working from the host's answer, and from its rows only when an older host gives none", () => { + // A turn row an agent that has since ended left running: the host says it is not working. + state = { ...state, items: [runningTurn()], working: false } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.isWorking).toBe(false) + act(() => renderer?.unmount()) + const { working: _answered, ...olderHost } = state + state = olderHost + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.isWorking).toBe(true) + }) + it('uses the rendered prompt identity when the journal changes before tap', async () => { act(() => { renderer = create(createElement(Harness, { promptCancelSupported: true })) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 3dd7b707e937..91e77f8554d5 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -7,12 +7,15 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/st import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { isStructuredAgentSessionThinking, - runningStructuredAgentSessionTurnId + runningStructuredAgentSessionTurnId, + structuredAgentSessionHostSaysWorking } from '../../../src/shared/structured-agent-session-live-turn' import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { - pendingStructuredApproval, - pendingStructuredQuestion, + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' +import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' @@ -91,7 +94,8 @@ export function useMobileStructuredAgentSession(args: { sessionId, enabled, stateRef, - onSendError + onSendError, + ...(agent ? { agentName: tuiAgentDisplayName(agent) ?? agent } : {}) }) const optionStamp = useMobileStructuredSessionOptionStamp({ @@ -159,7 +163,10 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state) - const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + // The host's answer counts only the agent running now; an older host gives none. + const isWorking = structuredAgentSessionHostSaysWorking(state.working, () => + isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + ) const backgroundTasks = useMobileStructuredBackgroundTasks({ sessionKey, state, @@ -214,14 +221,11 @@ export function useMobileStructuredAgentSession(args: { [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state.items] ) const status = state.status === 'idle' ? 'idle' : state.status - const approvalPrompt = useMemo( - () => state.items.find(pendingStructuredApproval) ?? null, - [state.items] - ) - const questionPrompt = useMemo( - () => state.items.find(pendingStructuredQuestion) ?? null, - [state.items] - ) + // The host's answer where it gives one: a prompt an agent that ended raised takes no answer. + const [approvalPrompt, questionPrompt] = useMemo(() => { + const prompts = { items: state.items, actionablePromptIds: state.actionablePromptIds } + return [actionableStructuredApproval(prompts), actionableStructuredQuestion(prompts)] as const + }, [state.items, state.actionablePromptIds]) // What a refused command's line on the phone stands on. const commandRefusalCauses = useMemo( () => ({ @@ -237,7 +241,7 @@ export function useMobileStructuredAgentSession(args: { queuedMessages, queuePause, submissions: state.submissions, - agentWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + agentWorking: isWorking, mutate, appendComposerText, onSendError, @@ -299,7 +303,7 @@ export function useMobileStructuredAgentSession(args: { void stopPress.track(() => requestCancel()) }, cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => - requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), + requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current)), permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index a701cdbc21d1..d23bb936c545 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -21,11 +21,14 @@ export function useMobileStructuredAgentTurnTiming( items, submissions, latestTurn, + working, hostClock }: { items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] latestTurn?: AgentSessionLatestTurn | null + /** The host's working answer, which places rows waiting behind the live turn. */ + working?: boolean hostClock?: StructuredAgentHostClock | null }, turnId: string | null @@ -40,8 +43,8 @@ export function useMobileStructuredAgentTurnTiming( [items, submissions, turnId, latestTurn] ) const turnJournal = useMemo( - () => ({ items, submissions, latestTurn }), - [items, submissions, latestTurn] + () => ({ items, submissions, latestTurn, ...(working !== undefined ? { working } : {}) }), + [items, submissions, latestTurn, working] ) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.ts b/mobile/src/session/use-mobile-structured-prompt-responses.ts index 093029b2be52..eb2a0b38caa1 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.ts +++ b/mobile/src/session/use-mobile-structured-prompt-responses.ts @@ -8,8 +8,10 @@ import { } from '../../../src/shared/agent-session-question-answer' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { - pendingStructuredApproval, - pendingStructuredQuestion, + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' +import { structuredApprovalResponseTarget, structuredQuestionResponseTarget } from './mobile-structured-agent-prompts' @@ -54,7 +56,7 @@ export function useMobileStructuredPromptResponses(args: { async (optionId: string): Promise => { const target = structuredApprovalResponseTarget( optionId, - stateRef.current.items.find(pendingStructuredApproval) ?? null + actionableStructuredApproval(stateRef.current) ) if (!target) { return false @@ -107,7 +109,7 @@ export function useMobileStructuredPromptResponses(args: { const respondQuestion = useCallback( async (answer: string): Promise => { - const prompt = stateRef.current.items.find(pendingStructuredQuestion) ?? null + const prompt = actionableStructuredQuestion(stateRef.current) if (prompt?.body.questions) { const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision) const grouped = advanceGroupedQuestion({ diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 81a0d0b0fdf3..aa833d4cdf61 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -22,9 +22,9 @@ import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view' import { - pendingStructuredApproval, - pendingStructuredQuestion -} from './mobile-structured-agent-prompts' + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' /** Whether a send made now asks the host to queue it. The host's queue waits on any pending prompt; * one this build cannot answer would hold the send forever, so it starts a turn instead. */ @@ -113,9 +113,8 @@ export function useMobileStructuredSendWithOutcome(args: { pending: commandPending, controller: { agent: agent ?? undefined, ...controller }, busy: () => - stateRef.current.items.some( - (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) - ) + actionableStructuredApproval(stateRef.current) || + actionableStructuredQuestion(stateRef.current) ? 'prompt' : runningStructuredAgentSessionTurnId(stateRef.current) ? 'working' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts index 5fcc711c09d5..b7ce50f5f352 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts @@ -1,16 +1,22 @@ // The host's one projection of current work (`structuredAgentSessionCurrentWork`), end to end // against the real host, store and SQLite journal: what an ended generation left holds nothing and -// reads as nothing to every host reader (the projection, the session list, the frames and pages a -// chat client is sent, Stop, the queue), whether or not its cleanup landed, while a live owner's -// work holds exactly as before. +// reads as nothing to every reader (the projection, the session list, a chat client, Stop, a prompt +// answer, the queue), whether or not its cleanup or its release landed, while a live owner's work +// holds exactly as before. import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + isActionableStructuredAgentSessionPrompt, + runningStructuredAgentSessionTurnId +} from '../../../shared/structured-agent-session-live-turn' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../shared/structured-agent-session-reducer' import { liveTestJournalRows, openTestJournalHostDatabase @@ -47,26 +53,15 @@ const FOLDER: AgentSessionExecutionLocation = { workspaceKind: 'folder' } -type HostWork = Pick< - Extract, - 'latestTurn' | 'actionablePromptIds' | 'working' -> - -/** A chat subscriber and the session list, each fed by the host as a real one is: the subscriber - * keeps the current-work fields of the newest frame or page that stated them. */ +/** A chat client and the session list, each fed by the host as a real one is. */ function watch(current: QueuedMessageTestRig) { - let work: HostWork = {} + let chat: StructuredAgentSessionState = EMPTY_STRUCTURED_AGENT_SESSION let listed: AgentSessionStatusSummary | undefined current.host.subscribe({ id: 'chat-1', sessionId: SESSION, emit: (event) => { - const fields = - event.type === 'snapshot' ? event.page : event.type === 'batch' ? event : undefined - if (fields?.working !== undefined) { - const { latestTurn, actionablePromptIds, working } = structuredClone(fields) - work = { latestTurn, actionablePromptIds, working } - } + chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) } }) current.host.subscribeStatus({ @@ -79,7 +74,19 @@ function watch(current: QueuedMessageTestRig) { } } }) - return { work: () => work, listed: () => listed } + return { + chat: () => chat, + listed: () => listed, + /** Whether the chat would let the person answer the prompt. */ + answerable: () => + chat.items.some( + (item) => + item.itemId === APPROVAL_ID && + item.body.kind === 'approval' && + item.body.resolution.state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, chat.actionablePromptIds) + ) + } } /** The tail page a client hydrates from. */ @@ -125,10 +132,10 @@ describe("a dead generation's leftovers, its settlement refused and its lease re expect(page.latestTurn?.turn.state).toBe('interrupted') expect(page.actionablePromptIds).toEqual([]) expect(page.working).toBe(false) - await vi.waitFor(() => - expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false }) - ) - expect(view.work().latestTurn?.turn.state).not.toBe('running') + await vi.waitFor(() => expect(view.chat().actionablePromptIds).toEqual([])) + expect(view.chat().working).toBe(false) + expect(runningStructuredAgentSessionTurnId(view.chat())).toBeNull() + expect(view.answerable()).toBe(false) }) it('gives Stop nothing to stop: the dead turn is not touched', async () => { @@ -148,7 +155,7 @@ describe('a close Orca asked for, its settlement refused', () => { await current.workingSend() await leaveUnfinishedWork(current, { prompt: true }) const view = watch(current) - await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID])) + await vi.waitFor(() => expect(view.answerable()).toBe(true)) const database = openTestJournalHostDatabase(current.root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) database.db.exec(REJECT_RECOVERED_ROWS) @@ -170,10 +177,9 @@ describe('a close Orca asked for, its settlement refused', () => { expect(currentJournal(current).cursor().sequence).toBe(before) expect(turnState(current)).toBe('running') - await vi.waitFor(() => - expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false }) - ) - expect(view.work().latestTurn?.turn.state).not.toBe('running') + await vi.waitFor(() => expect(view.answerable()).toBe(false)) + expect(view.chat().working).toBe(false) + expect(runningStructuredAgentSessionTurnId(view.chat())).toBeNull() expect(view.listed()?.status).not.toBe('working') }) }) @@ -238,11 +244,10 @@ describe('a live owner', () => { expect(page.latestTurn?.turn).toMatchObject({ turnId: 'unfinished', state: 'running' }) expect(page.actionablePromptIds).toEqual([APPROVAL_ID]) expect(page.working).toBe(true) - await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID])) - expect(view.work()).toMatchObject({ - working: true, - latestTurn: { turn: { turnId: 'unfinished', state: 'running' } } - }) + await vi.waitFor(() => expect(view.chat().actionablePromptIds).toEqual([APPROVAL_ID])) + expect(view.chat().working).toBe(true) + expect(runningStructuredAgentSessionTurnId(view.chat())).toBe('unfinished') + expect(view.answerable()).toBe(true) expect(view.listed()?.status).not.toBe('idle') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts new file mode 100644 index 000000000000..8a2c77554395 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts @@ -0,0 +1,155 @@ +// A Cancel that names a question an ended generation raised, from a client that still shows it: +// the card is dismissed in the journal alone. Main ran the chat's Stop here, which stopped the live +// turn and withdrew the queue. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudePromptCancelRoute } from '../../claude/claude-structured-prompt-replies' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import type { StructuredAgentSessionChatStopRun } from './structured-agent-session-chat-stop' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} +const ENDED = 1 +const LIVE = 2 + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +/** Generation 1 raised a card and ended without its cleanup; generation 2 runs `turn-2`, with a + * message queued behind it. */ +async function endedCardAndLiveTurn(): Promise<{ journal: AgentSessionJournal; itemId: string }> { + root = await mkdtemp(join(tmpdir(), 'orca-ended-prompt-cancel-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const turn = (turnId: string, fence: number) => + journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }, + { kind: 'turn', turnId, state: 'running', startedAt: fence }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const old = await turn('turn-1', ENDED) + const pending = { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + const card = await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, + { + kind: 'question', + question: 'Which?', + options: [{ id: 'a', label: 'A' }], + resolution: pending + }, + { fence: ENDED, turnScope: { kind: 'turn', turnItemId: old.itemId } } + ) + await turn('turn-2', LIVE) + await journal.queuedMessages.insert({ + messageId: 'queued-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after this' }] }, + fingerprint: 'queued-1', + hostInstance: 'host-1' + }) + return { journal, itemId: card.itemId } +} + +/** The queue as the live turn left it: its message waiting, and nothing pausing it. */ +function queueUntouched(journal: AgentSessionJournal): void { + expect(journal.queuedMessages.list().map((card) => [card.messageId, card.state])).toEqual([ + ['queued-1', 'waiting'] + ]) + expect(structuredQueuePauses(journal)).toEqual([]) +} + +function provider(routePromptCancel?: StructuredAgentSessionAdapter['routePromptCancel']) { + return { + cancelTurn: vi.fn(async (_input: { turnId?: string; prompt?: unknown }) => ({ + cancelled: true + })), + dismissPrompt: vi.fn(async () => undefined), + ...(routePromptCancel ? { routePromptCancel } : {}) + } +} + +function context( + journal: AgentSessionJournal, + adapter: ReturnType +): AgentSessionTurnContext { + return { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: LIVE, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a Cancel reaches only cancelTurn, dismissPrompt and routePromptCancel, all supplied. + adapter: adapter as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } +} + +function chatStop() { + return vi.fn(async (): Promise => ({ + outcome: { ok: true, value: { turnId: 'turn-2', cancelled: true } }, + endsSession: false + })) +} + +function resolution(journal: AgentSessionJournal, itemId: string) { + const body = journal.snapshot().items.find((item) => item.itemId === itemId)?.body + return body?.kind === 'approval' || body?.kind === 'question' ? body.resolution.state : undefined +} + +describe('a question an ended generation raised, cancelled from a client that still shows it', () => { + it('is dismissed only, even when the request names the live turn: no Stop, the queue untouched', async () => { + const { journal, itemId } = await endedCardAndLiveTurn() + const agent = provider(claudePromptCancelRoute) + const stop = chatStop() + const interrupt = vi.fn() + + const result = await cancelStructuredAgentSessionPrompt( + context(journal, agent), + { turnId: 'turn-2', prompt: { itemId, expectedRevision: 1 } }, + { stop, interrupt } + ) + + expect(result).toMatchObject({ ok: true, value: { turnId: 'turn-2', cancelled: true } }) + expect(resolution(journal, itemId)).toBe('cancelled') + expect(stop).not.toHaveBeenCalled() + expect(interrupt).not.toHaveBeenCalled() + expect(agent.cancelTurn).not.toHaveBeenCalled() + expect(agent.dismissPrompt).not.toHaveBeenCalled() + queueUntouched(journal) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts index 72c9f9b46e04..9fe28ba770aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts @@ -10,6 +10,7 @@ import { AgentSessionPromptUnavailableError } from './structured-agent-session-a import type { StructuredAgentSessionChatStopRun } from './structured-agent-session-chat-stop' import { answerCancelOfSettledPrompt, + isEndedGenerationPrompt, validatePendingPrompt, type PendingPromptValidation } from './structured-agent-session-prompt-state' @@ -36,6 +37,9 @@ export async function cancelStructuredAgentSessionPrompt( sessionId: ctx.sessionId, prompt: validated.prompt }) + if (isEndedGenerationPrompt(ctx, validated)) { + return cancelEndedGenerationPrompt(ctx, input, validated, route ? null : routes.interrupt) + } if (!route) { return routes.interrupt() } @@ -61,6 +65,45 @@ export async function cancelStructuredAgentSessionPrompt( return dismissed.ok ? cancelled : dismissed } +/** A card an ended generation raised: no live provider holds it, so it is dismissed in the journal + * alone. As on main, a provider whose card Cancel routes (a dismissal, or a Stop of the turn that + * raised it) stops nothing, since the live turn did not raise it. One with no route gets the + * named live turn's own cancel with the card, as main's plan does, never the chat's Stop, so + * queued messages stay and the queue is not paused: ACP interrupts that turn, and Codex, finding + * no such card in its live child, interrupts nothing. */ +async function cancelEndedGenerationPrompt( + ctx: AgentSessionTurnContext, + input: { turnId?: string }, + pending: PendingPrompt, + interrupt: (() => Promise) | null +): Promise { + const liveTurnId = contextStructuredAgentSessionCurrentWork(ctx).activeTurnId() + const interrupted = + interrupt && input.turnId !== undefined && input.turnId === liveTurnId + ? await interrupt() + : null + if (interrupted && !interrupted.ok) { + return interrupted + } + // The interrupt's provider may have let go of it already; what is still pending is dismissed. + const still = validatePendingPrompt(ctx, { + itemId: pending.item.itemId, + expectedRevision: pending.item.revision + }) + if (still.ok) { + const dismissed = await dismissPrompt(ctx, still, null) + if (!dismissed.ok) { + return dismissed + } + } + return ( + interrupted ?? { + ok: true, + value: { ...(input.turnId ? { turnId: input.turnId } : {}), cancelled: true } + } + ) +} + function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): boolean { const live = contextStructuredAgentSessionCurrentWork(ctx).turnScope() const raised = pending.item.turnScope @@ -68,11 +111,12 @@ function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): } /** Records the card as cancelled by the caller, after every row the provider already sent (each - * landed at its call); `answer` also declines the provider's request. */ + * landed at its call); `answer` also declines the provider's request. Null: no live provider holds + * the card (an ended generation's), so only the journal records it. */ async function dismissPrompt( ctx: AgentSessionTurnContext, pending: PendingPrompt, - answer: boolean + answer: boolean | null ): Promise> { const { item, prompt } = pending const identity = parseAgentJournalItemKey(item.itemId) @@ -107,13 +151,15 @@ async function dismissPrompt( committed = true } try { - await ctx.adapter.dismissPrompt?.({ - sessionId: ctx.sessionId, - itemId: item.itemId, - fence: ctx.fence, - answer, - commit - }) + if (answer !== null) { + await ctx.adapter.dismissPrompt?.({ + sessionId: ctx.sessionId, + itemId: item.itemId, + fence: ctx.fence, + answer, + commit + }) + } } catch (error) { if (!committed && !(error instanceof AgentSessionPromptUnavailableError)) { return dismissalNotSaved(ctx, error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 8cc9b7e4bc92..c8abbbab8121 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -8,6 +8,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' type PendingPromptBody = Extract @@ -91,3 +92,12 @@ export function validatePendingPrompt( } return { ok: true, item, prompt } } + +/** A pending prompt an ended generation raised (`StructuredAgentSessionCurrentWork.isCurrentItem`): + * no live provider holds it, so a Cancel of it reaches no provider. */ +export function isEndedGenerationPrompt( + ctx: Pick, + pending: Extract +): boolean { + return !contextStructuredAgentSessionCurrentWork(ctx).isCurrentItem(pending.item.itemId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index b7b21c767d6f..5b8f090b22ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -100,6 +100,14 @@ export async function performPrompt( return held ? { ok: true, value: held } : validated } const { prompt } = validated + const work = contextStructuredAgentSessionCurrentWork(ctx) + if (!work.isCurrentItem(input.itemId)) { + // The agent that asked has ended: an answer would reach whichever agent runs now, or none. + return invalid( + 'promptOwnerEnded', + `Item ${input.itemId} was raised by an agent that has ended.` + ) + } const choice = readPromptChoice(prompt, input) if (!choice) { return invalid( @@ -135,10 +143,7 @@ export async function performPrompt( identity, { ...prompt, resolution }, // A revision: the prompt keeps the turn it was raised in. - { - fence: ctx.fence, - turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() - }, + { fence: ctx.fence, turnScope: work.turnScope() }, // The answer's identity commits here, before the adapter tells the provider. ctx.operationReceipt ) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index af3a96b2c4d9..1e3002e0b04b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -27,6 +27,7 @@ import { NativeChatWaitingTranscriptItems } from './NativeChatTranscriptItems' import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' +import type { StructuredAgentSessionHostWork } from './use-structured-agent-session-transport-state' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { splitNativeChatSlotsWaitingBehindLiveTurn, @@ -68,7 +69,7 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe * `unshown`: it cannot (the prompt is only in the agent's terminal). */ export type NativeChatAwaitingInput = 'shown' | 'unshown' -type NativeChatNavigationRequest = +type NavigationRequest = | { kind: 'diff'; target: NativeChatDiffReveal } | { kind: 'rail'; messageId: string; requestId: number } @@ -78,6 +79,7 @@ export function NativeChatMessageList({ journalItems, journalSubmissions, journalLatestTurn: latestTurn, + journalHostWork: hostWork, subagentRoster, railOutline = null, isVisible = true, @@ -100,6 +102,8 @@ export function NativeChatMessageList({ journalSubmissions?: readonly AgentJournalSubmission[] /** The host's newest turn record, which places a live turn whose record is not loaded. */ journalLatestTurn?: AgentSessionLatestTurn | null + /** The host's prompts still waiting on the person and its working answer (an older host: none). */ + journalHostWork?: StructuredAgentSessionHostWork /** Every subagent the session's rosters named, whether or not its roster row is loaded. */ subagentRoster?: Parameters[2] /** User messages older than the loaded window, from the host's outline. */ @@ -122,9 +126,7 @@ export function NativeChatMessageList({ stopping?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { - const [navigationRequest, setNavigationRequest] = useState( - null - ) + const [navigationRequest, setNavigationRequest] = useState(null) const navigationSequence = useRef(0) const revealedDiff = navigationRequest?.kind === 'diff' ? navigationRequest.target : null const railJump = navigationRequest?.kind === 'rail' ? navigationRequest : null @@ -223,9 +225,10 @@ export function NativeChatMessageList({ allSlots, journalItems, stopping, - journalSubmissions + journalSubmissions, + { latestTurn, ...hostWork } ), - [allSlots, journalItems, stopping, journalSubmissions] + [allSlots, journalItems, stopping, journalSubmissions, latestTurn, hostWork] ) const { marker: newRowsMarker, dismiss } = useNativeChatNewRowsMarker( slots, @@ -274,11 +277,8 @@ export function NativeChatMessageList({ }) const requestRailJump = useCallback((item: NativeChatRailItem) => { navigationSequence.current += 1 - setNavigationRequest({ - kind: 'rail', - messageId: item.id, - requestId: navigationSequence.current - }) + const requestId = navigationSequence.current + setNavigationRequest({ kind: 'rail', messageId: item.id, requestId }) }, []) const railHistoryJump = useNativeChatRailHistoryJump({ items: rail.items, @@ -311,10 +311,8 @@ export function NativeChatMessageList({ openSubagentSections(target.subagentSections) } navigationSequence.current += 1 - setNavigationRequest({ - kind: 'diff', - target: { ...target, requestId: navigationSequence.current } - }) + const requestId = navigationSequence.current + setNavigationRequest({ kind: 'diff', target: { ...target, requestId } }) }, [beginNavigation, readerLeavesEnd, openSubagentSections] ) @@ -340,6 +338,7 @@ export function NativeChatMessageList({ const rowContext = useMemo( () => ({ agentName: structuredAgentLabel(session.agent), + actionablePromptIds: hostWork?.actionablePromptIds, expandSignal, revealedDiff, taskListPredecessors, @@ -355,6 +354,7 @@ export function NativeChatMessageList({ }), [ session.agent, + hostWork, allowFileUriLinks, expandSignal, expandedTurnIds, diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx index 72f1f62c21d3..00c9c4b94e05 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx @@ -218,6 +218,22 @@ describe('resolution receipts', () => { expect(screen.queryByText('1 grouped question from Claude')).toBeNull() }) + it('reports, without breathing, a question whose agent ended (the host says nobody waits)', () => { + const body: AgentJournalQuestionItem = { + kind: 'question', + question: 'Which branch?', + options: [], + resolution: { ...approval.resolution, state: 'pending', selectedOptionId: null } + } + + const { rerender } = render() + expect(screen.getByText('Awaiting user input:')).toHaveClass('animate-pulse') + rerender() + expect(screen.queryByText('Awaiting user input:')).toBeNull() + expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse') + expect(screen.getByText('Which branch?')).toBeInTheDocument() + }) + it('keeps an opened question open once it is answered', () => { const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth') Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx index fd026ba4ad65..f42890607b97 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx @@ -9,11 +9,15 @@ import { export function NativeChatResolutionReceipt({ body, - disclosureId + disclosureId, + awaiting = true }: { body: NativeChatResolvedPrompt /** Message this receipt stands in for; keys the question row's disclosure. */ disclosureId?: string + /** False when the host says nobody waits on this pending prompt: the agent that raised it ended, + * so its row reports what was asked instead of breathing. */ + awaiting?: boolean }): React.JSX.Element | null { const askDisclosureKey = disclosureId === undefined ? undefined : `ask:${disclosureId}` const subject: NativeChatAskRowSubject | null = @@ -35,7 +39,11 @@ export function NativeChatResolutionReceipt({ } if (body.resolution.state === 'pending') { return body.kind === 'question' ? ( - + ) : null } const { resolution } = body diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8fd1c62768e4..c5914fe871f9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -301,6 +301,7 @@ export function NativeChatStructuredSession( journalItems={controller.journalItems} journalSubmissions={controller.submissions} journalLatestTurn={controller.latestTurn} + journalHostWork={controller.hostWork} subagentRoster={controller.subagentRoster} railOutline={controller.railOutline} isVisible={props.isVisible} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 9810a09c8749..6b2f2bfa4475 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -14,6 +14,7 @@ import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' import { NativeChatRewindContext } from './native-chat-rewind-context' import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility' +import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/structured-agent-session-live-turn' import { NativeChatNewRowsMarker } from './NativeChatNewRowsMarker' import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker' @@ -21,6 +22,8 @@ import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker * object so a row's props change only when that row's own slot does. */ export type NativeChatTranscriptRowContext = { agentName?: string + /** The prompts the host says still wait on the person; absent from an older host. */ + actionablePromptIds?: readonly string[] | undefined expandSignal: boolean revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap @@ -107,7 +110,14 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ {/* A turn with no user bubble carries its bar above its first row. */} {slot.statusAbove ? statusRow : null} {!slot.drawsMessage ? null : receipt ? ( - + ) : ( - translate( - 'components.native-chat.failureWords.providerExitedRow', - COPY.providerExitedRow, - values - ), - providerExitedRejection: (values) => - translate( - 'components.native-chat.failureWords.providerExitedRejection', - COPY.providerExitedRejection, - values - ), + ...PROVIDER_EXIT_PIECES, providerRejected: (values) => translate( 'components.native-chat.failureWords.providerRejected', diff --git a/src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts b/src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts new file mode 100644 index 000000000000..a9b3785888de --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts @@ -0,0 +1,31 @@ +// Desktop words for an agent that stopped, kept beside the other failure pieces' translations. + +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues +} from '../../../../shared/agent-session-failure-copy' + +export const PROVIDER_EXIT_PIECES = { + providerExitedRow: (values) => + translate( + 'components.native-chat.failureWords.providerExitedRow', + COPY.providerExitedRow, + values + ), + providerExitedRejection: (values) => + translate( + 'components.native-chat.failureWords.providerExitedRejection', + COPY.providerExitedRejection, + values + ), + providerExitedAnswer: (values) => + translate( + 'components.native-chat.failureWords.providerExitedAnswer', + COPY.providerExitedAnswer, + values + ) +} satisfies Partial< + Record string> +> diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index f5296ad39838..9478be67461e 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,7 +2,10 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' -import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' +import { + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from '../../../../shared/agent-session-failure-words' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, @@ -180,7 +183,8 @@ export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNot export function agentSessionWriteFailureText( failure: AgentSessionWriteFailure, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): string { - return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write)) + return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write, context)) } diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 2b4a748a79de..14c1e0e0cbc5 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -322,13 +322,15 @@ export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], journalItems: readonly AgentJournalRenderItem[] | undefined, stopping = false, - journalSubmissions?: readonly AgentJournalSubmission[] + journalSubmissions?: readonly AgentJournalSubmission[], + host: Parameters[4] = {} ): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { const waiting = nativeChatMessagesWaitingBehindLiveTurn( slots.flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])), journalItems, stopping, - journalSubmissions + journalSubmissions, + host ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => slot.kind === 'message' && diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index fa3216d760ca..57f8a1ea2ee7 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -6,6 +6,7 @@ import type { import type { StructuredAgentSessionOptimisticMessage } from '../../../../shared/structured-agent-session-message-projection' import { projectStructuredAgentSessionMessages as projectMessages } from '../../../../shared/structured-agent-session-message-projection' import { projectStructuredQuestionMessages } from './structured-agent-question-projection' +import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/structured-agent-session-live-turn' /** The desktop's transcript: a message the host accepted and then rejected stays where it was * sent, as not sent, unless the queue holds it as a card. */ @@ -21,12 +22,19 @@ export type StructuredPromptItem = AgentJournalRenderItem & { body: Extract } -export function pendingStructuredSessionPrompts( - items: AgentJournalRenderItem[] -): StructuredPromptItem[] { - return agentSessionCurrentContextRows(items).items.filter( +/** The prompt cards a person can answer: pending, and named by the host as waiting + * (`isActionableStructuredAgentSessionPrompt`) when the host names them. */ +export function pendingStructuredSessionPrompts({ + journalItems, + actionablePromptIds +}: { + journalItems: AgentJournalRenderItem[] + actionablePromptIds?: readonly string[] +}): StructuredPromptItem[] { + return agentSessionCurrentContextRows(journalItems).items.filter( (item): item is StructuredPromptItem => (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' + item.body.resolution.state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, actionablePromptIds) ) } diff --git a/src/renderer/src/components/native-chat/structured-attention-read-observation.ts b/src/renderer/src/components/native-chat/structured-attention-read-observation.ts index a614438deb57..8a365d4fc652 100644 --- a/src/renderer/src/components/native-chat/structured-attention-read-observation.ts +++ b/src/renderer/src/components/native-chat/structured-attention-read-observation.ts @@ -1,5 +1,6 @@ import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' import { projectStructuredAgentSessionStatusState } from '../../../../shared/structured-agent-session-projection' +import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/structured-agent-session-live-turn' const observations = new WeakMap() @@ -16,7 +17,10 @@ export function structuredAttentionReadObservation(state: StructuredAgentSession const request = projection.latestRequest const key = JSON.stringify([ state.cursor?.epoch, - projection.pendingPromptIds, + // The host's answer where it gives one: a prompt an agent that ended raised waits on no one. + projection.pendingPromptIds.filter((itemId) => + isActionableStructuredAgentSessionPrompt(itemId, state.actionablePromptIds) + ), request?.turnState !== 'running' ? [request?.kind, request?.id, request?.outcome] : null ]) observations.set(state, key) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index dc4e7722b3f8..bdbbe492dc1b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -57,11 +57,16 @@ function namesWhatItStops(fingerprintMethod: string, fields: Record + fields: Record, + agentName: string | undefined ): { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } { return { kind: 'not-done', - notice: agentSessionWriteFailureText(failure, writeKind(fingerprintMethod, fields)), + notice: agentSessionWriteFailureText( + failure, + writeKind(fingerprintMethod, fields), + agentName ? { agentName } : {} + ), failure } } @@ -73,11 +78,13 @@ export function useStructuredAgentSessionMutate(args: { /** 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. */ stateRef: { current: { fence: number | null } } + /** The chat's agent, which a refusal about it names. */ + agentName?: string }): { write: StructuredAgentSessionWrite mutate: StructuredAgentSessionMutate } { - const { enabled = true, sessionId, stateRef, target } = args + const { agentName, enabled = true, sessionId, stateRef, target } = args const inFlightStops = useRef( new Map>>() ) @@ -144,13 +151,19 @@ export function useStructuredAgentSessionMutate(args: { error instanceof RuntimeRpcCallError ? error.code : undefined ), fingerprintMethod, - fields + fields, + agentName ) : { kind: 'dropped' } } if (!result.ok) { return settlesHere() - ? notDone(agentSessionRefusalFailure(result.refusal), fingerprintMethod, fields) + ? notDone( + agentSessionRefusalFailure(result.refusal), + fingerprintMethod, + fields, + agentName + ) : { kind: 'dropped' } } if (!settlesHere()) { @@ -158,7 +171,7 @@ export function useStructuredAgentSessionMutate(args: { } return { kind: 'done', value: result.value } }, - [enabled, sessionId, stateRef, target] + [agentName, enabled, sessionId, stateRef, target] ) const write = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index ef730a1781df..84f1eec124a0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -1,5 +1,8 @@ import { useMemo } from 'react' -import { runningStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-live-turn' +import { + runningStructuredAgentSessionTurnId, + structuredAgentSessionHostSaysWorking +} from '../../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../../shared/structured-agent-session-main-agent-working' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' import type { StructuredAgentSubagentRoster } from '../../../../shared/structured-agent-session-subagent-roster' @@ -13,6 +16,13 @@ const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = [] const NO_SUBAGENT_ROSTER: StructuredAgentSubagentRoster = new Map() +/** The host's prompts still waiting on the person and its working answer; empty from an older + * host, whose readers derive both from the rows. */ +export type StructuredAgentSessionHostWork = { + actionablePromptIds?: readonly string[] + working?: boolean +} + export function useStructuredAgentSessionTransportState( state: StructuredAgentSessionState, enabled: boolean @@ -22,14 +32,20 @@ export function useStructuredAgentSessionTransportState( const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER const fence = enabled ? state.fence : null const latestTurn = enabled ? state.latestTurn : undefined + // The host's answer to which pending prompts still wait on the person; absent from an older host. + const actionablePromptIds = enabled ? state.actionablePromptIds : undefined const current = useMemo( () => agentSessionCurrentContextRows(journalItems, submissions), [journalItems, submissions] ) // The host's whole-journal turn, never the loaded rows': a long turn's record is off the page. const turnId = runningStructuredAgentSessionTurnId({ items: journalItems, latestTurn }) - // The rule the host projects every session list's Working from, so this chat cannot disagree. - const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence) + // The host's own answer, which counts only the agent running now; an older host gives none, and + // the rule it projects every session list's Working from stands in, so this chat cannot disagree. + const hostWorking = enabled ? state.working : undefined + const isWorking = structuredAgentSessionHostSaysWorking(hostWorking, () => + isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence) + ) const nextQueuedMessageId = (enabled ? state.nextQueuedMessageId : null) ?? null // The host names the card its queue sends next. That send lands in a later update than a turn's // end or a Resume, so until then the chat still reads as working and nothing flips in between. @@ -55,9 +71,18 @@ export function useStructuredAgentSessionTransportState( structuredSessionForegroundCommands(roster, { items: journalItems, latestTurn }) ) }, [enabled, journalItems, latestTurn, state.backgroundTasks, turnId]) + const hostWork = useMemo( + () => ({ + ...(actionablePromptIds ? { actionablePromptIds } : {}), + ...(hostWorking !== undefined ? { working: hostWorking } : {}) + }), + [actionablePromptIds, hostWorking] + ) return { journalItems, latestTurn, + actionablePromptIds, + hostWork, subagentRoster, submissions, fence, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx new file mode 100644 index 000000000000..ae005ce95e17 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom + +// Whether the chat's own agent is working is the host's answer when it gives one: it counts only +// the agent running now, so a turn an ended agent left running reads as stopped. +// An older host gives none, and the chat derives it from its rows as before. + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' + +afterEach(cleanup) + +/** A turn row an agent that has since ended left running. */ +const LEFT_RUNNING: AgentJournalRenderItem = { + itemId: 'turn-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } +} + +function page(fields: Partial = {}): AgentSessionHistoryPage { + const cursor = { epoch: 'epoch-a', sequence: 1 } + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [LEFT_RUNNING], + removedItemIds: [], + submissions: [], + window: { oldest: cursor, newest: cursor, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false, + ...fields + } +} + +function loaded(fields: Partial): StructuredAgentSessionState { + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: page(fields) + }) +} + +function isWorking(state: StructuredAgentSessionState): boolean { + return renderHook(() => useStructuredAgentSessionTransportState(state, true)).result.current + .isWorking +} + +describe('the chat’s Working', () => { + it('is the host’s answer: a turn an ended agent left running reads as stopped', () => { + expect(isWorking(loaded({ working: false }))).toBe(false) + expect(isWorking(loaded({ items: [], working: true }))).toBe(true) + }) + + it('falls back to the rows when an older host gives no answer', () => { + expect(loaded({}).working).toBeUndefined() + expect(isWorking(loaded({}))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts index 109760de4e7d..3639357623f8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts @@ -9,8 +9,10 @@ export function useStructuredAgentSessionTransport(args: { target: RuntimeClientTarget isVisible: boolean enabled: boolean + /** The chat's agent, which a refused write about it names. */ + agentName?: string }) { - const { enabled, isVisible, sessionId, target } = args + const { agentName, enabled, isVisible, sessionId, target } = args const providerVisible = isVisible && enabled useStructuredAgentSessionHold({ sessionId, @@ -25,7 +27,8 @@ export function useStructuredAgentSessionTransport(args: { sessionId, target, stateRef, - enabled + enabled, + ...(agentName ? { agentName } : {}) }) useEffect(() => { stateRef.current = read.state diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index b7d1b4a62454..746412bfb372 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -95,7 +95,8 @@ export function useStructuredAgentSession(args: { sessionId, target, isVisible, - enabled: transportEnabled + enabled: transportEnabled, + agentName: structuredAgentLabel(agent) }) const commandPending = useRef(false) const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId) @@ -141,7 +142,7 @@ export function useStructuredAgentSession(args: { hostStopping, mutate }) - const prompts = pendingStructuredSessionPrompts(transportState.journalItems) + const prompts = pendingStructuredSessionPrompts(transportState) const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts) // A send after a command the queue will run goes behind it, even with follow-ups off. // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot @@ -260,6 +261,8 @@ export function useStructuredAgentSession(args: { journalItems: transcriptItems, /** The host's newest turn record, which places a live turn whose record is not loaded. */ latestTurn: transportState.latestTurn, + /** The host's prompts still waiting on the person and its working answer. */ + hostWork: transportState.hostWork, subagentRoster: transportState.subagentRoster, messages, status: transportEnabled ? state.status : 'ready', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 5b4b77bb9cb0..5a3863f6759b 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2912,6 +2912,7 @@ "notSignedIn": "{{agent}} is not signed in.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", + "providerExitedAnswer": "{{agent}} has stopped, so your answer was not sent. Send a message to continue.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRejected": "{{agent}} didn't accept this message.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 66f4992dce06..b8a1e1914553 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18198,6 +18198,7 @@ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", + "providerExitedAnswer": "{{agent}} has stopped, so your answer was not sent. Send a message to continue.", "providerRejected": "{{agent}} didn't accept this message.", "providerRejectedQuoted": "{{agent}} didn't accept this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 57232c26e59b..370615cc3ad2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17978,6 +17978,7 @@ "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.", "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", + "providerExitedAnswer": "{{agent}} se detuvo, así que su respuesta no se envió. Envíe un mensaje para continuar.", "providerRejected": "{{agent}} no aceptó este mensaje.", "providerRejectedQuoted": "{{agent}} no aceptó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 32db61b1895e..9458d5b7e551 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18082,6 +18082,7 @@ "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.", "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", + "providerExitedAnswer": "{{agent}} s'est arrêté, donc votre réponse n'a pas été envoyée. Envoyez un message pour continuer.", "providerRejected": "{{agent}} n'a pas accepté ce message.", "providerRejectedQuoted": "{{agent}} n'a pas accepté ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 984eb256736e..f0fa92a87b3d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18013,6 +18013,7 @@ "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", + "providerExitedAnswer": "{{agent}} が停止したため、回答は送信されませんでした。続けるにはメッセージを送信してください。", "providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。", "providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした: {{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0ab82a19bcd9..597038d666e5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18013,6 +18013,7 @@ "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", + "providerExitedAnswer": "{{agent}}이(가) 중지되어 답변이 전송되지 않았습니다. 계속하려면 메시지를 보내세요.", "providerRejected": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다.", "providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d6e022f1fff5..8234f29b8cf7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17978,6 +17978,7 @@ "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。", "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", + "providerExitedAnswer": "{{agent}} 已停止,因此您的回答未发送。发送消息以继续。", "providerRejected": "{{agent}} 未接受此消息。", "providerRejectedQuoted": "{{agent}} 未接受此消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index b170098f61f9..c6f430a00c9a 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -79,6 +79,8 @@ export const AGENT_SESSION_FAILURE_COPY = { providerExitedRow: '{{agent}} stopped while this response was in progress. You can continue in this conversation.', providerExitedRejection: '{{agent}} stopped before this message was sent.', + providerExitedAnswer: + '{{agent}} has stopped, so your answer was not sent. Send a message to continue.', providerRejected: "{{agent}} didn't accept this message.", providerRejectedQuoted: "{{agent}} didn't accept this message: {{detail}}.", attachmentEmpty: 'An image on this message is empty, so the message was not sent.', diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index bfdc644b8bee..f0eff4cdbffa 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -24,7 +24,7 @@ import { /** What Orca's own text looks like: a code or marker, a uuid, a path, an exception. */ const ORCA_INTERNAL = /\b[a-z]+_[a-z_]+\b|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ -const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection'] +const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection', 'answer'] const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ 'cancelled', 'writeFailed', @@ -76,7 +76,11 @@ describe('the words written beside a failure fact', () => { expect(sentence).not.toMatch(ORCA_INTERNAL) if (surface === 'row') { expect(agentSessionFailureWords(fact, { ...context, surface }).text).toBe(sentence) - } else if (isSubmissionRejectionFact(fact) && !LEGACY_MARKER_KINDS.has(fact.kind)) { + } else if ( + surface === 'rejection' && + isSubmissionRejectionFact(fact) && + !LEGACY_MARKER_KINDS.has(fact.kind) + ) { expect(agentSessionFailureWords(fact, { ...context, surface }).reason).toBe(sentence) } } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 73263831d1c9..9a2572a9cd2a 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -55,8 +55,9 @@ export type AgentJournalDispatchRejection = { rejection: SubmissionRejectionFact } -/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. */ -export type AgentSessionFailureSurface = 'row' | 'rejection' +/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. + * `answer`: a refused answer to an approval or question, about it. */ +export type AgentSessionFailureSurface = 'row' | 'rejection' | 'answer' export type AgentSessionFailureWordsContext = { /** The chat's agent, when the writer knows it. */ @@ -217,7 +218,14 @@ const FAILURE_SENTENCES = { : say('chooseClaudeAccountThenSend') ]), providerExited: (context, _fact, surface, say) => - say(surface === 'row' ? 'providerExitedRow' : 'providerExitedRejection', agent(say, context)), + say( + surface === 'row' + ? 'providerExitedRow' + : surface === 'answer' + ? 'providerExitedAnswer' + : 'providerExitedRejection', + agent(say, context) + ), restartFailed: couldNot('couldNotRestart'), providerRejected: (context, fact, _surface, say) => quotingPersonDetail( diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 435e3cb8e097..8aea50584bf6 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -57,6 +57,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'rewindUnconfirmed', // A prompt card or an option 'promptGone', + /** The agent that raised the prompt has ended, so nothing can take its answer. */ + 'promptOwnerEnded', 'optionRejected', // The provider 'providerStarting', diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 155affe2df72..1586c9c92b00 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -454,6 +454,12 @@ describe('the notice for every reason a host names', () => { } const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) const answeredAway = write === 'answer' && parts.includes('questionChanged') + // "...so your answer was not sent" is that sentence's own. + const ownerStopped = + write === 'answer' && + parts.some( + (part) => typeof part === 'object' && 'surface' in part && part.surface === 'answer' + ) const unsupported = failure.code === 'structured_agent_session_unsupported' && failure.details?.reason !== 'hostUnsupported' && @@ -475,7 +481,12 @@ describe('the notice for every reason a host names', () => { failure.code === 'agent_session_operation_invalid' && (failure.details?.reason === 'stopFailed' || failure.details?.reason === 'cancelNotSaved') expect(notDone, cell).toEqual( - answeredAway || unsupported || saysNotDone || clearWhileWorking || stopFailed + answeredAway || + ownerStopped || + unsupported || + saysNotDone || + clearWhileWorking || + stopFailed ? [] : [NOT_DONE[write]] ) @@ -549,6 +560,19 @@ describe('the notice for every reason a host names', () => { }) }) +describe('an answer to a prompt whose agent stopped', () => { + it('names the agent the client knows, on the phone and on desktop alike', () => { + const refusal = { + code: 'agent_session_operation_invalid' as const, + message: HOST_TEXT, + details: { reason: 'promptOwnerEnded' as const } + } + expect(agentSessionRefusalNotice(refusal, 'answer', { agentName: 'Codex' })).toBe( + 'Codex has stopped, so your answer was not sent. Send a message to continue.' + ) + }) +}) + describe('a refusal from a host that names no reason this build knows', () => { it.each([ ['an older host', undefined], diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index a4aeb90752b3..e611a1af7e66 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -152,6 +152,11 @@ function reasonParts( return context.retryControl && words.action === 'retry' ? said : [...said, words.step] } if ('fact' in words) { + if (write === 'answer' && words.fact === 'providerExited') { + // An approval or question was waiting on the person when its agent stopped: one sentence + // that says the answer was not sent, names the agent, and says how to go on. + return [{ failure: { kind: words.fact }, surface: 'answer', context }] + } return write === 'send' || write === 'composer-send' ? [ NOT_DONE[write], @@ -279,10 +284,11 @@ export function agentSessionWriteNoticeEnglish( * message is not read. */ export function agentSessionRefusalNotice( refusal: Pick, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): string { return agentSessionWriteNoticeEnglish( - agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write) + agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write, context) ) } diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index d26194911538..4c9da698eb58 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -99,6 +99,9 @@ const REASON_WORDS = { rewindRefused: codeWords('hostFinding'), rewindUnconfirmed: codeWords('hostFinding'), promptGone: causeWords('questionChanged', 'nothingLeft'), + // An approval or question waited on the person when its agent stopped: said naming the agent, + // with sending a message as the step on. + promptOwnerEnded: { fact: 'providerExited', action: 'nothingLeft' }, optionRejected: causeWords('optionRejected', 'retry'), providerStarting: AGENT_STARTING, goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), diff --git a/src/shared/native-chat-messages-waiting-behind-live-turn.ts b/src/shared/native-chat-messages-waiting-behind-live-turn.ts index 767bb1e8c826..6c42a8029dbc 100644 --- a/src/shared/native-chat-messages-waiting-behind-live-turn.ts +++ b/src/shared/native-chat-messages-waiting-behind-live-turn.ts @@ -1,10 +1,11 @@ // Which rows wait at the tail, after the live turn's activity line, rather than in the transcript. import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionLatestTurn } from './agent-session-wire' import { readAgentJournalTurn } from './agent-session-turn-record' import type { NativeChatRole } from './native-chat-types' import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' -import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' +import { runningStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' import { structuredAgentSessionOpeningSendIn } from './structured-agent-session-opening-send' /** @@ -30,7 +31,10 @@ export function nativeChatMessagesWaitingBehindLiveTurn( }[], items: readonly AgentJournalRenderItem[] | null | undefined, stopping = false, - submissions?: readonly AgentJournalSubmission[] + submissions?: readonly AgentJournalSubmission[], + /** The host's view of current work (`latestTurn`, `working`): an ended generation's running turn + * or unanswered send holds nothing. Absent from an older host, whose rows are read instead. */ + host: { latestTurn?: AgentSessionLatestTurn | null; working?: boolean } = {} ): ReadonlySet { const waits = (message: (typeof messages)[number]): boolean => message.queued === true || (stopping && message.sentWhileStopping === true) @@ -45,9 +49,11 @@ export function nativeChatMessagesWaitingBehindLiveTurn( if (candidates.length === 0 || !items) { return new Set() } - const behindHold = stopping || commandTurnRunning(items) + const behindHold = stopping || commandTurnRunning(items, host.latestTurn) const behindOpening = - submissions !== undefined && structuredAgentSessionOpeningSendIn(items, submissions) !== null + host.working !== false && + submissions !== undefined && + structuredAgentSessionOpeningSendIn(items, submissions) !== null return new Set( candidates .filter( @@ -59,8 +65,14 @@ export function nativeChatMessagesWaitingBehindLiveTurn( ) } -function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { - const running = liveStructuredAgentSessionTurnScope(items) +function commandTurnRunning( + items: readonly AgentJournalRenderItem[], + latestTurn: AgentSessionLatestTurn | null | undefined +): boolean { + const running = runningStructuredAgentSessionTurnScope({ + items, + ...(latestTurn !== undefined ? { latestTurn } : {}) + }) const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body return ( running.kind === 'turn' && diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 40dbf096e2ef..43c28719dda2 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -46,6 +46,8 @@ export type NativeChatTurnJournal = { * record is not loaded, so the turn's loaded rows still draw under its bar. Absent from older * hosts. */ latestTurn?: AgentSessionLatestTurn | null + /** The host's answer to whether the session's own agent is working; absent from older hosts. */ + working?: boolean } /** diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index 69013756ac2d..86f949dfd71a 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -1,5 +1,9 @@ import type { AgentSessionSubscribeEvent } from './agent-session-wire' -import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' +import { + actionablePromptIdsAfterStructuredAgentSessionBatch, + hostWorkingAfterStructuredAgentSessionBatch, + latestTurnAfterStructuredAgentSessionBatch +} from './structured-agent-session-live-turn' export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48 @@ -29,6 +33,11 @@ function mergeBatch( } // As applying both in turn would leave it, so an older host's rows still drop a stale claim. const latestTurn = latestTurnAfterStructuredAgentSessionBatch(left.latestTurn, right) + const actionablePromptIds = actionablePromptIdsAfterStructuredAgentSessionBatch( + left.actionablePromptIds, + right + ) + const working = hostWorkingAfterStructuredAgentSessionBatch(left.working, right) return { type: 'batch', ...(right.commands !== undefined || left.commands !== undefined @@ -56,6 +65,8 @@ function mergeBatch( ? { activity: right.activity !== undefined ? right.activity : (left.activity ?? null) } : {}), ...(latestTurn !== undefined ? { latestTurn } : {}), + ...(actionablePromptIds !== undefined ? { actionablePromptIds } : {}), + ...(working !== undefined ? { working } : {}), // The later page's: a merged frame ends the catch-up only if its last page did. ...(right.hasNewer ? { hasNewer: true as const } : {}) } diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 94d3ac921735..0316defad8f2 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -175,6 +175,70 @@ export function latestTurnAfterStructuredAgentSessionBatch( return carriesRows || event.latestTurn !== undefined ? event.latestTurn : previous } +/** The host's actionable prompts once `event` applies, under `latestTurn`'s rule: a batch carrying + * rows restates them, so one without them came from an older host and drops the claim. */ +export function actionablePromptIdsAfterStructuredAgentSessionBatch( + previous: string[] | undefined, + event: Extract +): string[] | undefined { + const { items, removedItemIds, submissions } = event.batch + const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0 + return carriesRows || event.actionablePromptIds !== undefined + ? event.actionablePromptIds + : previous +} + +/** The host's working answer once `event` applies, under `latestTurn`'s rule. */ +export function hostWorkingAfterStructuredAgentSessionBatch( + previous: boolean | undefined, + event: Extract +): boolean | undefined { + const { items, removedItemIds, submissions } = event.batch + const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0 + return carriesRows || event.working !== undefined ? event.working : previous +} + +/** Whether a frame's turn, prompts and working answer, when it names them, are what the state + * already holds: a generation's end can change them with no row, so a frame that does is never a + * no-op. */ +export function restatesStructuredAgentSessionHostWork( + event: Pick< + Extract, + 'latestTurn' | 'actionablePromptIds' | 'working' + >, + state: { + latestTurn?: AgentSessionLatestTurn | null + actionablePromptIds?: string[] + working?: boolean + } +): boolean { + return ( + (event.latestTurn === undefined || + JSON.stringify(event.latestTurn) === JSON.stringify(state.latestTurn)) && + (event.actionablePromptIds === undefined || + event.actionablePromptIds.join('\n') === state.actionablePromptIds?.join('\n')) && + (event.working === undefined || event.working === state.working) + ) +} + +/** Whether the session's own agent is working: the host's answer when it gave one, else what the + * client derives from its rows (an older host). */ +export function structuredAgentSessionHostSaysWorking( + hostWorking: boolean | undefined, + derived: () => boolean +): boolean { + return hostWorking ?? derived() +} + +/** Whether a pending prompt waits on the person: the host names the ones that do, and a prompt an + * agent that has ended raised is not among them. An older host names none: every pending one. */ +export function isActionableStructuredAgentSessionPrompt( + itemId: string, + actionablePromptIds: readonly string[] | undefined +): boolean { + return actionablePromptIds === undefined || actionablePromptIds.includes(itemId) +} + /** * Whether the newest thing the active turn produced is the model's own reasoning. * diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 1592e949b1b2..8a8343a1e904 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -595,6 +595,60 @@ describe('structured agent session reducer', () => { }) }) + it("keeps the host's working answer and prompts across a quiet frame, takes a row-less restatement, and drops them for rows that name none", () => { + const pending: AgentJournalRenderItem = { + itemId: 'prompt', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } + const frame = ( + items: AgentJournalRenderItem[], + host: { working?: boolean; actionablePromptIds?: string[] } = {} + ) => ({ + type: 'event' as const, + event: { + type: 'batch' as const, + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 1 }, + items, + removedItemIds: [], + submissions: [] + }, + ...host + } + }) + const opened = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: { ...hydrationPage([pending]), working: true, actionablePromptIds: ['prompt'] } + } + }) + const quiet = reduceStructuredAgentSession(opened, frame([])) + expect(quiet).toMatchObject({ working: true, actionablePromptIds: ['prompt'] }) + // The generation ended with no row written: the host restates both. + const ended = reduceStructuredAgentSession( + quiet, + frame([], { working: false, actionablePromptIds: [] }) + ) + expect(ended).toMatchObject({ working: false, actionablePromptIds: [] }) + // Rows from a downgraded host name neither: back to deriving from rows. + const downgraded = reduceStructuredAgentSession(ended, frame([{ ...pending, revision: 2 }])) + expect(downgraded.working).toBeUndefined() + expect(downgraded.actionablePromptIds).toBeUndefined() + }) + it('records the host clock from frames that carry it and keeps it otherwise', () => { const snapshot = reduceStructuredAgentSession( EMPTY_STRUCTURED_AGENT_SESSION, diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 6ff6124feb38..f20c673e7969 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -26,7 +26,12 @@ import { import { compareAgentJournalItems } from './agent-session-journal-position' import { readAgentJournalTurn } from './agent-session-turn-record' import { queuePublicationField } from './structured-agent-session-queue-publication-field' -import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' +import { + actionablePromptIdsAfterStructuredAgentSessionBatch, + hostWorkingAfterStructuredAgentSessionBatch, + latestTurnAfterStructuredAgentSessionBatch, + restatesStructuredAgentSessionHostWork +} from './structured-agent-session-live-turn' import { foldStructuredAgentSubagentRoster, foldStructuredAgentSubagentRosterPage, @@ -75,6 +80,12 @@ export type StructuredAgentSessionState = { /** The host's newest turn record over the whole journal, which says whether a turn runs; absent * from an older host, whose answer is read off `items` instead. */ latestTurn?: AgentSessionLatestTurn | null + /** The prompts the host says still wait on the person (`AgentSessionHistoryPage`); absent from + * an older host, whose every pending prompt in `items` is read as waiting. */ + actionablePromptIds?: string[] + /** Whether the host says the session's own agent is working (`AgentSessionHistoryPage.working`); + * absent from an older host, whose answer is derived from `items` and `submissions`. */ + working?: boolean /** Bumped per live batch that leaves a turn row's newest revision outside the window * (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */ unloadedTurnRevisions?: number @@ -130,6 +141,10 @@ function replacePage( subagentRoster: foldStructuredAgentSubagentRosterPage(undefined, page), activity: activity ?? null, ...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}), + ...(page.actionablePromptIds !== undefined + ? { actionablePromptIds: page.actionablePromptIds } + : {}), + ...(page.working !== undefined ? { working: page.working } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : page.backgroundTasks !== undefined @@ -275,6 +290,7 @@ export function reduceStructuredAgentSession( (event.nextQueuedMessageId === undefined || event.nextQueuedMessageId === state.nextQueuedMessageId) && backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) && + restatesStructuredAgentSessionHostWork(event, state) && activity?.turnId === state.activity?.turnId && activity?.text === state.activity?.text && state.status === 'ready' && @@ -310,6 +326,11 @@ export function reduceStructuredAgentSession( readRefusal: undefined, commands: event.commands !== undefined ? event.commands : state.commands, latestTurn: latestTurnAfterStructuredAgentSessionBatch(state.latestTurn, event), + actionablePromptIds: actionablePromptIdsAfterStructuredAgentSessionBatch( + state.actionablePromptIds, + event + ), + working: hostWorkingAfterStructuredAgentSessionBatch(state.working, event), ...queuePublicationField(event, state), backgroundTasks, ...(activity !== undefined ? { activity } : {}),