Native chat: desktop and mobile read the host's working answer; plain refusal for a stopped agent's prompt (3/5, rebuild of #26867) (#28104)

* feat(native-chat): record which agent run wrote each journal item

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.

* feat(native-chat): one current-work projection for every host reader; record death first

The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.

* feat(native-chat): clients read the host's working answer; refuse answers to a stopped agent plainly

Desktop and mobile prefer the host's working and actionablePromptIds fields
when present (merged across batches like latestTurn) and fall back to today's
row derivation against an older host. A prompt an ended agent raised stops
looking active; answering it is refused before any provider sees it, with
"<Agent> has stopped, so your answer was not sent. Send a message to continue."
Cancelling it only dismisses it in the journal: it no longer triggers the
chat's Stop, pauses the queue or withdraws queued cards.

* refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.

* test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).

* test(native-chat): drop current-work mirrors: the viewKey unit case and the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.

* test(native-chat): fewer client tests: one flow per client, one fallback each

Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows.

* test(native-chat): an unstamped roster entry from an older build reads as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.

* test(native-chat): build the older-build roster row inline, after main removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.

* refactor(native-chat): provenance is per item only; drop per-entry stamping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.

* fix(native-chat): an item's provenance is set when it is created; revisions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.

* test(native-chat): keep the provenance test below the production lines it covers

* fix(native-chat): an ended agent's card Cancel commits with its receipt, through dismissPrompt

