mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
fix(orchestration): native-chat agents get their mail notice right away, the same way for every agent (#28540)
* fix(orchestration): steer mail notices into a busy native chat's running turn A native-chat coordinator's mail notice always queued as a card until the whole turn ended, so a coordinator busy in a long command never saw its workers' reports. Agents that take a message mid-turn (Claude, Codex, Pi) now get the notice at once; agents that would cancel their turn for it (ACP) still queue it. * fix(orchestration): drain mail that arrives while a notice is pending * Send orchestration mail the same way to every chat agent A mail notice is a plain send for every agent, as a person's own mid-turn message is. While a pending approval or question waits on the person it is held, and the turn's working edge after the answer sends it. * Hold orchestration mail at live prompt admission
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { openAttachedHostRig, send, hello, framesOf } from './acp-structured-host.test-support'
|
||||
import { PROVIDER_SESSION, waitFor } from './acp-structured-adapter.test-support'
|
||||
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
|
||||
import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { commandReceiptScope } from '../native-chat/agent-session-journal/command-receipt-schema'
|
||||
import { createStructuredMailboxPointerHost } from '../runtime/orchestration/structured-mailbox-pointer-host'
|
||||
import { mintAgentSessionOperationId } from '../runtime/orchestration/structured-pointer-operation-id'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
|
||||
afterEach(async () => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
await closeProviderTimelineRigs()
|
||||
})
|
||||
|
||||
it('holds mail without interrupting an answerable ACP question', async () => {
|
||||
const { host, rig } = await openAttachedHostRig()
|
||||
setStructuredAgentSessionHost(host)
|
||||
await send(host, 'work')
|
||||
await rig.frame('session/prompt')
|
||||
void rig.child().agent.request(601, 'x.ai/ask_user_question', {
|
||||
sessionId: PROVIDER_SESSION,
|
||||
toolCallId: 'question-1',
|
||||
questions: [{ question: 'Which file?', options: [{ label: 'a.ts' }] }]
|
||||
})
|
||||
const pointer = createStructuredMailboxPointerHost()
|
||||
await waitFor(() => expect(pointer.hasActionablePromptNow(SESSION)).toBe(true))
|
||||
const body: AgentJournalMessageItem = {
|
||||
...hello,
|
||||
from: {
|
||||
kind: 'agent',
|
||||
senders: [],
|
||||
orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] }
|
||||
}
|
||||
}
|
||||
const operationId = mintAgentSessionOperationId(Date.now())
|
||||
const expectedRuntimeFence = host.deps.store.getRecord(SESSION)!.lease.runtimeFence
|
||||
expect(
|
||||
await pointer.send({
|
||||
sessionId: SESSION,
|
||||
dispatchId: null,
|
||||
operationId,
|
||||
expectedRuntimeFence,
|
||||
body
|
||||
})
|
||||
).toEqual({ kind: 'held' })
|
||||
expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
|
||||
expect(
|
||||
host.deps.store.readCommandReceipt(commandReceiptScope('any', 'global'), operationId)
|
||||
).toMatchObject({ verdict: 'absent' })
|
||||
expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0)
|
||||
await host.close(SESSION, 'user-close')
|
||||
})
|
||||
@@ -109,7 +109,8 @@ describe('mail dispatch through the current runtime', () => {
|
||||
})
|
||||
|
||||
target.onStructuredSessionStatusForMail({ sessionId: 'session-mailbox', status: 'working' })
|
||||
expect(events).toEqual([])
|
||||
expect(events).toEqual(['journal:session-mailbox'])
|
||||
events.length = 0
|
||||
target.onStructuredSessionStatusForMail({ sessionId: 'session-mailbox', status: 'idle' })
|
||||
expect(events).toEqual(['journal:session-mailbox', 'delivery:term_mailbox'])
|
||||
})
|
||||
|
||||
@@ -129,6 +129,7 @@ async function renderChatPointer(mailbox: string): Promise<string> {
|
||||
senderName: () => null,
|
||||
host: {
|
||||
readSessionFacts: async () => ({ submissions: [] }),
|
||||
hasActionablePromptNow: () => false,
|
||||
currentFence: () => 1,
|
||||
currentContextClearOperationId: () => undefined,
|
||||
send: async (input) => {
|
||||
|
||||
@@ -41,6 +41,8 @@ export type StructuredSessionTurn = {
|
||||
operationId: string
|
||||
expectedRuntimeFence: number
|
||||
delivery: AgentTurnDelivery
|
||||
/** Host-local admission for an automated send. */
|
||||
beforeRun?: () => void
|
||||
}
|
||||
|
||||
export type StructuredSessionTurnSend = {
|
||||
@@ -134,7 +136,10 @@ async function sendStructuredSessionTurn(
|
||||
body: turn.body,
|
||||
delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined
|
||||
})
|
||||
const result = await send.host.send({ callerKey: send.callerKey }, message)
|
||||
const result = await send.host.send(
|
||||
{ callerKey: send.callerKey },
|
||||
turn.beforeRun ? { ...message, beforeRun: turn.beforeRun } : message
|
||||
)
|
||||
if (!result.ok) {
|
||||
return { kind: 'refused', refusal: result.refusal }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type StructuredPointerSubmission
|
||||
} from './structured-pointer-operation-id'
|
||||
import type { StructuredWorkerIdentity } from '../structured-worker-identity'
|
||||
import { RuntimeMessageWaiters } from '../runtime-message-waiters'
|
||||
|
||||
const IDENTITY: StructuredWorkerIdentity = {
|
||||
handle: 'structworker_1',
|
||||
@@ -32,10 +33,12 @@ function harness(options: {
|
||||
/** The mailbox this worker owns; its own handle for direct peer mail outside a dispatch. */
|
||||
mailbox?: string
|
||||
dispatchId?: string | null
|
||||
waiters?: RuntimeMessageWaiters
|
||||
}) {
|
||||
const mailbox = options.mailbox ?? 'dispatch:d1'
|
||||
const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId
|
||||
let attached = options.attached ?? true
|
||||
let awaitingHuman = false
|
||||
// The session's recorded sends, as its journal reports them.
|
||||
let submissions: StructuredPointerSubmission[] = []
|
||||
// The mailbox's unread mail; a pointed message is no longer selected for a pointer.
|
||||
@@ -49,9 +52,11 @@ function harness(options: {
|
||||
}
|
||||
})
|
||||
const send: StructuredMailboxPointerHost['send'] = vi.fn(async () =>
|
||||
options.queued
|
||||
? { kind: 'queued' as const }
|
||||
: { kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) }
|
||||
awaitingHuman
|
||||
? { kind: 'held' as const }
|
||||
: options.queued
|
||||
? { kind: 'queued' as const }
|
||||
: { kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) }
|
||||
)
|
||||
const sendMock = vi.mocked(send)
|
||||
const readContext = vi.fn<StructuredMailboxPointerHost['currentContextClearOperationId']>(
|
||||
@@ -63,7 +68,14 @@ function harness(options: {
|
||||
hasOutstandingMailboxDelivery: (handle: string) =>
|
||||
((options.outstandingRunDelivery ?? false) && handle.startsWith('run:')) ||
|
||||
((options.outstandingOwnDelivery ?? false) && !handle.startsWith('run:')),
|
||||
getUndeliveredUnreadMessages: () => mail.filter((message) => !pointed.has(message.id)),
|
||||
getUndeliveredUnreadMessages: (
|
||||
_handle: string,
|
||||
_filter: unknown,
|
||||
input: { excludeTypes: readonly string[] }
|
||||
) =>
|
||||
mail.filter(
|
||||
(message) => !pointed.has(message.id) && !input.excludeTypes.includes(message.type)
|
||||
),
|
||||
markAsDelivered,
|
||||
getStructuredPointerOperation: (key: string) => stored.get(key),
|
||||
putStructuredPointerOperation: (row: StructuredPointerOperationRow) =>
|
||||
@@ -72,13 +84,14 @@ function harness(options: {
|
||||
}
|
||||
const delivery = new OrchestrationStructuredMailboxPointerDelivery({
|
||||
getDb: () => db as never,
|
||||
getMessageWaiters: () => undefined,
|
||||
getMessageWaiters: (handle) => options.waiters?.get(handle),
|
||||
resolveStructuredTarget: (mailboxHandle) =>
|
||||
mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null,
|
||||
getCliCommand: () => 'orca-dev',
|
||||
senderName: (party) => (party.address === 'term_coord' ? 'Coordinator' : null),
|
||||
host: {
|
||||
readSessionFacts: async () => (attached ? { submissions } : null),
|
||||
hasActionablePromptNow: () => awaitingHuman,
|
||||
currentFence: () => 4,
|
||||
currentContextClearOperationId: readContext,
|
||||
send
|
||||
@@ -93,11 +106,14 @@ function harness(options: {
|
||||
setAttached: (next: boolean) => {
|
||||
attached = next
|
||||
},
|
||||
receive: (id: string, sequence: number) => {
|
||||
mail.push({ id, type: 'status', sequence, from_handle: 'term_coord', run_id: 'run_1' })
|
||||
receive: (id: string, sequence: number, type = 'status') => {
|
||||
mail.push({ id, type, sequence, from_handle: 'term_coord', run_id: 'run_1' })
|
||||
},
|
||||
setSubmissions: (next: StructuredPointerSubmission[]) => {
|
||||
submissions = next
|
||||
},
|
||||
setAwaitingHuman: (next: boolean) => {
|
||||
awaitingHuman = next
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +227,20 @@ describe('structured mailbox pointer delivery', () => {
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
|
||||
})
|
||||
|
||||
it('rechecks a held notice when the answer edge lands during its host call', async () => {
|
||||
const { delivery, send, markAsDelivered, setAwaitingHuman } = harness({})
|
||||
setAwaitingHuman(true)
|
||||
send.mockImplementationOnce(async () => {
|
||||
setAwaitingHuman(false)
|
||||
delivery.onJournalActivity('session-1')
|
||||
return { kind: 'held' }
|
||||
})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
|
||||
})
|
||||
|
||||
it('sends the pointer through the chat, with who it is from, and counts it pointed once queued', async () => {
|
||||
const { delivery, send, markAsDelivered, stored } = harness({ queued: true })
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
@@ -250,6 +280,38 @@ describe('structured mailbox pointer delivery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('points a second report after its filtered waiter expires during first-notice acceptance', async () => {
|
||||
const waiters = new RuntimeMessageWaiters()
|
||||
const { delivery, send, receive, markAsDelivered } = harness({
|
||||
mailbox: 'run:run_1',
|
||||
waiters
|
||||
})
|
||||
const waiting = waiters.wait('run:run_1', { typeFilter: ['worker_done'], timeoutMs: 40 })
|
||||
const accepted = Promise.withResolvers<void>()
|
||||
send.mockImplementationOnce(async () => {
|
||||
await accepted.promise
|
||||
return { kind: 'sent', state: 'accepted' }
|
||||
})
|
||||
delivery.deliverForHandle('run:run_1', new Set(['worker_done']))
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
expect(await waiting).toBe('timed_out')
|
||||
receive('m2', 4, 'worker_done')
|
||||
delivery.deliverForHandle('run:run_1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
accepted.resolve()
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2))
|
||||
expect(send.mock.calls[1]![0].body.from).toMatchObject({
|
||||
orchestration: { messages: [{ messageId: 'm2' }] }
|
||||
})
|
||||
expect(markAsDelivered.mock.calls).toEqual([[['m1']], [['m2']]])
|
||||
delivery.onJournalActivity('session-1')
|
||||
delivery.deliverForHandle('run:run_1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('nudges the worker while its coordinator holds an unacked Run delivery', async () => {
|
||||
// The exact window in which a coordinator replies to its workers: it checked, is acting on the
|
||||
// batch, and has not acked yet. The gate is keyed on the handle being nudged, so the
|
||||
@@ -442,6 +504,7 @@ describe('forgetting one settled worker', () => {
|
||||
function twoWorkerHarness() {
|
||||
let resolves = true
|
||||
let attached = false
|
||||
const pointed = new Set<string>()
|
||||
const sessionByMailbox: Record<string, string> = {
|
||||
'dispatch:d1': 'session-1',
|
||||
'dispatch:d2': 'session-2'
|
||||
@@ -453,10 +516,11 @@ describe('forgetting one settled worker', () => {
|
||||
const db = {
|
||||
getDispatchContextById: () => ({ run_id: 'run_1' }),
|
||||
hasOutstandingMailboxDelivery: () => false,
|
||||
getUndeliveredUnreadMessages: () => [
|
||||
{ id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' }
|
||||
],
|
||||
markAsDelivered: vi.fn(),
|
||||
getUndeliveredUnreadMessages: () =>
|
||||
pointed.has('m1')
|
||||
? []
|
||||
: [{ id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' }],
|
||||
markAsDelivered: vi.fn((ids: string[]) => ids.forEach((id) => pointed.add(id))),
|
||||
getStructuredPointerOperation: () => undefined,
|
||||
putStructuredPointerOperation: () => {},
|
||||
deleteStructuredPointerOperation: () => {}
|
||||
@@ -474,6 +538,7 @@ describe('forgetting one settled worker', () => {
|
||||
senderName: () => null,
|
||||
host: {
|
||||
readSessionFacts: async () => (attached ? { submissions: [] } : null),
|
||||
hasActionablePromptNow: () => false,
|
||||
currentFence: () => 4,
|
||||
currentContextClearOperationId: () => undefined,
|
||||
send
|
||||
@@ -575,6 +640,7 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
senderName: () => null,
|
||||
host: {
|
||||
readSessionFacts: async () => ({ submissions: [] }),
|
||||
hasActionablePromptNow: () => false,
|
||||
currentFence: () => 4,
|
||||
currentContextClearOperationId: () => contextClearOperationId,
|
||||
send
|
||||
|
||||
@@ -51,6 +51,7 @@ type ParkedPointerDelivery = {
|
||||
|
||||
export type StructuredPointerSendOutcome =
|
||||
| { kind: 'sent'; state: StructuredDispatchState }
|
||||
| { kind: 'held' }
|
||||
/** The chat's queue took it, as it takes a person's message. */
|
||||
| { kind: 'queued' }
|
||||
| { kind: 'unattached' }
|
||||
@@ -63,6 +64,7 @@ export type StructuredPointerSessionFacts = {
|
||||
export type StructuredMailboxPointerHost = {
|
||||
/** `null` when the session cannot be read. */
|
||||
readSessionFacts: (sessionId: string) => Promise<StructuredPointerSessionFacts | null>
|
||||
hasActionablePromptNow: (sessionId: string) => boolean | null
|
||||
send: (input: {
|
||||
sessionId: string
|
||||
dispatchId: string | null
|
||||
@@ -168,56 +170,92 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
reservedTypes?: ReadonlySet<string>,
|
||||
attemptedContexts = new Set<string>()
|
||||
): Promise<void> {
|
||||
const db = this.deps.getDb()
|
||||
if (!db || this.inFlight.has(mailboxHandle)) {
|
||||
return
|
||||
}
|
||||
// Don't re-nudge a mailbox whose consumer still holds an unacknowledged batch. The lookup is
|
||||
// keyed on the exact handle being nudged, so a coordinator's own `run:` delivery is invisible
|
||||
// to a worker's `dispatch:` gate and cannot suppress the nudges a coordinator sends its
|
||||
// workers. Worth more here than in the PTY lane: a structured nudge costs a whole provider
|
||||
// turn, not a line of text into a composer.
|
||||
if (db.hasOutstandingMailboxDelivery?.(mailboxHandle)) {
|
||||
return
|
||||
}
|
||||
const unread = selectOrchestrationPointerBatch({
|
||||
db,
|
||||
mailboxHandle,
|
||||
waiters: this.deps.getMessageWaiters(mailboxHandle),
|
||||
reservedTypes
|
||||
})
|
||||
if (unread.length === 0) {
|
||||
if (this.inFlight.has(mailboxHandle)) {
|
||||
return
|
||||
}
|
||||
this.inFlight.add(mailboxHandle)
|
||||
let current = target
|
||||
let currentReservedTypes = reservedTypes
|
||||
let contextKey: string | undefined
|
||||
let progressed = false
|
||||
try {
|
||||
const session = await this.deps.host.readSessionFacts(target.sessionId)
|
||||
const contextClearOperationId = this.deps.host.currentContextClearOperationId(
|
||||
target.sessionId
|
||||
)
|
||||
contextKey = JSON.stringify([target.sessionId, contextClearOperationId])
|
||||
if (attemptedContexts.has(contextKey)) {
|
||||
return
|
||||
for (;;) {
|
||||
const db = this.deps.getDb()
|
||||
// The consumer's own unacknowledged batch suppresses nudges only for this exact mailbox.
|
||||
if (!db || db.hasOutstandingMailboxDelivery?.(mailboxHandle)) {
|
||||
return
|
||||
}
|
||||
const unread = selectOrchestrationPointerBatch({
|
||||
db,
|
||||
mailboxHandle,
|
||||
waiters: this.deps.getMessageWaiters(mailboxHandle),
|
||||
reservedTypes: currentReservedTypes
|
||||
})
|
||||
if (unread.length === 0) {
|
||||
return
|
||||
}
|
||||
this.parkedUntilJournalEdge.set(mailboxHandle, {
|
||||
sessionId: current.sessionId,
|
||||
reservedTypes: currentReservedTypes
|
||||
})
|
||||
const session = await this.deps.host.readSessionFacts(current.sessionId)
|
||||
const contextClearOperationId = this.deps.host.currentContextClearOperationId(
|
||||
current.sessionId
|
||||
)
|
||||
contextKey = JSON.stringify([current.sessionId, contextClearOperationId])
|
||||
if (!progressed && attemptedContexts.has(contextKey)) {
|
||||
return
|
||||
}
|
||||
attemptedContexts.add(contextKey)
|
||||
let attempt = await this.attempt(
|
||||
db,
|
||||
mailboxHandle,
|
||||
current,
|
||||
unread,
|
||||
currentReservedTypes,
|
||||
session,
|
||||
contextClearOperationId
|
||||
)
|
||||
while (
|
||||
attempt === 'held' &&
|
||||
this.deps.host.hasActionablePromptNow(current.sessionId) === false
|
||||
) {
|
||||
attempt = await this.attempt(
|
||||
db,
|
||||
mailboxHandle,
|
||||
current,
|
||||
unread,
|
||||
currentReservedTypes,
|
||||
session,
|
||||
contextClearOperationId
|
||||
)
|
||||
}
|
||||
if (attempt === 'held') {
|
||||
this.retain(mailboxHandle, current.sessionId, 'turn-unsettled', currentReservedTypes)
|
||||
return
|
||||
}
|
||||
progressed = attempt
|
||||
if (!progressed) {
|
||||
return
|
||||
}
|
||||
// The arrival snapshot protects its first handoff; successors derive live waiter ownership.
|
||||
currentReservedTypes = undefined
|
||||
// Acceptance releases the snapshotted batch; rederive any mail that arrived meanwhile.
|
||||
this.parkedUntilJournalEdge.delete(mailboxHandle)
|
||||
const next = this.deps.resolveStructuredTarget(mailboxHandle)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
current = next
|
||||
}
|
||||
attemptedContexts.add(contextKey)
|
||||
await this.attempt(
|
||||
db,
|
||||
mailboxHandle,
|
||||
target,
|
||||
unread,
|
||||
reservedTypes,
|
||||
session,
|
||||
contextClearOperationId
|
||||
)
|
||||
} finally {
|
||||
this.inFlight.delete(mailboxHandle)
|
||||
// A thrown attempt follows too; its own failure is what still propagates.
|
||||
if (contextKey !== undefined) {
|
||||
await this.followChangedContext(
|
||||
mailboxHandle,
|
||||
target,
|
||||
reservedTypes,
|
||||
current,
|
||||
currentReservedTypes,
|
||||
attemptedContexts
|
||||
).catch(() => undefined)
|
||||
}
|
||||
@@ -258,16 +296,12 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
reservedTypes: ReadonlySet<string> | undefined,
|
||||
session: StructuredPointerSessionFacts | null,
|
||||
contextClearOperationId: string | undefined
|
||||
): Promise<void> {
|
||||
): Promise<boolean | 'held'> {
|
||||
const sessionId = target.sessionId
|
||||
if (!session) {
|
||||
this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes)
|
||||
return
|
||||
}
|
||||
const fence = this.deps.host.currentFence(sessionId)
|
||||
if (fence === null) {
|
||||
if (!session || fence === null) {
|
||||
this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes)
|
||||
return
|
||||
return false
|
||||
}
|
||||
const body: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
@@ -301,11 +335,11 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
db.markAsDelivered(staged)
|
||||
db.deleteStructuredPointerOperation(mailboxHandle)
|
||||
this.sentOperationIds.delete(mailboxHandle)
|
||||
return
|
||||
return true
|
||||
}
|
||||
if (operation.kind === 'park') {
|
||||
this.retain(mailboxHandle, sessionId, 'turn-unsettled', reservedTypes)
|
||||
return
|
||||
return false
|
||||
}
|
||||
this.sentOperationIds.set(mailboxHandle, operation.operationId)
|
||||
const outcome = await this.deps.host.send({
|
||||
@@ -315,21 +349,23 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
expectedRuntimeFence: fence,
|
||||
body
|
||||
})
|
||||
if (outcome.kind === 'held') {
|
||||
return 'held'
|
||||
}
|
||||
if (outcome.kind === 'unattached') {
|
||||
this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes)
|
||||
return
|
||||
return false
|
||||
}
|
||||
// A queued pointer is the chat's queue's to send, as a person's queued message is.
|
||||
if (outcome.kind === 'sent' && !structuredDispatchDelivered(outcome.state)) {
|
||||
// The row stays: resending under its id replays this verdict and starts nothing.
|
||||
this.retain(mailboxHandle, sessionId, retainReasonForDispatch(outcome.state), reservedTypes)
|
||||
return
|
||||
return false
|
||||
}
|
||||
db.markAsDelivered(staged)
|
||||
// The nudge landed as its own turn, so the next settle edge is the natural retry point for
|
||||
// anything that arrives while it runs.
|
||||
db.deleteStructuredPointerOperation(mailboxHandle)
|
||||
this.sentOperationIds.delete(mailboxHandle)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,7 +87,8 @@ describe('structured mailbox pointer host', () => {
|
||||
const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }]
|
||||
hostRef.current = {
|
||||
deps: { store: { getRecord: () => null } },
|
||||
journalSnapshot: () => ({ items: [], submissions })
|
||||
journalSnapshot: () => ({ items: [], submissions }),
|
||||
currentWork: () => null
|
||||
}
|
||||
expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toEqual({
|
||||
submissions
|
||||
@@ -115,10 +116,16 @@ describe('structured mailbox pointer host', () => {
|
||||
getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } })
|
||||
}
|
||||
},
|
||||
journalSnapshot: () => ({ items, submissions: [pointerSubmission('earlier', 2, 6), current] })
|
||||
journalSnapshot: () => ({
|
||||
items,
|
||||
submissions: [pointerSubmission('earlier', 2, 6), current]
|
||||
}),
|
||||
currentWork: () => null
|
||||
}
|
||||
const host = createStructuredMailboxPointerHost()
|
||||
expect(await host.readSessionFacts('s1')).toEqual({ submissions: [current] })
|
||||
expect(await host.readSessionFacts('s1')).toEqual({
|
||||
submissions: [current]
|
||||
})
|
||||
expect(host.currentContextClearOperationId('s1')).toBe('clear-1')
|
||||
})
|
||||
|
||||
@@ -133,10 +140,13 @@ describe('structured mailbox pointer host', () => {
|
||||
journalSnapshot: () => ({
|
||||
items: [],
|
||||
submissions: [pointerSubmission('earlier', 2, 2), current]
|
||||
})
|
||||
}),
|
||||
currentWork: () => null
|
||||
}
|
||||
const host = createStructuredMailboxPointerHost()
|
||||
expect(await host.readSessionFacts('s1')).toEqual({ submissions: [current] })
|
||||
expect(await host.readSessionFacts('s1')).toEqual({
|
||||
submissions: [current]
|
||||
})
|
||||
expect(host.currentContextClearOperationId('s1')).toBe('clear-1')
|
||||
})
|
||||
|
||||
@@ -192,14 +202,11 @@ describe('structured mailbox pointer host', () => {
|
||||
expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks a busy chat to queue the pointer as a card, with who it is from', async () => {
|
||||
it('sends the pointer as a plain send for every agent, with who it is from', async () => {
|
||||
const send = vi.fn(
|
||||
async (_caller: unknown, _payload: { delivery?: string; body?: unknown }) => ({
|
||||
ok: true,
|
||||
value: {
|
||||
clientMessageId: 'op1',
|
||||
queued: { messageId: 'op1', position: 0, state: 'waiting' }
|
||||
}
|
||||
value: { clientMessageId: 'op1', submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
)
|
||||
hostRef.current = { send }
|
||||
@@ -211,11 +218,9 @@ describe('structured mailbox pointer host', () => {
|
||||
expectedRuntimeFence: 1,
|
||||
body: { kind: 'message', role: 'user', blocks: [], from: NOTICE_SOURCE }
|
||||
})
|
||||
).resolves.toEqual({ kind: 'queued' })
|
||||
expect(send.mock.calls[0]![1]).toMatchObject({
|
||||
delivery: 'queue-if-active',
|
||||
body: { from: NOTICE_SOURCE }
|
||||
})
|
||||
).resolves.toEqual({ kind: 'sent', state: 'accepted' })
|
||||
expect(send.mock.calls[0]![1]).toMatchObject({ body: { from: NOTICE_SOURCE } })
|
||||
expect(send.mock.calls[0]![1]!.delivery).toBeUndefined()
|
||||
})
|
||||
|
||||
it('consumes mail once an accepted nudge is delivered while the worker starts (W10)', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
structuredSessionGateFacts,
|
||||
type StructuredSessionGateFacts
|
||||
} from './structured-session-pointer-delivery'
|
||||
import { sendAgentTurn } from './send-agent-turn'
|
||||
import { sendAgentTurn, type StructuredSessionTurnOutcome } from './send-agent-turn'
|
||||
|
||||
/** Names the dispatch a nudge belongs to on the operation row it writes. */
|
||||
export function structuredPointerCallerKey(dispatchId: string): string {
|
||||
@@ -63,8 +63,8 @@ async function readPointerSessionFacts(
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const boundary =
|
||||
getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.providerContextBoundary
|
||||
const host = getStructuredAgentSessionHost()
|
||||
const boundary = host?.deps.store.getRecord(sessionId)?.providerContextBoundary
|
||||
return {
|
||||
submissions: boundary
|
||||
? snapshot.submissions.filter((submission) => submission.fence > boundary.afterFence)
|
||||
@@ -95,6 +95,10 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
|
||||
return readPointerSessionFacts(sessionId)
|
||||
},
|
||||
|
||||
hasActionablePromptNow(sessionId) {
|
||||
return getStructuredAgentSessionHost()?.currentWork(sessionId)?.hasActionablePrompt() ?? null
|
||||
},
|
||||
|
||||
currentFence(sessionId) {
|
||||
return (
|
||||
getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null
|
||||
@@ -111,21 +115,37 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
|
||||
if (!host) {
|
||||
return { kind: 'unattached' }
|
||||
}
|
||||
const outcome = await sendAgentTurn({
|
||||
kind: 'structured-session',
|
||||
host,
|
||||
sessionId: input.sessionId,
|
||||
callerKey: input.dispatchId
|
||||
? structuredPointerCallerKey(input.dispatchId)
|
||||
: structuredSessionPointerCallerKey(input.sessionId),
|
||||
turn: {
|
||||
body: input.body,
|
||||
// As a person's message is: a busy chat queues it as a card, sent when the turn ends.
|
||||
delivery: 'queue',
|
||||
operationId: input.operationId,
|
||||
expectedRuntimeFence: input.expectedRuntimeFence
|
||||
let outcome: StructuredSessionTurnOutcome
|
||||
try {
|
||||
outcome = await sendAgentTurn({
|
||||
kind: 'structured-session',
|
||||
host,
|
||||
sessionId: input.sessionId,
|
||||
callerKey: input.dispatchId
|
||||
? structuredPointerCallerKey(input.dispatchId)
|
||||
: structuredSessionPointerCallerKey(input.sessionId),
|
||||
turn: {
|
||||
body: input.body,
|
||||
// A person's plain send: every agent takes it the way it takes their own mid-turn message.
|
||||
delivery: 'now',
|
||||
operationId: input.operationId,
|
||||
expectedRuntimeFence: input.expectedRuntimeFence,
|
||||
beforeRun: () => {
|
||||
if (
|
||||
input.body.from?.orchestration?.message === 'mail-notice' &&
|
||||
host.currentWork(input.sessionId)?.hasActionablePrompt()
|
||||
) {
|
||||
throw new StructuredMailHeldError()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredMailHeldError) {
|
||||
return { kind: 'held' }
|
||||
}
|
||||
})
|
||||
throw error
|
||||
}
|
||||
switch (outcome.kind) {
|
||||
case 'refused':
|
||||
return outcome.refusal.code === AGENT_SESSION_NOT_ATTACHED.code
|
||||
@@ -146,3 +166,5 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StructuredMailHeldError extends Error {}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* What orchestration mail delivery reads of a host-owned structured ("native") agent session.
|
||||
*
|
||||
* A structured session has no PTY the pointer can be typed into, so the nudge travels as a session
|
||||
* turn instead of as bytes, and a busy session's own queue holds it until the turn ends.
|
||||
* turn instead of as bytes: steered into a busy session's running turn, or held by its queue until
|
||||
* the turn ends when its agent cannot take a message mid-turn.
|
||||
* Everything here is pure. Orchestration's database stays the source of truth: nothing here
|
||||
* consumes mail.
|
||||
*/
|
||||
|
||||
@@ -195,20 +195,24 @@ export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeW
|
||||
}
|
||||
|
||||
/**
|
||||
* Every structured session's status change reaches here. At its idle edge, retry what is parked
|
||||
* on it and re-derive the mailboxes it owns, so mail it could not take earlier (mid-turn, closed)
|
||||
* is pointed again. Workers and chats alike: this is not per-dispatch.
|
||||
* Every structured session's status change reaches here. At its working and idle edges, retry
|
||||
* what is parked on it; at idle also re-derive the mailboxes it owns, so mail it could not take
|
||||
* earlier (closed) is pointed again. Workers and chats alike: this is not per-dispatch.
|
||||
*/
|
||||
onStructuredSessionStatusForMail(summary: {
|
||||
sessionId: string
|
||||
status: 'working' | 'attention' | 'idle' | null
|
||||
}): void {
|
||||
if (summary.status === 'working' || summary.status === 'attention') {
|
||||
if (summary.status === 'attention') {
|
||||
return
|
||||
}
|
||||
// Logged, never thrown: the same status callback goes on to the first-turn workspace rename.
|
||||
try {
|
||||
// Working also follows an answered prompt, so mail held behind the prompt is retried too.
|
||||
this.notifyStructuredSessionJournalActivity(summary.sessionId)
|
||||
if (summary.status === 'working') {
|
||||
return
|
||||
}
|
||||
const openDb = () => this.getExistingOrchestrationDb()
|
||||
const deliver = (mailbox: string) => this.deliverPendingMessagesForHandle(mailbox)
|
||||
structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver)
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
CodexAppServerConnectionHandlers,
|
||||
openCodexAppServerConnection
|
||||
} from '../codex/codex-app-server-connection'
|
||||
import { refuseUnroutedSteer } from '../codex/codex-structured-dispatch-test-support'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
||||
import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { isRecord } from './rpc/orchestration-session-caller-test-fixture'
|
||||
@@ -20,6 +19,8 @@ export type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
|
||||
/** Every JSON-RPC method the adapter called, in order. */
|
||||
methods: string[]
|
||||
turns: { clientUserMessageId: string; text: string }[]
|
||||
/** Messages steered into a running turn, as Codex takes them. */
|
||||
steers: { expectedTurnId: string; clientUserMessageId: string; text: string }[]
|
||||
}
|
||||
|
||||
/** How the fake provider misbehaves; reset before each test. */
|
||||
@@ -61,6 +62,7 @@ export function fakeCodex() {
|
||||
threadId: null,
|
||||
methods: [],
|
||||
turns: [],
|
||||
steers: [],
|
||||
pid: 4321,
|
||||
closed: false,
|
||||
request: async (method, params) => {
|
||||
@@ -114,7 +116,12 @@ export function fakeCodex() {
|
||||
}
|
||||
}
|
||||
if (method === 'turn/steer') {
|
||||
return refuseUnroutedSteer(undefined)
|
||||
connection.steers.push({
|
||||
expectedTurnId: String(input.expectedTurnId),
|
||||
clientUserMessageId: String(input.clientUserMessageId),
|
||||
text: JSON.stringify(input.input)
|
||||
})
|
||||
return { turnId: input.expectedTurnId }
|
||||
}
|
||||
return {}
|
||||
},
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
// A busy structured chat holds the orchestration pointer as a card in its own queue, sent when the
|
||||
// turn ends, as it holds a message the person sends then; the queue does nothing else with it. End
|
||||
// to end on the coordinator-mail rig.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { structuredAgentSessionMessageSendMutation } from '../../shared/structured-agent-session-send-mutation'
|
||||
import { operationId, type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture'
|
||||
import { idOf } from './rpc/orchestration-session-caller-test-fixture'
|
||||
import {
|
||||
COORDINATOR,
|
||||
WORKER_2_PANE,
|
||||
WAIT,
|
||||
call,
|
||||
coordinatorRunAndTask,
|
||||
db,
|
||||
finishWorker,
|
||||
host,
|
||||
observationClock,
|
||||
openChat,
|
||||
ptyPointer,
|
||||
queuedCardTexts,
|
||||
runtime,
|
||||
sendUserMessage,
|
||||
settleTurn,
|
||||
turnText
|
||||
} from './structured-chat-coordinator-mail-rig.test-fixture'
|
||||
|
||||
/** The person's turn, started and still running; resolves to its end. */
|
||||
async function runningUserTurn(chat: FakeConnection): Promise<() => Promise<void>> {
|
||||
expect(await sendUserMessage(COORDINATOR, 'go')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT)
|
||||
const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params)
|
||||
notify('turn/started', { turn: { id: 'turn-1' } })
|
||||
notify('item/completed', {
|
||||
item: {
|
||||
type: 'userMessage',
|
||||
id: 'echo-go',
|
||||
clientId: chat.turns[0]!.clientUserMessageId,
|
||||
content: [{ type: 'text', text: 'go' }]
|
||||
}
|
||||
})
|
||||
await host.flushStreamedEvents(COORDINATOR)
|
||||
return async () => {
|
||||
notify('turn/completed', { turn: { id: 'turn-1' } })
|
||||
await host.flushStreamedEvents(COORDINATOR)
|
||||
}
|
||||
}
|
||||
|
||||
/** Idle edges with nothing owed: whatever they would send gets the time to show. */
|
||||
async function idleEdgesSettled(): Promise<void> {
|
||||
for (let edge = 0; edge < 3; edge += 1) {
|
||||
runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' })
|
||||
await observationClock.observe(100)
|
||||
}
|
||||
}
|
||||
|
||||
/** The chat's queue as its journal stores it. */
|
||||
function queuedRows() {
|
||||
return host.collaboratorsForTests().sessions.get(COORDINATOR)?.journal.queuedMessages.list() ?? []
|
||||
}
|
||||
|
||||
/** A second task, for a second worker result. */
|
||||
async function secondTask(): Promise<string> {
|
||||
return idOf(
|
||||
(await call('orchestration.taskCreate', { spec: 'more' }, { sessionId: COORDINATOR })).task
|
||||
)
|
||||
}
|
||||
|
||||
describe("a busy chat's orchestration pointer waits in its queue", () => {
|
||||
it('queues the pointer as a card, with who it is from, and sends it once when the turn ends', async () => {
|
||||
observationClock.start()
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
const endTurn = await runningUserTurn(chat)
|
||||
const dispatchId = await finishWorker(taskId)
|
||||
// The report was accepted, so its dispatch settled before its mail was named.
|
||||
expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed')
|
||||
await vi.waitFor(
|
||||
async () => expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]),
|
||||
WAIT
|
||||
)
|
||||
expect(chat.turns).toHaveLength(1)
|
||||
const [card] = queuedRows()
|
||||
const [mail] = db.getAllMessages(`run:${runId}`)
|
||||
const from = {
|
||||
kind: 'agent',
|
||||
senders: [
|
||||
{
|
||||
party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null },
|
||||
// Named by the task of the dispatch it just finished, through the real runtime's naming.
|
||||
name: 'build it'
|
||||
}
|
||||
],
|
||||
orchestration: {
|
||||
message: 'mail-notice',
|
||||
mailbox: `run:${runId}`,
|
||||
dispatchId: null,
|
||||
messages: [{ messageId: mail!.id, runId, from: 'term_worker' }]
|
||||
}
|
||||
}
|
||||
// On the card's body: the turn the queue sends carries it, and the provider never sees it.
|
||||
expect(card?.body.from).toEqual(from)
|
||||
|
||||
await endTurn()
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT)
|
||||
expect(turnText(chat.turns[1]!)).toBe(ptyPointer(`run:${runId}`))
|
||||
expect(JSON.stringify(chat.turns[1])).not.toContain('term_worker')
|
||||
const sent = (await host.journalSnapshot(COORDINATOR)).items.filter(
|
||||
(item) => item.body.kind === 'message' && item.body.from
|
||||
)
|
||||
expect(sent.map((item) => item.body.kind === 'message' && item.body.from)).toEqual([from])
|
||||
expect(await queuedCardTexts()).toEqual([])
|
||||
await settleTurn(COORDINATOR, 1)
|
||||
await idleEdgesSettled()
|
||||
expect(chat.turns).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('queues a second card for mail that arrives while the first waits, each counting its own mail', async () => {
|
||||
observationClock.start()
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
const second = await secondTask()
|
||||
const endTurn = await runningUserTurn(chat)
|
||||
await finishWorker(taskId)
|
||||
await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT)
|
||||
await finishWorker(second, { handle: 'term_worker_2', paneKey: WORKER_2_PANE })
|
||||
const pointer = ptyPointer(`run:${runId}`)
|
||||
await vi.waitFor(async () => expect(await queuedCardTexts()).toEqual([pointer, pointer]), WAIT)
|
||||
|
||||
await endTurn()
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT)
|
||||
await settleTurn(COORDINATOR, 1)
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(3), WAIT)
|
||||
expect(turnText(chat.turns[2]!)).toBe(pointer)
|
||||
await settleTurn(COORDINATOR, 2)
|
||||
await idleEdgesSettled()
|
||||
expect(chat.turns).toHaveLength(3)
|
||||
expect(await queuedCardTexts()).toEqual([])
|
||||
})
|
||||
|
||||
it("leaves the chat's own `check` as it is: the mail stays readable, and the card stays", async () => {
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
const endTurn = await runningUserTurn(chat)
|
||||
await finishWorker(taskId)
|
||||
await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT)
|
||||
const [mail] = db.getAllMessages(`run:${runId}`)
|
||||
expect(await call('orchestration.check', {}, { sessionId: COORDINATOR })).toMatchObject({
|
||||
count: 1,
|
||||
messages: [{ id: mail!.id }]
|
||||
})
|
||||
expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)])
|
||||
await endTurn()
|
||||
})
|
||||
|
||||
// The queue once refused every send past twenty cards, so a busy coordinator's mail locked the
|
||||
// person out of their own chat.
|
||||
it("still queues the person's message behind more than twenty mail cards", async () => {
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
await runningUserTurn(chat)
|
||||
await finishWorker(taskId)
|
||||
await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT)
|
||||
for (let index = 1; index <= 24; index += 1) {
|
||||
await call('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: `run:${runId}`,
|
||||
subject: `mail ${index}`
|
||||
})
|
||||
await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(index + 1), WAIT)
|
||||
}
|
||||
const sent = await host.send(
|
||||
{ callerKey: 'test-surface' },
|
||||
{
|
||||
...structuredAgentSessionMessageSendMutation({
|
||||
sessionId: COORDINATOR,
|
||||
clientOperationId: operationId(),
|
||||
expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'human message' }]
|
||||
},
|
||||
delivery: 'queue-if-active'
|
||||
}),
|
||||
userSend: true
|
||||
}
|
||||
)
|
||||
expect(sent).toMatchObject({ ok: true, value: { queued: { position: 26, state: 'waiting' } } })
|
||||
expect((await queuedCardTexts()).at(-1)).toBe('human message')
|
||||
})
|
||||
})
|
||||
@@ -410,10 +410,10 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
}
|
||||
})
|
||||
|
||||
it("holds mail a refused turn left in doubt, then queues the next pointer behind it as the person's message would wait", async () => {
|
||||
it('points mail a refused turn left in doubt again with the next result, counting both', async () => {
|
||||
// A failed turn/start cannot prove the turn never started, so the host records it `unknown`
|
||||
// and a resend under its id replays that. A live doubt counts as work still owed, so the next
|
||||
// result's pointer waits in the chat's queue, as a message the person sent then would.
|
||||
// and a resend under its id replays that. The next result's pointer does not wait behind
|
||||
// that doubt: it names both messages, and the chat's own `check` reads them.
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
const second = await call(
|
||||
@@ -429,14 +429,11 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
expect(chat.turns).toHaveLength(0)
|
||||
|
||||
await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE })
|
||||
await vi.waitFor(
|
||||
async () =>
|
||||
expect(await queuedCardTexts()).toEqual([
|
||||
formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim()
|
||||
]),
|
||||
WAIT
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT)
|
||||
expect(turnText(chat.turns[0]!)).toBe(
|
||||
formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim()
|
||||
)
|
||||
expect(chat.turns).toHaveLength(0)
|
||||
expect(await queuedCardTexts()).toEqual([])
|
||||
expect(codex.connections.length).toBe(before)
|
||||
})
|
||||
|
||||
@@ -459,53 +456,45 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
expect(await userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)])
|
||||
})
|
||||
|
||||
it('points mail at the idle edge when it arrived mid-turn', async () => {
|
||||
it('steers mail that arrives mid-turn into the running turn, with who it is from', async () => {
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
const first = await host.send(
|
||||
{ callerKey: 'test-surface' },
|
||||
{
|
||||
envelope: {
|
||||
sessionId: COORDINATOR,
|
||||
clientOperationId: operationId(),
|
||||
expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: COORDINATOR,
|
||||
fields: {
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
}
|
||||
})
|
||||
},
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
}
|
||||
)
|
||||
expect(first).toMatchObject({ ok: true })
|
||||
// Accepted, then delivered: the provider sees the turn once the host hands it over.
|
||||
expect(await sendUserMessage(COORDINATOR, 'go')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT)
|
||||
const notify = (method: string, params: unknown) =>
|
||||
chat.handlers.onNotification?.(method, params)
|
||||
const echo = (id: string, clientId: string, text: string) =>
|
||||
notify('item/completed', {
|
||||
item: { type: 'userMessage', id, clientId, content: [{ type: 'text', text }] }
|
||||
})
|
||||
notify('turn/started', { turn: { id: 'turn-1' } })
|
||||
notify('item/completed', {
|
||||
item: {
|
||||
type: 'userMessage',
|
||||
id: 'echo-go',
|
||||
clientId: chat.turns[0]!.clientUserMessageId,
|
||||
content: [{ type: 'text', text: 'go' }]
|
||||
}
|
||||
})
|
||||
echo('echo-go', chat.turns[0]!.clientUserMessageId, 'go')
|
||||
await host.flushStreamedEvents(COORDINATOR)
|
||||
|
||||
await finishWorker(taskId)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
// The coordinator is mid-turn, so nothing is folded into that turn.
|
||||
expect(chat.turns).toHaveLength(1)
|
||||
|
||||
// Into the turn that is running, as a terminal agent takes its typed nudge: no card, no
|
||||
// second turn.
|
||||
await vi.waitFor(() => expect(chat.steers).toHaveLength(1), WAIT)
|
||||
const [steer] = chat.steers
|
||||
expect(steer!.expectedTurnId).toBe('turn-1')
|
||||
expect(steer!.text).toContain(ptyPointer(`run:${runId}`))
|
||||
expect(await queuedCardTexts()).toEqual([])
|
||||
const sent = (await host.journalSnapshot(COORDINATOR)).items.find(
|
||||
(item) => item.body.kind === 'message' && item.body.from
|
||||
)
|
||||
expect(sent?.body).toMatchObject({
|
||||
from: { kind: 'agent', orchestration: { message: 'mail-notice', mailbox: `run:${runId}` } }
|
||||
})
|
||||
|
||||
echo('echo-pointer', steer!.clientUserMessageId, ptyPointer(`run:${runId}`))
|
||||
notify('turn/completed', { turn: { id: 'turn-1' } })
|
||||
await host.flushStreamedEvents(COORDINATOR)
|
||||
await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT)
|
||||
expect(chat.turns[1]!.text).toMatch(POINTER)
|
||||
expect(chat.turns[1]!.text).toContain(runId)
|
||||
// The idle edge finds the mail delivered: nothing is resent.
|
||||
runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
expect(chat.turns).toHaveLength(1)
|
||||
expect(chat.steers).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ export function settleRecorded(
|
||||
finish(
|
||||
entry,
|
||||
'recorded',
|
||||
keep ? { phase: 'recorded', issued: true, ...(cursor ? { recordedCursor: cursor } : {}) } : undefined
|
||||
keep
|
||||
? { phase: 'recorded', issued: true, ...(cursor ? { recordedCursor: cursor } : {}) }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,7 @@ const FINISHED: AiVaultScanProgress = {
|
||||
|
||||
it('says the finished total in the strip and opens to one row per agent', async () => {
|
||||
render(<AiVaultScanProgressStrip progress={FINISHED} />)
|
||||
expect(screen.getByText('102,452 files').parentElement?.textContent).toBe(
|
||||
'Scanned 102,452 files'
|
||||
)
|
||||
expect(screen.getByText('102,452 files').parentElement?.textContent).toBe('Scanned 102,452 files')
|
||||
expect(screen.queryByText('Codex')).toBeNull()
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
@@ -120,7 +118,12 @@ it('shows a running scan at once but a finished one only with the list it produc
|
||||
|
||||
act(() => push({ scanKey: 'scope-a', progress: FINISHED }))
|
||||
expect(aView.getByText('Scanned', { exact: false })).toBeTruthy()
|
||||
act(() => push({ scanKey: 'scope-b', progress: { ...FINISHED, scanId: 'scan-b', stage: 'finding', settledAt: null } }))
|
||||
act(() =>
|
||||
push({
|
||||
scanKey: 'scope-b',
|
||||
progress: { ...FINISHED, scanId: 'scan-b', stage: 'finding', settledAt: null }
|
||||
})
|
||||
)
|
||||
expect(bView.getByText('Scanning', { exact: false })).toBeTruthy()
|
||||
expect(aView.getByText('Scanned', { exact: false })).toBeTruthy()
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ export function structuredAgentSessionReadCoversSend(
|
||||
): boolean {
|
||||
return Boolean(
|
||||
cursor &&
|
||||
acknowledged &&
|
||||
cursor.epoch === acknowledged.epoch &&
|
||||
cursor.sequence >= acknowledged.sequence
|
||||
acknowledged &&
|
||||
cursor.epoch === acknowledged.epoch &&
|
||||
cursor.sequence >= acknowledged.sequence
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user