Files
orca/src/shared/agent-session-message-source.test.ts
T
Brennan Benson 9fdd90ffa7 feat(orchestration): a native chat can be a dispatch worker, like a terminal agent (#22972)
* feat(native-chat): a queued card can record a Dispatch's task as its source

A chat worker's task is held in the chat's queue like any agent message, so
the card needs to say which Dispatch it is from: the sender, run, task and
Dispatch ids. An older build reads an unknown kind as the person's card and
sends it as written.

* feat(orchestration): a chat can be a dispatch worker, like a terminal agent

`dispatch --to orca_session_id:<id>` and `worker-start --terminal
orca_session_id:<id>` now accept a chat on this host instead of refusing it.

- The chat is refused only where mail to it would be: unknown, a provider id,
  another host, or closed. A chat can't be its own coordinator's worker.
- Its task goes through sendAgentTurn as a queued send, as mail notices do:
  an idle chat starts a turn, a busy one holds a card naming the Dispatch.
  The operation id is derived from the Dispatch, so a resend replays.
- worker-start reads that outcome as the terminal path reads its write; a
  card held behind a running turn is handed over with its start unobserved.
- The Dispatch names the chat by its /clear root, with no pane or process,
  and records its Orca session id, so its own sub-dispatches nest under it.
- Mail to dispatch:<id> reaches the chat; worker-show/list/read read the
  chat's session records; stop and abandon never close the chat; closing the
  chat fails its Dispatch as closing a terminal does.
- A dispatch preamble for a structured session names the CLI the structured
  mail lane names.

* test(orchestration): a chat worker's reach, and its Dispatch across a /clear

At rest is live, a closed chat has exited, and another host or nothing to
read is unverifiable. A /clear keeps the chat's Dispatch, and the session
that continues the chat reports as it.

* fix(orchestration): chat worker review round 1

- dispatch --inject is a keepalive-backed wait, so a chat that takes a while
  to accept its task no longer reads as a dead runtime at the 30 s idle cut.
- An injected task whose delivery is unknown keeps its Dispatch open, as an
  unknown worker-start does, instead of failing it while the chat may run it.
- A busy chat's worker-start receipt says the task waits as a card in the
  chat's queue, and that worker-abandon does not remove that card.
- A close that puts the chat's tab back no longer fails its Dispatch: the
  closed-chat settle runs after the close's outcome, not at its hide.
- worker-start adopting a chat says it gave the task to the chat, instead of
  claiming it started a terminal agent; the mode value is unchanged.
- Test: a chat whose agent has not taken its task reads outcome_unknown.

* test(native-chat): a close's hide defers its hidden notice until the close settles

The rollback suite pinned the hide's arguments; it now expects the deferred
notice, and that the notice is sent once, after a rolled-back tab is back.

* fix(orchestration): a chat worker whose successor is unknown is unverifiable, not exited

A /clear successor this host has no record of is missing evidence, not proof
the chat is gone. Only a closed chat reads exited, so only a closed chat
settles its Dispatch; a lost successor reads unverifiable with that reason.

* fix(orchestration): chat worker review round 2

- A chat's close notice settles only that chat's Dispatch. A scan of every
  chat Dispatch read another chat whose close was still in flight (its tab
  hidden, maybe to be put back) as closed and failed its Dispatch.
- Only a dispatch --inject into a chat is a long poll; a terminal inject
  writes and returns, and keeps its short-RPC slot.
- Receipt wording: worker-start says it gave the task to the chat, and a
  queued task is sent when the chat's queue reaches it.

* fix(orchestration): chat worker wording, review round 3

- worker-start's mode sentence for a chat states the placement, which is
  true whether the task is delivered, queued or refused.
- Comments and a test title no longer claim a close notice re-derives every
  chat worker, or that a queued task waits for the current turn.

* fix(orchestration): build a structured worker's task sender from the start's own ids

The minted worker's preamble names who its task is from with the run, task
and Dispatch ids worker-start already holds, so it reads no Dispatch row.

* fix(orchestration): point a chat worker at its held Dispatch mail again

A chat worker's coordinator mail lands in its Dispatch's mailbox, but a
chat's idle edge re-derived the Dispatch mailbox only for a party with a
terminal handle. A pointer lost with the provider then waited for new mail
after a restart or a /clear. The re-derivation now looks the Dispatch up by
the party's address, which names a worker by its handle and a chat by its
root.

* fix(native-chat): open a task card's sender by address; a task carries no mail

Opening an agent message's sender looked up the mail it carried, which only a
mail notice has; with the task kind in the union that read no longer typed.
A task's coordinator is found by its address alone.
2026-10-06 18:36:21 -07:00

96 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
agentMessageSendersShown,
readAgentMessageSource,
type AgentMessageSource
} from './agent-session-message-source'
import { testOrcaSessionId } from './orca-session-address-test-fixture'
const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
const FROM: AgentMessageSource = {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Build tab'
},
{
party: {
address: `orca_session_id:${SESSION}`,
terminalHandle: null,
orcaSessionId: testOrcaSessionId(SESSION)
},
name: null
}
],
orchestration: {
message: 'mail-notice',
mailbox: 'run:r1',
dispatchId: null,
messages: [{ messageId: 'm1', runId: 'r1', from: 'term_a' }]
}
}
describe("a message's sender, read through its one reader", () => {
it('reads absence as the person, and a valid sender back whole', () => {
expect(readAgentMessageSource(undefined)).toBeUndefined()
expect(readAgentMessageSource(structuredClone(FROM))).toEqual(FROM)
})
it("reads a Dispatch's task back whole", () => {
const task: AgentMessageSource = {
...FROM,
orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'ctx_1' }
}
expect(readAgentMessageSource(structuredClone(task))).toEqual(task)
})
it('keeps an agent an agent when its orchestration kind is newer than this build', () => {
const newer = { ...FROM, orchestration: { message: 'review', dispatchId: 'ctx_1' } }
expect(readAgentMessageSource(newer)).toEqual({ ...FROM, orchestration: null })
// A newer sender kind is still not the person.
expect(readAgentMessageSource({ ...FROM, kind: 'automation' })).toMatchObject({
kind: 'agent'
})
})
it('drops a sender it cannot read and bounds a name, keeping the rest', () => {
const read = readAgentMessageSource({
...FROM,
senders: [
{ party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: 'term_a' } },
{ party: { address: 'term_b', terminalHandle: 'term_b', orcaSessionId: null }, name: 7 },
{
party: { address: 'term_c', terminalHandle: 'term_c', orcaSessionId: null },
name: `a‮b\n${'x'.repeat(300)}`
}
]
})
expect(read?.senders.map(({ party, name }) => [party.address, name?.length ?? null])).toEqual([
['term_b', null],
['term_c', 200]
])
expect(read?.senders[1]?.name?.startsWith('a b ')).toBe(true)
})
it("reads a value that is not a sender at all as the person's message", () => {
for (const value of [null, 'nobody', 42, [], { senders: [] }]) {
expect(readAgentMessageSource(value)).toBeUndefined()
}
})
it('names at most a few senders and counts the rest', () => {
const many: AgentMessageSource = {
...FROM,
senders: Array.from({ length: 5 }, (_, index) => ({
party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null },
name: `Agent ${index}`
}))
}
const { shown, more } = agentMessageSendersShown(many)
expect(shown.map((sender) => sender.name)).toEqual(['Agent 0', 'Agent 1', 'Agent 2'])
expect(more).toBe(2)
})
})