The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation
receipt and without the dismissal's failure handling, so a receipt write failing after it would
answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which
carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a
null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's
adapter refuses a fence with no live session outright).
This commit is contained in:
Brennan Benson
2026-10-14 23:41:52 -07:00
committed by GitHub
parent 9ebdc68294
commit d9de1fad1d
52 changed files with 860 additions and 158 deletions
@@ -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',
@@ -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<StructuredAgentSessionState, 'items' | 'actionablePromptIds'>
/** 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
)
}
@@ -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<StructuredAgentSessionState, 'items' | 'actionablePromptIds'>
): 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
@@ -127,6 +127,8 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
fields: Record<string, unknown>
clientOperationId?: string
timeoutMs?: number
/** The chat's agent, which a refusal about it names. */
agentName?: string
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
const {
client,
@@ -136,8 +138,10 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
expectedRuntimeFence,
fields,
clientOperationId,
timeoutMs
timeoutMs,
agentName
} = args
const context = agentName ? { agentName } : {}
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
client,
@@ -171,7 +175,8 @@ export async function requestStructuredAgentSessionMutation<TValue>(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<TValue>(args: {
return {
status: 'failed',
message: agentSessionWriteNoticeEnglish(
agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields))
agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields), context)
)
}
}
@@ -145,7 +145,8 @@ export function useMobileNativeChatTurnDisclosure({
rows,
turnJournal?.items,
stopping,
turnJournal?.submissions
turnJournal?.submissions,
turnJournal ?? {}
)
: null
if (!ids?.size) {
@@ -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 <TValue>(
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]
)
}
@@ -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 }))
@@ -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,
@@ -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<StructuredAgentTurnClockLatch | null>(null)
// Stamp during render (React's derive-from-props pattern) so the first paint of
@@ -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<boolean> => {
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<boolean> => {
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({
@@ -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'
@@ -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<AgentSessionSubscribeEvent, { type: 'batch' }>,
'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')
})
})
@@ -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<typeof provider>
): 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<StructuredAgentSessionChatStopRun> => ({
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)
})
})
@@ -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<CancelOutcome>) | null
): Promise<CancelOutcome> {
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<TurnOutcome<null>> {
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)
@@ -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<AgentJournalItemBody, { kind: 'approval' | 'question' }>
@@ -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<AgentSessionTurnContext, 'journal' | 'fence' | 'currentWork'>,
pending: Extract<PendingPromptValidation, { ok: true }>
): boolean {
return !contextStructuredAgentSessionCurrentWork(ctx).isCurrentItem(pending.item.itemId)
}
@@ -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
)
@@ -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<typeof useNativeChatSubagentSections>[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<NativeChatNavigationRequest | null>(
null
)
const [navigationRequest, setNavigationRequest] = useState<NavigationRequest | null>(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<NativeChatTranscriptRowContext>(
() => ({
agentName: structuredAgentLabel(session.agent),
actionablePromptIds: hostWork?.actionablePromptIds,
expandSignal,
revealedDiff,
taskListPredecessors,
@@ -355,6 +354,7 @@ export function NativeChatMessageList({
}),
[
session.agent,
hostWork,
allowFileUriLinks,
expandSignal,
expandedTurnIds,
@@ -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(<NativeChatResolutionReceipt body={body} />)
expect(screen.getByText('Awaiting user input:')).toHaveClass('animate-pulse')
rerender(<NativeChatResolutionReceipt body={body} awaiting={false} />)
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', {
@@ -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' ? (
<NativeChatAwaitingInputRow subject={subject} pending disclosureKey={askDisclosureKey} />
<NativeChatAwaitingInputRow
subject={subject}
pending={awaiting}
disclosureKey={askDisclosureKey}
/>
) : null
}
const { resolution } = body
@@ -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}
@@ -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<string, NativeChatTaskListPredecessors>
@@ -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 ? (
<NativeChatResolutionReceipt body={receipt} disclosureId={message.id} />
<NativeChatResolutionReceipt
body={receipt}
disclosureId={message.id}
awaiting={isActionableStructuredAgentSessionPrompt(
message.id,
context.actionablePromptIds
)}
/>
) : (
<MessageRow
message={message}
@@ -11,6 +11,7 @@ import {
import { AVAILABILITY_PIECES } from './agent-session-availability-words-text'
import { ATTACHMENT_FAILURE_PIECES } from './agent-session-failure-attachment-words-text'
import { STOP_FAILURE_PIECES } from './agent-session-failure-stop-words-text'
import { PROVIDER_EXIT_PIECES } from './agent-session-provider-exit-words-text'
import { PROVIDER_RETRY_PIECES } from './agent-session-provider-retry-words-text'
// The pieces a refusal notice says too keep the notice's keys, so each has one translation.
@@ -150,18 +151,7 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
'components.native-chat.failureWords.chooseClaudeAccountThenSend',
COPY.chooseClaudeAccountThenSend
),
providerExitedRow: (values) =>
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',
@@ -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<AgentSessionFailureCopyId, (values: AgentSessionFailureCopyValues) => string>
>
@@ -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))
}
@@ -322,13 +322,15 @@ export function splitNativeChatSlotsWaitingBehindLiveTurn(
slots: readonly NativeChatTranscriptSlot[],
journalItems: readonly AgentJournalRenderItem[] | undefined,
stopping = false,
journalSubmissions?: readonly AgentJournalSubmission[]
journalSubmissions?: readonly AgentJournalSubmission[],
host: Parameters<typeof nativeChatMessagesWaitingBehindLiveTurn>[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' &&
@@ -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<AgentJournalRenderItem['body'], { kind: 'approval' | 'question' }>
}
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)
)
}
@@ -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<StructuredAgentSessionState, string>()
@@ -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)
@@ -57,11 +57,16 @@ function namesWhatItStops(fingerprintMethod: string, fields: Record<string, unkn
function notDone(
failure: AgentSessionWriteFailure,
fingerprintMethod: string,
fields: Record<string, unknown>
fields: Record<string, unknown>,
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<string, Promise<StructuredAgentSessionWriteOutcome<unknown>>>()
)
@@ -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(
@@ -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<StructuredAgentSessionHostWork>(
() => ({
...(actionablePromptIds ? { actionablePromptIds } : {}),
...(hostWorking !== undefined ? { working: hostWorking } : {})
}),
[actionablePromptIds, hostWorking]
)
return {
journalItems,
latestTurn,
actionablePromptIds,
hostWork,
subagentRoster,
submissions,
fence,
@@ -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> = {}): 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<AgentSessionHistoryPage>): 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)
})
})
@@ -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
@@ -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',
+1
View File
@@ -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.",
+1
View File
@@ -18200,6 +18200,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.",
+1
View File
@@ -17980,6 +17980,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ó.",
+1
View File
@@ -18084,6 +18084,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é.",
+1
View File
@@ -18015,6 +18015,7 @@
"chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。",
"providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。",
"providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。",
"providerExitedAnswer": "{{agent}} が停止したため、回答は送信されませんでした。続けるにはメッセージを送信してください。",
"providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。",
"providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした: {{detail}}。",
"attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。",
+1
View File
@@ -18015,6 +18015,7 @@
"chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.",
"providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.",
"providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.",
"providerExitedAnswer": "{{agent}}이(가) 중지되어 답변이 전송되지 않았습니다. 계속하려면 메시지를 보내세요.",
"providerRejected": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다.",
"providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다: {{detail}}.",
"attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.",
+1
View File
@@ -17980,6 +17980,7 @@
"chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。",
"providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。",
"providerExitedRejection": "{{agent}} 在发送此消息前已停止。",
"providerExitedAnswer": "{{agent}} 已停止,因此您的回答未发送。发送消息以继续。",
"providerRejected": "{{agent}} 未接受此消息。",
"providerRejectedQuoted": "{{agent}} 未接受此消息:{{detail}}。",
"attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。",
+2
View File
@@ -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.',
@@ -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<SubmissionRejectionKind> = 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)
}
}
+11 -3
View File
@@ -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(
@@ -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',
@@ -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],
+8 -2
View File
@@ -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<AgentSessionWireRefusal, 'code' | 'message' | 'details'>,
write: AgentSessionWriteKind
write: AgentSessionWriteKind,
context: AgentSessionFailureWordsContext = {}
): string {
return agentSessionWriteNoticeEnglish(
agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write)
agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write, context)
)
}
@@ -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'),
@@ -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<string> {
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' &&
@@ -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
}
/**
@@ -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 } : {})
}
@@ -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<AgentSessionSubscribeEvent, { type: 'batch' }>
): 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<AgentSessionSubscribeEvent, { type: 'batch' }>
): 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<AgentSessionSubscribeEvent, { type: 'batch' }>,
'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.
*
@@ -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,
+22 -1
View File
@@ -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 } : {}),