mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
|
||||
+41
-36
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+155
@@ -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' &&
|
||||
|
||||
+13
-5
@@ -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(
|
||||
|
||||
+28
-3
@@ -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,
|
||||
|
||||
+72
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ó.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -18015,6 +18015,7 @@
|
||||
"chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。",
|
||||
"providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。",
|
||||
"providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。",
|
||||
"providerExitedAnswer": "{{agent}} が停止したため、回答は送信されませんでした。続けるにはメッセージを送信してください。",
|
||||
"providerRejected": "{{agent}} はこのメッセージを受け付けませんでした。",
|
||||
"providerRejectedQuoted": "{{agent}} はこのメッセージを受け付けませんでした: {{detail}}。",
|
||||
"attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。",
|
||||
|
||||
@@ -18015,6 +18015,7 @@
|
||||
"chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.",
|
||||
"providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.",
|
||||
"providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.",
|
||||
"providerExitedAnswer": "{{agent}}이(가) 중지되어 답변이 전송되지 않았습니다. 계속하려면 메시지를 보내세요.",
|
||||
"providerRejected": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다.",
|
||||
"providerRejectedQuoted": "{{agent}}이(가) 이 메시지를 받아들이지 않았습니다: {{detail}}.",
|
||||
"attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.",
|
||||
|
||||
@@ -17980,6 +17980,7 @@
|
||||
"chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。",
|
||||
"providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。",
|
||||
"providerExitedRejection": "{{agent}} 在发送此消息前已停止。",
|
||||
"providerExitedAnswer": "{{agent}} 已停止,因此您的回答未发送。发送消息以继续。",
|
||||
"providerRejected": "{{agent}} 未接受此消息。",
|
||||
"providerRejectedQuoted": "{{agent}} 未接受此消息:{{detail}}。",
|
||||
"attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user