mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* feat(native-chat): publish each submission's journal positions A client never learned where in the journal a send was accepted or taken back, so drawing a stopped send had to guess from its own row and from clocks, and the guesses misfired after restarts. Each projected submission now carries `submittedSequence` (its submission row) and `resolvedSequence` (the dispatch row that resolved it; absent while pending): two optional fields, computed on every fold from rows already stored, never stored themselves. Older clients ignore them. The failure-fact schema moves to its own module to keep the journal schema file within its size limit. * test(native-chat): pin a submission's resolved position on the provider-echo accept path Also say which rows can resolve a submission, and that the submission schema, not the shared type, omits acceptedSequence. * refactor(native-chat): publish only where each submission was sent A rejected send's own row now moves to its rejection (#24710), so the row that resolved a send is the item's own position and needs no second copy. Keep submittedSequence, the one journal-order record of where a send was sent. * feat(native-chat): publish the turn a withdrawn Codex send was answered into A send Codex answered into a turn that then ended without taking it is rejected by that turn's end. The rejection row now names that turn's record, and the submission carries it as answeredInTurnItemId, so a client can tell which turn the send belonged to without guessing from journal order or clocks. Absent on every other send. * feat(native-chat): say how a withdrawn Codex send joined the turn it names The answered turn becomes { turnItemId, via }: 'start' when Codex answered the send's turn/start with that turn, 'steer' when Orca steered it into that running turn. A client can then tell the turn's opener from a send steered into it even when the opener is on an older page. * feat(native-chat): state on every rejection whether it names a turn A rejection a host writes now always carries answeredInTurn: the turn Codex answered the send into, or null for none. A reader can then tell 'answered into no turn' from a row written before the field existed, which keeps no answer and is placed by the older rules. * refactor(native-chat): give the answered-turn schema its own module Keeps the journal schema file within its line limit. * docs(native-chat): say an unreadable answered turn reads as none
182 lines
6.1 KiB
TypeScript
182 lines
6.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
|
|
import {
|
|
createCodexDispatchEchoes,
|
|
readCodexDispatchEcho,
|
|
MAX_CODEX_PENDING_DISPATCH_ECHOES
|
|
} from './codex-structured-dispatch-echo'
|
|
|
|
const CODEX_IDENTITY: AgentJournalItemIdentity = {
|
|
provider: 'codex',
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
ordinal: 3
|
|
}
|
|
|
|
describe('codex dispatch echoes', () => {
|
|
it('settles by client message id rather than arrival order', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.arm('client-2')
|
|
|
|
// Codex coalesces both sends into one turn, and the second can be echoed
|
|
// first. Queue position would settle the wrong submission here.
|
|
expect(echoes.settle('client-2')).toBe(true)
|
|
expect(echoes.settle('client-1')).toBe(true)
|
|
expect(echoes.size).toBe(0)
|
|
})
|
|
|
|
it('reads each submission instant by client message id', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('stale-unknown', 100)
|
|
echoes.arm('later-turn', 200)
|
|
|
|
expect(echoes.requestOrigin('later-turn')).toEqual({ requestedAt: 200, sequence: 1 })
|
|
expect(echoes.requestOrigin('stale-unknown')).toEqual({ requestedAt: 100, sequence: 0 })
|
|
expect(echoes.requestOrigin('never-armed')).toBeNull()
|
|
expect(echoes.latestSequence()).toBe(1)
|
|
})
|
|
|
|
it('keeps one causal sequence when an unconfirmed send retries', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1', 100)
|
|
echoes.arm('client-1', 200)
|
|
|
|
expect(echoes.requestOrigin('client-1')).toEqual({ requestedAt: 100, sequence: 0 })
|
|
expect(echoes.latestSequence()).toBe(0)
|
|
})
|
|
|
|
it('refuses an echo this session never armed', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
|
|
expect(echoes.settle('client-from-history')).toBe(false)
|
|
expect(echoes.size).toBe(1)
|
|
})
|
|
|
|
it('settles a send exactly once', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
|
|
expect(echoes.settle('client-1')).toBe(true)
|
|
expect(echoes.settle('client-1')).toBe(false)
|
|
})
|
|
|
|
it('drops a send whose write never reached the provider', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.disarm('client-1')
|
|
|
|
expect(echoes.settle('client-1')).toBe(false)
|
|
})
|
|
|
|
it('clears every armed send', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.arm('client-2')
|
|
|
|
echoes.clear()
|
|
|
|
expect(echoes.size).toBe(0)
|
|
expect(echoes.settle('client-1')).toBe(false)
|
|
})
|
|
|
|
it('refuses new correlations at capacity without dropping an older send', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) {
|
|
expect(echoes.arm(`client-${index}`)).toBe(true)
|
|
}
|
|
|
|
expect(echoes.arm(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false)
|
|
expect(echoes.size).toBe(MAX_CODEX_PENDING_DISPATCH_ECHOES)
|
|
expect(echoes.settle('client-0')).toBe(true)
|
|
expect(echoes.settle(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('readCodexDispatchEcho', () => {
|
|
it('reads the client message id off a user message', () => {
|
|
expect(
|
|
readCodexDispatchEcho(
|
|
{ type: 'userMessage', id: 'item-1', clientId: 'client-1' },
|
|
CODEX_IDENTITY
|
|
)
|
|
).toEqual({ clientMessageId: 'client-1', providerIdentity: CODEX_IDENTITY })
|
|
})
|
|
|
|
it('ignores an item that is not a user message', () => {
|
|
expect(
|
|
readCodexDispatchEcho(
|
|
{ type: 'agentMessage', id: 'item-1', clientId: 'client-1' },
|
|
CODEX_IDENTITY
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('ignores a user message Codex did not correlate', () => {
|
|
expect(readCodexDispatchEcho({ type: 'userMessage', id: 'item-1' }, CODEX_IDENTITY)).toBeNull()
|
|
})
|
|
|
|
it('ignores an item with no durable Codex identity', () => {
|
|
expect(
|
|
readCodexDispatchEcho(
|
|
{ type: 'userMessage', id: 'item-1', clientId: 'client-1' },
|
|
{ provider: 'orca', clientMessageId: 'codex-item:thread-1:item-1' }
|
|
)
|
|
).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('the turn Codex answered a send into but has not opened', () => {
|
|
const NONE_OPEN = new Set<string>()
|
|
|
|
it('is the turn the latest armed send was answered into', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
|
|
echoes.arm('client-2')
|
|
echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start')
|
|
|
|
expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2')
|
|
})
|
|
|
|
it('is none once Codex opened that turn', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
|
|
|
|
expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull()
|
|
})
|
|
|
|
it('is none once that turn ended, even with its send still armed for an echo', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
|
|
// A completed end leaves its unechoed send armed.
|
|
expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([])
|
|
|
|
expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull()
|
|
})
|
|
|
|
it('skips a turn a wait left unopened, and still names an earlier one', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
|
|
echoes.arm('client-2')
|
|
echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start')
|
|
|
|
echoes.leftUnopened('thread-1', 'turn-2')
|
|
|
|
expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-1')
|
|
})
|
|
|
|
it('is none for a send not yet answered, or answered on another thread', () => {
|
|
const echoes = createCodexDispatchEchoes()
|
|
echoes.arm('client-1')
|
|
echoes.arm('client-2')
|
|
echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start')
|
|
|
|
expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull()
|
|
})
|
|
})
|