Files
2026-10-31 20:05:39 +08:00

1622 lines
104 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Engine, MemoryRepositories, UserError, followupOwners, key, runningAssignment, ticketOutcome, ticketStage,
waitsForPerson } from '../src/core/index.ts'
import type { Assessor, Assignment, Candidate, EngineOptions, Executor, OutboundMessage, Outcome, Policy, Ticket, Trigger } from '../src/core/index.ts'
const member = { source: 'chat', id: 'alice' }
const guest = { source: 'chat', id: 'guest' }
function trigger(id: string, overrides: Partial<Trigger> = {}): Trigger {
return { id, source: 'chat', threads: [{ source: 'chat', id: 't1' }], actor: member, human: true, at: `2026-09-23T00:00:${id.padStart(2, '0')}Z`, text: `message ${id}`, channel: { source: 'chat', thread: 't1', address: { message: id } }, meta: {}, ...overrides }
}
interface Hooks {
emit?: (event: { type: string; data: Record<string, unknown> }) => void
prepareTicket?: EngineOptions['prepareTicket']
}
function harness(policyOverrides: Partial<Policy> = {}, { emit, prepareTicket }: Hooks = {}) {
const repo = new MemoryRepositories()
const outbox: OutboundMessage[] = []
const withdrawn: string[] = []
const requests: Candidate[] = []
const assessor: Assessor = { request: vi.fn(candidate => { requests.push(candidate) }), release: vi.fn() }
const launches: [Ticket, Assignment][] = []
const deliveries = new Set<string>()
/** Inputs the Executor handed to a running assignment, as [ticket id, input id]. */
const delivered: [string, string][] = []
const executor: Executor = {
start: vi.fn((ticket, assignment) => { launches.push([ticket, assignment]) }), deliver: vi.fn(ticket => { deliveries.add(ticket.id) }), stop: vi.fn(),
}
let version = 'v1', paused = false
const policy: Policy = {
get version() { return version }, get paused() { return paused }, evalTimeoutMs: 60_000,
gate: (t, thread) => !t.human ? { admissible: false, reason: 'not human' } : t.actor.id === 'alice' ? { admissible: true, strength: 'explicit', basis: { via: 'listed' } } : thread.subscriptions.length ? { admissible: true, strength: 'inferred', basis: { via: 'followup' } } : { admissible: false, reason: 'not a member' },
revalidate: () => version !== 'revoked',
admitsInferred: rules => rules.length > 0,
...policyOverrides,
}
let clock = 0
const scope: EngineOptions['scope'] = { scope: t => t.meta.blocked ? { listened: false, reason: 'chat not allowed' } : { listened: true, observed: false } }
const queued: EngineOptions['outbox'] = {
send: message => { if (!outbox.some(item => item.id === message.id)) outbox.push(message) },
status: id => outbox.some(item => item.id === id) ? 'pending' : undefined,
withdraw: id => { withdrawn.push(id) },
}
const engine = new Engine({ repo, scope, policy: () => policy, assessor, executor, outbox: queued,
...emit ? { emit } : {}, ...prepareTicket ? { prepareTicket } : {}, now: () => new Date(Date.UTC(2026, 8, 23, 0, 0, clock)).toISOString() })
/** Simulate the executor starting queued assignments (unless `launch` is false) and delivering pending ticket inputs. */
function settle({ launch = true } = {}) {
for (;;) {
const next = launch ? launches.shift() : undefined
if (next) { engine.assignmentStarted(next[0].id, next[1].id); continue }
const [ticketId] = deliveries
if (ticketId === undefined) return
deliveries.delete(ticketId)
const ticket = repo.tickets.get(ticketId)!
const assignment = runningAssignment(ticket)
if (ticket.ended || !assignment) continue
for (const inputId of ticket.undelivered) { delivered.push([ticketId, inputId]); engine.inputsDelivered(ticketId, assignment.id, [inputId]) }
}
}
const pump = () => { engine.evaluateDue(); settle() }
/** Simulate the worker's Session taking the delivered Trigger Inputs it has not yet read into a step. */
const read = (ticketId: string) => engine.inputsRead(ticketId, repo.tickets.get(ticketId)!.inputs.flatMap(input =>
input.kind === 'trigger' && input.readAt === null ? [input.id] : []))
const decide = (candidate: Candidate, outcome: Outcome) => engine.decide(candidate.triggers[0]!.id, { ...outcome, evalId: candidate.evalId })
return { repo, engine, outbox, withdrawn, requests, assessor, executor, delivered, settle, pump, read, decide,
setVersion: (value: string) => { version = value }, setPaused: (value: boolean) => { paused = value }, tick: (seconds: number) => { clock += seconds } }
}
function accepted(h: ReturnType<typeof harness>, id = '1', thread = 't1') {
h.engine.receive(trigger(id, { threads: [{ source: 'chat', id: thread }] })); h.pump()
const decision = h.decide(h.requests.at(-1)!, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'requested', acknowledgement: 'ok' })
h.settle()
return decision
}
describe('intake', () => {
it('stores a trigger once and records source filtering', () => {
const h = harness()
expect(h.engine.receive(trigger('1'))).toBe('stored')
expect(h.engine.receive(trigger('1'))).toBe('duplicate')
expect(h.engine.receive(trigger('2', { meta: { blocked: true } }))).toBe('filtered')
expect(h.repo.triggers.disposition('2')?.filtered).toEqual({ code: 'chat not allowed' })
expect(h.repo.threads.get('chat:t1')?.pending).toEqual(['1'])
})
it('records correlation for a trigger on several threads and evaluates only on the primary one', () => {
const h = harness()
h.engine.receive(trigger('1', { threads: [{ source: 'gh', id: 'pr-7' }, { source: 'gh', id: 'run-9' }] }))
expect(h.repo.threads.get('gh:pr-7')).toMatchObject({ pending: ['1'], correlated: ['gh:run-9'] })
expect(h.repo.threads.get('gh:run-9')).toMatchObject({ pending: [], correlated: ['gh:pr-7'] })
})
})
describe('eval', () => {
it('ignores at the gate without an assessor when nothing is admissible', async () => {
const h = harness()
h.engine.receive(trigger('1', { human: false })); h.pump()
expect(h.assessor.request).not.toHaveBeenCalled()
expect(h.repo.decisions.byThread('chat:t1')).toEqual([expect.objectContaining({ kind: 'ignore', triggerIds: ['1'] })])
expect(h.repo.triggers.disposition('1')?.decisionId).toBeDefined()
})
it('releases the assessor\'s state for every eval that ends, decided or not', async () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
h.decide(h.requests[0]!, { kind: 'ignore', reason: 'chatter' })
expect(h.assessor.release).toHaveBeenCalledExactlyOnceWith(h.requests[0]!.evalId)
})
it('serializes evals per thread and debounces triggers that arrive meanwhile', async () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
h.engine.receive(trigger('2')); h.engine.receive(trigger('3')); h.pump()
expect(h.requests).toHaveLength(1)
expect(h.repo.threads.get('chat:t1')?.pending).toEqual(['2', '3'])
h.decide(h.requests[0]!, { kind: 'ignore', reason: 'chatter' })
h.pump()
expect(h.requests[1]!.triggers.map(t => t.id)).toEqual(['2', '3'])
expect(h.requests[1]!.strength).toBe('explicit')
})
it('accept creates a ticket with a mandate, subscribes the thread, queues the acknowledgement and starts an assignment', async () => {
const h = harness()
const decision = accepted(h)
const ticket = h.repo.tickets.get(decision.ticketId!)!
expect(ticket).toMatchObject({ title: 'Fix', mandate: { strength: 'explicit', policyVersion: 'v1', basis: { via: 'listed' }, actor: member, scope: 'small' }, channels: [trigger('1').channel],
origin: { triggerId: '1', triggerIds: ['1'] } })
expect(ticket).not.toHaveProperty('ended')
expect(ticket.assignments).toEqual([expect.objectContaining({ state: 'running', startedAt: expect.any(String) })])
expect(h.repo.threads.get('chat:t1')?.subscriptions).toEqual([ticket.id])
expect(h.outbox).toEqual([{ id: key('ack', '1'), channel: trigger('1').channel, text: 'ok', kind: 'acknowledgement', ticketId: ticket.id }])
expect(h.executor.start).toHaveBeenCalledOnce()
})
it('refuses to decide an eval that a later eval replaced, and decides the later one', async () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
const first = h.requests[0]!
h.setVersion('v2'); h.engine.policyChanged('v1', 'v2'); h.pump()
const second = h.requests[1]!
expect(() => h.decide(first, { kind: 'ignore', reason: 'chatter' })).toThrow('has finished or a later eval replaced it')
expect(h.decide(second, { kind: 'ignore', reason: 'chatter' })).toMatchObject({ kind: 'ignore', evalId: second.evalId })
})
it('records an accept with its ticket, subscriptions and acknowledgement together, or none of them', async () => {
const h = harness({}, { prepareTicket: () => { throw new Error('No directory for the ticket') } })
h.engine.receive(trigger('1')); h.pump()
const evalId = h.requests[0]!.evalId
expect(() => h.decide(h.requests[0]!, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'requested', acknowledgement: 'ok' })).toThrow('No directory')
expect(h.repo.decisions.byThread('chat:t1')).toEqual([])
expect(h.repo.tickets.list()).toEqual([])
expect(h.repo.threads.get('chat:t1')).toMatchObject({ evaluating: { id: evalId }, subscriptions: [] })
expect(h.outbox).toEqual([])
})
it('records unavailable when the policy version changed during assessment', async () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
h.setVersion('v2')
const decision = h.decide(h.requests[0]!, { kind: 'accept', title: 'x', scope: 'x', reason: 'x' })
expect(decision.kind).toBe('unavailable')
expect(h.repo.tickets.list()).toEqual([])
})
it('starts every pending eval in one step, so a second pump starts none of them again', () => {
const h = harness()
for (const [id, thread] of [['1', 'A'], ['2', 'B']] as const) h.engine.receive(trigger(id, { threads: [{ source: 'chat', id: thread }] }))
expect(h.engine.evaluateDue()).toBeUndefined()
h.engine.evaluateDue()
expect(h.requests.map(candidate => candidate.thread.key)).toEqual(['chat:A', 'chat:B'])
})
it('counts an Eval\'s deadline from its delivery, queues its Triggers again twice when it expires, then records it unavailable', async () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
// A candidate the assessor's Session has not taken does not expire.
h.tick(600); h.pump()
expect(h.engine.candidate('1')?.evalId).toBe(h.requests[0]!.evalId)
const deliver = () => { h.engine.candidateDelivered(h.requests.at(-1)!.evalId) }
deliver()
h.tick(59); h.pump()
expect(h.requests).toHaveLength(1)
// Expired, it records nothing and queues the Trigger for a fresh Eval, twice.
h.tick(2); h.pump()
expect(h.repo.decisions.byThread('chat:t1')).toEqual([])
expect(h.assessor.release).toHaveBeenCalledWith(h.requests[0]!.evalId)
expect(h.requests.map(item => item.triggers.map(each => each.id))).toEqual([['1'], ['1']])
deliver(); h.tick(61); h.pump()
expect(h.requests).toHaveLength(3)
// The third expiry is recorded unavailable.
deliver(); h.tick(61); h.pump()
expect(h.repo.decisions.byThread('chat:t1')).toEqual([expect.objectContaining({ kind: 'unavailable', reason: [{ code: 'timeout' }] })])
expect(h.repo.threads.get('chat:t1')).toMatchObject({ pending: [] })
expect(h.repo.threads.get('chat:t1')).not.toHaveProperty('evaluating')
expect(h.repo.threads.get('chat:t1')).not.toHaveProperty('expired')
})
})
describe('subscription fan-out', () => {
it('delivers a person\'s later message on a subscribed thread to the live ticket as context, and not to Eval', async () => {
const h = harness()
const decision = accepted(h)
const requests = h.requests.length
h.engine.receive(trigger('2', { actor: guest })); h.pump()
const ticket = h.repo.tickets.get(decision.ticketId!)!
expect(ticket.inputs).toEqual([expect.objectContaining({ kind: 'trigger', triggerId: '2' })])
expect(ticket.undelivered).toEqual([])
expect(h.delivered).toEqual([[ticket.id, ticket.inputs[0]!.id]])
expect(h.repo.triggers.disposition('2')).toEqual({ fanout: [ticket.id], at: expect.any(String) })
// No Eval, so no Decision and no timeout, and the Thread's queue stays free for a new request.
expect([h.requests.length, h.repo.threads.get('chat:t1')!.pending, h.repo.decisions.byThread('chat:t1').length]).toEqual([requests, [],
1])
h.tick(600); h.engine.evaluateDue()
expect(h.repo.decisions.byThread('chat:t1')).toHaveLength(1)
// A bot's message there still goes to Eval, which the gate does not admit.
h.engine.receive(trigger('3', { human: false })); h.pump()
expect(h.repo.decisions.byThread('chat:t1').at(-1)).toMatchObject({ kind: 'ignore', triggerIds: ['3'] })
})
it('keeps inputs undelivered until an assignment is running, and asks to launch one whose launch failed again', () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
const decision = h.decide(h.requests[0]!, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'requested' })
h.settle({ launch: false })
h.engine.receive(trigger('2')); h.engine.evaluateDue(); h.settle({ launch: false })
const ticket = h.repo.tickets.get(decision.ticketId!)!
expect(ticket.undelivered).toHaveLength(1)
expect(h.executor.deliver).not.toHaveBeenCalled()
expect(h.executor.start).toHaveBeenCalledTimes(2)
h.engine.assignmentStarted(ticket.id, ticket.assignments[0]!.id); h.settle()
expect(h.delivered).toEqual([[ticket.id, ticket.undelivered[0]!]])
})
it('does not hand a bot\'s message to an ended ticket, and still assesses it', async () => {
const h = harness()
const decision = accepted(h)
h.engine.report(decision.ticketId!, { state: 'ready', summary: 'done' })
h.engine.receive(trigger('2', { human: false })); h.pump()
// The Executor reads its threads again when it resumes, so bots and CI neither wake nor queue on an ended ticket.
expect(h.repo.tickets.get(decision.ticketId!)).toMatchObject({ ended: expect.anything(), undelivered: [] })
expect(h.executor.start).toHaveBeenCalledOnce()
})
it('resumes once however often it pumps, with the resume note ahead of the message, and drops what waited when the policy no longer holds the mandate', async () => {
const h = harness()
const decision = accepted(h)
h.engine.report(decision.ticketId!, { state: 'ready', summary: 'done' })
h.setVersion('revoked')
h.engine.receive(trigger('2', { actor: guest })); h.pump()
expect(h.repo.tickets.get(decision.ticketId!)).toMatchObject({ ended: expect.anything(), undelivered: [] })
h.setVersion('v1')
h.pump()
expect(h.repo.tickets.get(decision.ticketId!)!.ended).toBeDefined()
h.engine.receive(trigger('3', { actor: guest })); h.pump(); h.pump()
const ticket = h.repo.tickets.get(decision.ticketId!)!
expect(ticket.assignments).toHaveLength(2)
expect(ticket.inputs.filter(input => input.kind === 'resumed')).toHaveLength(1)
})
it('takes delivery reports only from the assignment that still runs the open ticket', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const assignment = h.repo.tickets.get(ticketId)!.assignments[0]!.id
h.engine.receive(trigger('2'))
const input = key('input', ticketId, 'trigger', '2')
h.engine.deliveryFailed(ticketId, assignment, 'Session busy')
expect(h.repo.tickets.get(ticketId)!.delivery).toMatchObject({ error: 'Session busy' })
h.engine.deliveryFailed(ticketId, 'another', 'other')
h.engine.inputsDelivered(ticketId, 'another', [input])
expect(h.repo.tickets.get(ticketId)).toMatchObject({ undelivered: [input], delivery: { error: 'Session busy' } })
h.engine.inputsDelivered(ticketId, assignment, [input])
expect(h.repo.tickets.get(ticketId)!.undelivered).toEqual([])
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('delivery')
h.engine.receive(trigger('3')); h.engine.stopTicket(ticketId)
h.engine.deliveryFailed(ticketId, assignment, 'late')
expect(h.repo.tickets.get(ticketId)).toMatchObject({ ended: expect.anything(), undelivered: [] })
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('delivery')
})
it('unsubscribing a thread stops fan-out until a new decision subscribes it', async () => {
const h = harness()
const decision = accepted(h)
expect(h.engine.unsubscribe('chat:t1')).toBe(true)
h.engine.receive(trigger('2')); h.pump()
expect(h.repo.tickets.get(decision.ticketId!)!.inputs).toEqual([])
})
it('lets an Executor drop a thread attached to its ticket, keeping the thread\'s other tickets, but not its own threads', async () => {
const events: { type: string; data: Record<string, unknown> }[] = []
const h = harness({}, { emit: event => events.push(event) })
const first = accepted(h).ticketId!
const second = accepted(h, '2', 't2').ticketId!
h.engine.receive(trigger('3', { threads: [{ source: 'chat', id: 't2' }] }))
// A follow-up of the second ticket goes to the first only once the second's worker hands it back.
h.engine.handback(second, '3', 'It is about the first fix')
h.engine.attach(first, '3')
expect(h.repo.threads.get('chat:t2')!.subscriptions).toEqual([second, first])
h.engine.markUnrelated(first, 'chat:t2', 'about another fix')
expect(h.repo.threads.get('chat:t2')!.subscriptions).toEqual([second])
expect(events.filter(event => event.type === 'thread.unsubscribed').map(event => event.data))
.toEqual([{ threadKey: 'chat:t2', ticketId: first, by: 'executor', reason: 'about another fix' }])
expect(() => h.engine.markUnrelated(first, 'chat:t2', 'again')).toThrow(new UserError('notFollowing'))
expect(() => h.engine.markUnrelated(first, 'chat:t1', 'its own')).toThrow(new UserError('sourceThread'))
h.engine.bindThread(first, { source: 'gh', id: 'pr-9' }, true)
expect(() => h.engine.markUnrelated(first, 'gh:pr-9', 'its PR')).toThrow(new UserError('followedThread'))
})
})
describe('resume and stop', () => {
async function ended(h: ReturnType<typeof harness>, by: 'executor' | 'human') {
const decision = accepted(h)
if (by === 'executor') h.engine.report(decision.ticketId!, { state: 'ready', summary: 'done' })
else h.engine.stopTicket(decision.ticketId!)
return decision.ticketId!
}
it('resumes an ended ticket on any person\'s message on its thread, under the same mandate and without a Decision', async () => {
const h = harness()
const ticketId = await ended(h, 'executor')
const decisions = h.repo.decisions.byThread('chat:t1').length
// It resumes as the message arrives, before any Eval of it.
h.engine.receive(trigger('2', { actor: guest }))
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
h.pump()
const ticket = h.repo.tickets.get(ticketId)!
expect(ticket.assignments).toHaveLength(2)
expect(ticket.mandate.policyVersion).toBe('v1')
expect(ticket.inputs.map(input => input.kind)).toEqual(['trigger', 'resumed'])
expect(h.executor.start).toHaveBeenCalledTimes(2)
// The message still reaches Eval as a follow-up, but no reopen Decision is needed or possible.
expect(h.repo.decisions.byThread('chat:t1').filter(decision => decision.kind !== 'ignore')).toHaveLength(decisions)
})
it('resumes an ended ticket an earlier version left with a person\'s message waiting, on the next pass', async () => {
const h = harness()
const ticketId = await ended(h, 'executor')
h.engine.receive(trigger('2', { actor: guest, threads: [{ source: 'chat', id: 'elsewhere' }] }))
const stored = h.repo.tickets.get(ticketId)!
const input = { id: 'waiting', kind: 'trigger' as const, triggerId: '2', at: stored.updatedAt, human: true as const }
h.repo.tickets.save({ ...stored, inputs: [...stored.inputs, input], undelivered: ['waiting'] })
h.pump()
expect(h.repo.tickets.get(ticketId)).toMatchObject({ inputs: expect.arrayContaining([expect.objectContaining({ kind: 'resumed' })]) })
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('ended')
})
it('resumes an ended ticket at once when a person attaches a message to it', async () => {
const h = harness()
const ticketId = await ended(h, 'executor')
h.engine.receive(trigger('2', { actor: guest, threads: [{ source: 'chat', id: 'elsewhere' }] }))
const subject = h.engine.attach(ticketId, '2')
expect(subject).toMatchObject({ assignments: [expect.anything(), expect.objectContaining({ state: 'pending' })] })
expect(subject).not.toHaveProperty('ended')
})
it('keeps a ticket a person stopped ended: the stop withdrew its mandate', async () => {
const h = harness()
const ticketId = await ended(h, 'human')
expect(h.executor.stop).toHaveBeenCalledOnce()
h.engine.receive(trigger('2')); h.pump()
expect(h.repo.tickets.get(ticketId)).toMatchObject({ ended: expect.objectContaining({ by: 'human' }) })
expect(h.executor.start).toHaveBeenCalledOnce()
})
it('keeps a ticket ended when its mandate no longer passes the current policy', async () => {
const h = harness()
const ticketId = await ended(h, 'executor')
h.setVersion('revoked')
h.engine.receive(trigger('2')); h.pump()
expect(h.repo.tickets.get(ticketId)!.ended).toBeDefined()
})
it('stops a live ticket only on an explicit instruction and ends it by the human', async () => {
const h = harness()
const decision = accepted(h)
// Follow-ups that reached the ticket: the gate judges them when the main Session acts on one, a guest's as inferred.
h.engine.receive(trigger('2', { actor: guest })); h.pump()
expect(h.engine.decide('2', { kind: 'stop', ticketId: decision.ticketId!, reason: 'x' })).toMatchObject({ kind: 'ignore',
strength: 'inferred', reason: [{ code: 'stopNotExplicit' }] })
h.engine.receive(trigger('3')); h.pump()
expect(h.engine.decide('3', { kind: 'stop', ticketId: decision.ticketId!, reason: 'human said stop' }).kind).toBe('stop')
expect(h.repo.tickets.get(decision.ticketId!)).toMatchObject({ ended: expect.objectContaining({ by: 'human' }) })
expect(h.executor.stop).toHaveBeenCalledOnce()
})
})
describe('follow-ups', () => {
it('takes a follow-up an older version queued out of the queue without a Decision, and keeps one sent to Eval again', () => {
const h = harness()
accepted(h)
h.engine.receive(trigger('2', { actor: guest })); h.engine.receive(trigger('3', { actor: guest }))
// As an older version left them: both queued for Eval; a person sent the second there again.
const thread = h.repo.threads.get('chat:t1')!
h.repo.threads.save({ ...thread, pending: ['2', '3'] })
h.engine.reevaluate('3'); h.pump()
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['3'])
expect(h.repo.threads.get('chat:t1')!.pending).toEqual([])
expect(h.repo.decisions.byThread('chat:t1').flatMap(decision => decision.triggerIds)).toEqual(['1'])
})
it('decides a follow-up in a gate-only Eval beside the Eval its Thread has in progress, which goes on', () => {
const h = harness()
const decision = accepted(h)
h.engine.receive(trigger('2', { actor: guest })); h.engine.reevaluate('2'); h.pump()
const evaluating = h.repo.threads.get('chat:t1')!.evaluating!
h.engine.receive(trigger('3'))
expect(h.engine.decide('3', { kind: 'stop', ticketId: decision.ticketId!, reason: 'stop it' })).toMatchObject({ kind: 'stop',
triggerIds: ['3'] })
expect(h.repo.threads.get('chat:t1')!.evaluating).toEqual(evaluating)
expect(h.decide(h.requests.at(-1)!, { kind: 'ignore', reason: 'chat' })).toMatchObject({ kind: 'ignore', triggerIds: ['2'] })
})
it('leaves the Thread free for a later request that reaches no ticket', () => {
const h = harness()
const decision = accepted(h)
h.engine.receive(trigger('2', { actor: guest })); h.pump()
h.engine.stopTicket(decision.ticketId!, 'not needed')
// The follow-up held no Eval while the ticket decided it, so the next request is assessed at once rather than after a
// timeout, together with the follow-up, which the stop let go of before its worker took it up.
h.engine.receive(trigger('3')); h.pump()
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['2', '3'])
})
})
describe('hand-back', () => {
it('queues a message that reached a ticket for a fresh Eval with the gate\'s strength, once per ticket, and keeps the subscription',
() => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2')); h.engine.receive(trigger('3', { actor: guest })); h.pump()
expect(h.engine.handback(ticketId, '2', 'A new request')).toBe(true); h.pump()
expect(h.requests.at(-1)).toMatchObject({ strength: 'explicit', triggers: [{ id: '2' }] })
expect(h.repo.triggers.disposition('2')!.handbacks).toEqual([{ ticketId, reason: 'A new request', at: expect.any(String) }])
h.decide(h.requests.at(-1)!, { kind: 'ignore', reason: 'chat' })
expect(() => h.engine.handback(ticketId, '2', 'Again')).toThrow(new UserError('handedBack'))
expect(h.engine.handback(ticketId, '3', 'A guest asks for more')).toBe(true); h.pump()
expect(h.requests.at(-1)).toMatchObject({ strength: 'inferred', triggers: [{ id: '3' }] })
expect(h.repo.threads.get('chat:t1')!.subscriptions).toContain(ticketId)
// Only a message that reached the ticket can go back.
h.engine.receive(trigger('4', { threads: [{ source: 'chat', id: 'elsewhere' }] }))
expect(() => h.engine.handback(ticketId, '4', 'Not mine')).toThrow(new UserError('notReached'))
})
it('queues again no message the ticket did not decide: one a person sent to Eval, a bot\'s, or one on a Thread followed for context',
() => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.followForContext(ticketId, { source: 'chat', id: 'issue' })
h.engine.receive(trigger('2')); h.engine.reevaluate('2')
h.engine.receive(trigger('3', { human: false })); h.engine.receive(trigger('4', { threads: [{ source: 'chat', id: 'issue' }] })); h.pump()
for (const candidate of h.requests.slice(-2)) h.decide(candidate, { kind: 'ignore', reason: 'chat' })
// Each went to Eval without the ticket, and its Decision stands.
for (const id of ['2', '3', '4']) expect(h.engine.handback(ticketId, id, 'Not this ticket')).toBe(false)
expect([h.repo.threads.get('chat:t1')!.pending, h.repo.threads.get('chat:issue')!.pending]).toEqual([[], []])
// The hand-back is recorded all the same, so the main Session does not attach the message back.
expect(h.repo.triggers.disposition('4')!.handbacks).toEqual([{ ticketId, reason: 'Not this ticket', at: expect.any(String) }])
expect(() => h.engine.attach(ticketId, '4')).toThrow(new UserError('handedBackHere'))
})
it('leaves a message the main Session attached to a ticket to its worker, whose hand-back sends it to Eval', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2', { threads: [{ source: 'chat', id: 'x' }] })); h.pump()
h.engine.attach(ticketId, '2'); h.settle()
// Routed to the ticket, it is not new work as well.
expect(() => h.engine.decide('2', { kind: 'accept', title: 'Y', scope: 'small', reason: 'requested' }))
.toThrow(new UserError('workerDecides', { tickets: ticketId }))
expect(h.engine.handback(ticketId, '2', 'Asks for another change')).toBe(true)
expect(h.repo.threads.get('chat:x')!.pending).toEqual(['2'])
})
})
describe('context threads and notes', () => {
it('fans a Thread followed for context out to the live ticket, resumes no ended ticket from it, unless it was attached', async () => {
const h = harness()
const ticketId = accepted(h).ticketId!
expect(h.engine.followForContext(ticketId, { source: 'chat', id: 'issue' })).toBe(true)
expect(h.engine.followForContext(ticketId, { source: 'chat', id: 'issue' })).toBe(false)
h.engine.receive(trigger('2', { threads: [{ source: 'chat', id: 'issue' }] })); h.settle()
expect(h.repo.tickets.get(ticketId)!.inputs.map(input => input.kind === 'trigger' && input.triggerId)).toEqual(['2'])
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
h.engine.receive(trigger('3', { threads: [{ source: 'chat', id: 'issue' }] }))
expect(h.repo.tickets.get(ticketId)!.ended).toBeDefined()
// Attached by the main Session, the Thread is the ticket's own, and a person's later message there resumes it.
h.engine.attach(ticketId, '3'); h.settle()
expect(h.repo.threads.get('chat:issue')).toMatchObject({ context: [] })
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.tickets.get(ticketId)!.ended).toBeDefined()
h.engine.receive(trigger('4', { threads: [{ source: 'chat', id: 'issue' }] }))
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
})
it('counts a message on a Thread a ticket follows only for context as none of its follow-ups, and lifts no block with it', () => {
const h = harness()
const owner = accepted(h).ticketId!
const follower = accepted(h, '2', 'other').ticketId!
h.engine.followForContext(follower, { source: 'chat', id: 't1' })
h.engine.receive(trigger('3', { actor: guest })); h.pump()
expect(h.repo.triggers.disposition('3')).toMatchObject({ fanout: [owner, follower], context: [follower] })
expect(h.repo.tickets.get(follower)!.inputs.at(-1)).toMatchObject({ triggerId: '3', context: true })
// On the follower's own Thread no longer: a message reaching only it goes to Eval, with the guest's that the stopped owner
// let go of.
h.engine.stopTicket(owner, 'done')
h.engine.report(follower, { state: 'blocked', summary: 'needs a decision' })
h.engine.receive(trigger('4')); h.pump()
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['3', '4'])
expect(waitsForPerson(h.repo.tickets.get(follower)!)).toBe(true)
})
it('sends a message to Eval when the ticket it reached cannot resume and only a ticket following its Thread for context is left', () => {
const h = harness()
const owner = accepted(h).ticketId!
const follower = accepted(h, '2', 'other').ticketId!
h.engine.followForContext(follower, { source: 'chat', id: 't1' })
h.engine.report(owner, { state: 'ready', summary: 'done' })
h.setVersion('revoked')
h.engine.receive(trigger('3')); h.setVersion('v1'); h.pump()
expect(h.repo.triggers.disposition('3')).toMatchObject({ fanout: [follower], context: [follower] })
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['3'])
})
it('gives an open ticket\'s worker a note as an Input of its own, held like automatic Input, and refuses one for an ended ticket', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
expect(h.engine.note(ticketId, 'The other ticket renamed the API').inputs.at(-1)).toMatchObject({ kind: 'note',
text: 'The other ticket renamed the API' })
// A worker that reported blocked waits for a person: a note does not wake it.
h.settle()
h.engine.report(ticketId, { state: 'blocked', summary: 'needs a decision' })
const note = h.engine.note(ticketId, 'Ticket B changed the schema').inputs.at(-1)!
h.settle()
expect(h.delivered.map(([, id]) => id)).not.toContain(note.id)
expect(h.repo.tickets.get(ticketId)!.inputs).toContainEqual(note)
h.engine.stopTicket(ticketId, 'done')
expect(() => h.engine.note(ticketId, 'Too late')).toThrow(new UserError('ticketEnded'))
})
})
describe('follow-up owners', () => {
const accept = { kind: 'accept' as const, title: 'Y', scope: 'small', reason: 'requested' }
const decides = (...tickets: string[]) => new UserError('workerDecides', { tickets: tickets.join(', ') })
it('lets the main Session take a follow-up as new work once it stopped the ticket on it', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
// "Stop this, and do Y instead": the stop ends the ticket that decided the message, so the request is the main Session's.
h.engine.receive(trigger('2')); h.settle()
expect(() => h.engine.decide('2', accept)).toThrow(decides(ticketId))
expect(h.engine.decide('2', { kind: 'stop', ticketId, reason: 'asked to stop' }).kind).toBe('stop')
// At the same moment, the accept is a Decision of its own beside the stop's.
expect(h.engine.decide('2', accept)).toMatchObject({ kind: 'accept', triggerIds: ['2'] })
expect(h.repo.decisions.byThread('chat:t1').filter(item => item.triggerIds.includes('2')).map(item => item.kind)).toEqual(['stop',
'accept'])
})
it('sends a follow-up its worker never received to Eval when the ticket ends, by a report or a stop', () => {
for (const end of ['report', 'stop'] as const) {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2'))
expect(h.repo.tickets.get(ticketId)!.undelivered).toHaveLength(1)
if (end === 'report') h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
else h.engine.stopTicket(ticketId, 'not needed')
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
h.pump()
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['2'])
}
})
it('sends a follow-up its worker got since its last report to Eval when the ticket ends, unless it acknowledged it', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'progress', summary: 'working' })
// The worker acknowledges one of two messages, and its final report was on its way before it took up the other.
h.tick(1); h.engine.receive(trigger('2')); h.engine.receive(trigger('3')); h.settle()
h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '2', text: 'On it' })
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['3'])
// The ticket no longer counts as having reached it, so its candidate names no ticket that gave no reason.
expect([h.repo.triggers.disposition('2')!.fanout, h.repo.triggers.disposition('3')!.fanout]).toEqual([[ticketId], []])
})
it('keeps a ticket open after its pull request merged while its worker is on a person\'s message it took up after its last report',
() => {
for (const taken of [true, false]) {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, true)
h.engine.report(ticketId, { state: 'ready', summary: 'PR ready' })
// A person asks for one more change, which the worker takes up or has not yet, and the pull request merges meanwhile.
h.tick(1); h.engine.receive(trigger('2')); h.settle()
if (taken) h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '2', text: 'On it' })
h.engine.threadClosed({ source: 'gh', id: 'pr-9' }, 'merged', '2026-09-23T00:00:01Z')
// One it has not taken up leaves the merge to end the ticket, and goes to Eval.
expect([h.repo.tickets.get(ticketId)!.ended?.outcome, h.repo.threads.get('chat:t1')!.pending]).toEqual(taken ? [undefined, []]
: ['merged', ['2']])
// The worker's ready report ends it as the pull request did.
if (taken) h.engine.report(ticketId, { state: 'ready', summary: 'Done' })
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ outcome: 'merged' })
}
})
it('gives a follow-up its ended ticket let go to that ticket again when the main Session attaches it there', () => {
for (const resumed of [false, true]) {
const h = harness()
const ticketId = accepted(h).ticketId!
// The ticket ends before its worker took up a person's message, which goes to Eval; a later message may resume it.
h.engine.report(ticketId, { state: 'progress', summary: 'working' })
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
if (resumed) { h.tick(1); h.engine.receive(trigger('3')) }
h.pump()
h.tick(1); h.engine.attach(ticketId, '2'); h.settle()
// Its worker gets the message again, and an ended ticket resumes for it.
const copies = h.repo.tickets.get(ticketId)!.inputs.filter(input => input.kind === 'trigger' && input.triggerId === '2')
expect(h.delivered).toContainEqual([ticketId, copies[1]?.id])
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
expect(followupOwners(trigger('2'), h.repo)).toEqual([ticketId])
}
})
it('wakes an ended ticket for a message attached to it that its worker read only for context or before its last report', () => {
for (const read of ['context', 'report'] as const) {
const h = harness()
const ticketId = accepted(h).ticketId!
if (read === 'context') h.engine.followForContext(ticketId, { source: 'chat', id: 'issue' })
h.tick(1); h.engine.receive(trigger('2', { threads: [{ source: 'chat', id: read === 'context' ? 'issue' : 't1' }] })); h.settle()
// The worker reports after reading it and drops the ticket; a person sends a follow-up it had to Eval from the panel.
h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'read it' })
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
if (read === 'report') h.engine.reevaluate('2')
h.pump()
h.tick(1); h.engine.attach(ticketId, '2'); h.settle()
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
expect(followupOwners(h.repo.triggers.get('2')!, h.repo)).toEqual([ticketId])
}
})
it('counts a question the final report answers as taken up, so it does not come back as a candidate beside its answer', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.report(ticketId, { state: 'ready', summary: 'It reads the config.', answers: '2' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual([])
expect(h.repo.triggers.disposition('2')).toMatchObject({ fanout: [ticketId], acked: [ticketId] })
})
it('sends to Eval a message attached after its Eval was unavailable, once its ticket ends without taking it up', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2', { threads: [{ source: 'chat', id: 'x' }] })); h.pump()
h.decide(h.requests.at(-1)!, { kind: 'unavailable', reason: 'model down' })
h.tick(1); h.engine.attach(ticketId, '2'); h.settle()
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.threads.get('chat:x')!.pending).toEqual(['2'])
})
it('counts an acknowledgement an earlier version queued without recording it on the message as taking the message up', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'progress', summary: 'working' })
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.outbox.push({ id: key('ack', '2'), channel: trigger('2').channel!, text: 'On it', kind: 'acknowledgement', ticketId })
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect([h.repo.threads.get('chat:t1')!.pending, h.repo.triggers.disposition('2')!.fanout]).toEqual([[], [ticketId]])
})
it('counts a message that resumed a ticket as taken up by the report that ends it again, unlike one that came later', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'ready', summary: 'done' })
// A person's thanks resumes the ticket, and its worker takes it into a step first, a moment after its Assignment started,
// as the Executor delivers it; a request comes while it reports that nothing is left.
h.tick(1); h.engine.receive(trigger('2'))
h.tick(1); h.engine.assignmentStarted(ticketId, h.repo.tickets.get(ticketId)!.assignments.at(-1)!.id)
h.tick(1); h.settle(); h.read(ticketId)
h.tick(1); h.engine.receive(trigger('3')); h.settle()
h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['3'])
})
it('sends a follow-up its ticket let go to Eval after a stop on it was refused, but not the one a stop ended it on', () => {
// A guest's stop is refused, and the worker drops the ticket; a member's stop ends it, and the main Session, which stopped
// it, decides the rest of the message itself.
for (const actor of [guest, member]) {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2', { actor }))
h.engine.decide('2', { kind: 'stop', ticketId, reason: 'asked to stop' })
if (actor === guest) h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(actor === guest ? ['2'] : [])
}
})
it('sends the message a stop ended a ticket on to Eval once another ticket it reached ends without taking it up', () => {
const h = harness()
const stopped = accepted(h).ticketId!, other = accepted(h, '5', 't5').ticketId!
h.engine.bindThread(other, { source: 'chat', id: 't1' }, false)
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.decide('2', { kind: 'stop', ticketId: stopped, reason: 'asked to stop' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual([])
h.engine.report(other, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
})
it('hands the follow-ups on a thread a worker found unrelated back to Eval, with its reason', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const elsewhere = { threads: [{ source: 'chat', id: 'x' }] }
h.engine.receive(trigger('2', elsewhere)); h.pump()
h.engine.attach(ticketId, '2'); h.settle()
h.engine.receive(trigger('3', elsewhere)); h.settle()
expect(() => h.engine.decide('3', accept)).toThrow(decides(ticketId))
// The worker finds the attached thread is not its ticket's, and answers nothing there: the message the main Session
// attached goes back with the later one, and each candidate says why.
h.engine.markUnrelated(ticketId, 'chat:x', 'about something else')
expect(h.repo.threads.get('chat:x')!.pending).toEqual(['2', '3'])
expect(h.repo.triggers.disposition('3')!.handbacks).toEqual([{ ticketId, reason: 'about something else', at: expect.any(String) }])
h.pump()
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['2', '3'])
})
it('keeps a follow-up two tickets reached with the one that did not hand it back, and queues it once neither decides it', () => {
const h = harness()
const first = accepted(h).ticketId!
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.handback(first, '2', 'another change'); h.pump()
const second = h.decide(h.requests.at(-1)!, accept).ticketId!
h.settle()
h.tick(1); h.engine.receive(trigger('3')); h.settle()
expect(h.repo.triggers.disposition('3')!.fanout).toEqual([first, second])
expect(h.engine.handback(first, '3', 'for the other ticket')).toBe(false)
expect(h.repo.threads.get('chat:t1')!.pending).toEqual([])
// The main Session may neither take nor dismiss it while the second ticket's worker decides it.
expect(() => h.engine.decide('3', accept)).toThrow(decides(second))
expect(() => h.engine.decide('3', { kind: 'ignore', reason: 'chat' })).toThrow(decides(second))
expect(h.engine.handback(second, '3', 'neither ticket')).toBe(true)
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['3'])
})
it('lets an Eval an earlier version started on a follow-up decide it', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2')); h.settle()
const thread = h.repo.threads.get('chat:t1')!
h.repo.threads.save({ ...thread, evaluating: { id: 'e-legacy', threadKey: 'chat:t1', triggerIds: ['2'], strength: 'explicit',
actor: member, basis: { via: 'listed' }, policyVersion: 'v1', startedAt: '2026-09-23T00:00:00Z', deadline: '2026-09-24T00:00:00Z' } })
expect(followupOwners(trigger('2'), h.repo)).toEqual([])
expect(h.engine.decide('2', { kind: 'ignore', reason: 'chat', evalId: 'e-legacy' })).toMatchObject({ kind: 'ignore',
triggerIds: ['2'] })
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
})
})
describe('interrupted assignments', () => {
it('starts an Assignment interrupted mid-work again with a note, and hands one that waits what waits for it', () => {
const h = harness()
const interrupted = accepted(h).ticketId!
const first = h.repo.tickets.get(interrupted)!.assignments[0]!
const waiting = accepted(h, '2', 't2').ticketId!
h.engine.report(waiting, { state: 'waiting', summary: 'done for now' })
h.engine.receive(trigger('3', { threads: [{ source: 'chat', id: 't2' }] }))
vi.mocked(h.executor.start).mockClear(); vi.mocked(h.executor.deliver).mockClear()
// The Executor reports each Assignment that was running; a fact about one that no longer runs is dropped.
for (const id of [interrupted, waiting]) h.engine.assignmentInterrupted(id, runningAssignment(h.repo.tickets.get(id)!)!.id)
h.engine.assignmentInterrupted(interrupted, first.id)
const ticket = h.repo.tickets.get(interrupted)!
expect(ticket.assignments).toEqual([expect.objectContaining({ id: first.id, state: 'ended', endedReason: 'hostRestarted' }),
expect.objectContaining({ state: 'pending' })])
expect(ticket.inputs).toEqual([expect.objectContaining({ kind: 'text', text: expect.stringContaining('The host restarted') })])
expect(ticket.undelivered).toEqual([ticket.inputs[0]!.id])
expect(h.executor.start).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ id: interrupted }), ticket.assignments[1])
// A worker that waits keeps its Session and gets what waits for it.
expect(h.repo.tickets.get(waiting)!.assignments).toHaveLength(1)
expect(h.executor.deliver).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ id: waiting }), expect.objectContaining({ state: 'running' }))
})
it('starts again a worker that reported but then got a person\'s message, which it was working on', () => {
const h = harness()
const other = accepted(h, '2', 't2').ticketId!
h.engine.report(other, { state: 'waiting', summary: 'done for now' })
h.tick(1); h.engine.receive(trigger('3', { actor: guest, threads: [{ source: 'chat', id: 't2' }] })); h.settle()
const running = runningAssignment(h.repo.tickets.get(other)!)!
expect(running.deliveredAt! > running.report!.at).toBe(true)
h.engine.assignmentInterrupted(other, running.id)
expect(h.repo.tickets.get(other)!.assignments.map(item => item.state)).toEqual(['ended', 'pending'])
})
})
describe('pause', () => {
it('lets no Eval expire while processing is paused, and moves its deadline on by the pause\'s length', () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
h.engine.candidateDelivered(h.requests[0]!.evalId)
const deadline = Date.parse(h.repo.threads.get('chat:t1')!.evaluating!.deadline)
h.tick(30); h.setPaused(true); h.pump()
// Paused past the deadline, the Eval waits.
h.tick(300); h.pump()
expect(h.engine.candidate('1')).toBeDefined()
h.setPaused(false); h.pump()
expect(Date.parse(h.repo.threads.get('chat:t1')!.evaluating!.deadline)).toBe(deadline + 300_000)
h.tick(29); h.pump()
expect(h.engine.candidate('1')).toBeDefined()
// Expired, it queues the Trigger again for a fresh Eval.
h.tick(1); h.pump()
expect(h.engine.candidate('1')?.evalId).not.toBe(h.requests[0]!.evalId)
expect(h.repo.decisions.byThread('chat:t1')).toEqual([])
})
})
describe('policy change', () => {
it('tells only the live tickets a change bears on how it does, and restarts in-flight evals under the new version', async () => {
const h = harness()
const decision = accepted(h)
const other = accepted(h, '3', 't3').ticketId!
h.engine.receive(trigger('2', { threads: [{ source: 'chat', id: 't2' }] })); h.pump()
const effect = { rules: [{ id: 'copy', name: 'Copy', change: 'changed' as const, instruction: 'Fix typos only.' }] }
h.setVersion('v2'); h.engine.policyChanged('v1', 'v2', new Map([[decision.ticketId!, effect]]))
expect(h.repo.tickets.get(decision.ticketId!)!.inputs).toEqual([expect.objectContaining({ kind: 'policy-changed', from: 'v1', to: 'v2', effect })])
expect(h.repo.tickets.get(other)!.inputs.filter(input => input.kind === 'policy-changed')).toEqual([])
expect(h.repo.decisions.byThread('chat:t2')).toEqual([expect.objectContaining({ kind: 'unavailable', reason: [{ code: 'policyChanged' }] })])
expect(h.repo.threads.get('chat:t2')?.pending).toEqual(['2'])
h.pump()
expect(h.requests.at(-1)).toMatchObject({ policyVersion: 'v2', triggers: [expect.objectContaining({ id: '2' })] })
const told = h.repo.tickets.get(decision.ticketId!)!.inputs.find(input => input.kind === 'policy-changed')!
expect(h.delivered).toEqual([[decision.ticketId, told.id]])
})
it('does not start evals while paused', async () => {
const h = harness({ paused: true })
h.engine.receive(trigger('1')); h.pump()
expect(h.assessor.request).not.toHaveBeenCalled()
})
})
describe('worker reports', () => {
it('asks, keeps, replaces and clears the question for a person, and says which question a report replaced', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const asked = h.engine.report(ticketId, { state: 'progress', summary: 'need a call', question: 'Which color?', askedBy: '1' })
expect(asked.attention).toMatchObject({ question: 'Which color?', triggerId: '1' })
expect(h.engine.report(ticketId, { state: 'blocked', summary: 'still waiting' }).attention).toEqual(asked.attention)
expect(h.withdrawn).toEqual([])
const replaced = h.engine.report(ticketId, { state: 'progress', summary: 'other question', question: 'Which size?' })
expect(replaced.attention).toMatchObject({ question: 'Which size?' })
expect(replaced.attention).not.toHaveProperty('triggerId')
// The replaced question is withdrawn from the outbox if its message has not gone out yet.
expect(h.withdrawn).toEqual([key('attention-reply', asked.attention!.id)])
const cleared = h.engine.report(ticketId, { state: 'progress', summary: 'answered', question: null })
expect(cleared).not.toHaveProperty('attention')
expect(h.withdrawn).toEqual([key('attention-reply', asked.attention!.id), key('attention-reply', replaced.attention!.id)])
})
it('ends the ticket on a final report: dropped drops it, ready completes it unless it follows a Source\'s Thread that is open', () => {
const outcome = (state: 'ready' | 'dropped' | 'failed', follows: boolean, closed = false) => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'progress', summary: 'asking', question: 'Merge it?' })
if (follows) h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, false)
if (follows) h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-10' }, true)
// Work resumed after both closed ends with its ready report, using the latest close outcome.
if (closed) {
h.engine.threadClosed({ source: 'gh', id: 'pr-9' }, 'closed', '2026-09-22T00:00:00Z')
h.engine.threadClosed({ source: 'gh', id: 'pr-10' }, 'merged', '2026-09-22T00:00:01Z')
}
h.engine.report(ticketId, { state, summary: 'done' })
return h.repo.tickets.get(ticketId)!
}
expect(outcome('dropped', true)).toMatchObject({ ended: { by: 'executor', outcome: 'dropped' } })
expect(outcome('dropped', true).ended).not.toHaveProperty('reason')
expect(outcome('ready', false)).toMatchObject({ ended: { outcome: 'completed' } })
expect(outcome('ready', false)).not.toHaveProperty('attention')
expect(outcome('ready', true)).toMatchObject({ follows: [{ thread: 'gh:pr-9', own: false }, { thread: 'gh:pr-10', own: true }],
assignments: [expect.objectContaining({ report: expect.objectContaining({ state: 'ready' }) })] })
expect(outcome('ready', true)).not.toHaveProperty('ended')
expect(outcome('ready', true)).not.toHaveProperty('attention')
expect(outcome('ready', true, true)).toMatchObject({ ended: { outcome: 'merged' } })
expect(outcome('failed', false)).toMatchObject({ attention: { question: 'Merge it?' } })
expect(outcome('failed', false)).not.toHaveProperty('ended')
})
it('refuses a question with a result or an answer, and questions or answers that name no person\'s message of the ticket', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2', { human: false, threads: [{ source: 'chat', id: 't1' }] }))
h.engine.receive(trigger('4', { threads: [{ source: 'chat', id: 'elsewhere' }] }))
const report = (extra: object) => () => h.engine.report(ticketId, { state: 'progress', summary: 'x', ...extra })
expect(report({ state: 'ready', question: 'Q?' })).toThrow(new UserError('questionWithResult'))
expect(report({ question: 'Q?', answers: '1' })).toThrow(new UserError('questionWithResult'))
expect(report({ askedBy: '1' })).toThrow(new UserError('askerWithoutQuestion'))
expect(report({ question: 'Q?', askedBy: '2' })).toThrow(new UserError('notAsker'))
expect(report({ answers: '4' })).toThrow(new UserError('notAnswerable'))
expect(report({ answers: '1' })()).toMatchObject({ answers: ['1'] })
})
it('refuses a report on an ended ticket', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.stopTicket(ticketId)
expect(() => h.engine.report(ticketId, { state: 'progress', summary: 'x' })).toThrow(new UserError('ticketEnded'))
})
})
describe('outcomes', () => {
it('records how a ticket ended', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.stopTicket(ticketId, 'Human asked to stop')
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ by: 'human', outcome: 'stopped', reason: 'Human asked to stop' })
expect(ticketOutcome(h.repo.tickets.get(ticketId)!)).toBe('stopped')
expect(ticketOutcome({})).toBeUndefined()
})
})
describe('attach', () => {
it('sends the routing note for a message the worker has, ends an Eval that holds only that message, and refuses a ticket a person stopped or the Policy no longer holds', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.receive(trigger('2', { actor: guest })); h.pump()
expect(h.delivered.map(([, input]) => input)).toEqual([key('input', ticketId, 'trigger', '2')])
// The follow-up reached the ticket without an Eval, and is its worker's to judge; sent to Eval again by a person, it is judged there.
expect(h.engine.candidate('2')).toBeUndefined()
expect(() => h.engine.attach(ticketId, '2')).toThrow(new UserError('workerDecides', { tickets: ticketId }))
expect(h.engine.reevaluate('2')).toBe(true); h.pump()
expect(h.engine.candidate('2')).toBeDefined()
const attached = h.engine.attach(ticketId, '2', 'The guest asks for the same fix'); h.settle()
expect(attached.inputs).toEqual([expect.objectContaining({ triggerId: '2' }), expect.objectContaining({ triggerId: '2', note: 'The guest asks for the same fix' })])
expect(h.delivered.map(([, input]) => input)).toEqual(attached.inputs.map(input => input.id))
expect(h.engine.candidate('2')).toBeUndefined()
expect(h.repo.decisions.byThread('chat:t1').at(-1)).toMatchObject({ kind: 'ignore', reason: [{ code: 'routed', values: { ticket: ticketId } }] })
// A note for a message that still waits for the worker goes out with it, as for one a person sent to Eval at once.
h.engine.receive(trigger('5', { actor: guest })); h.engine.reevaluate('5')
expect(h.engine.attach(ticketId, '5', 'Same fix again').inputs.at(-1)).toMatchObject({ triggerId: '5', note: 'Same fix again' })
h.settle()
h.engine.report(ticketId, { state: 'ready', summary: 'done' })
h.setVersion('revoked')
expect(() => h.engine.attach(ticketId, '2')).toThrow(new UserError('mandateRevoked'))
h.setVersion('v1')
const stopped = accepted(h, '3', 't3').ticketId!
h.engine.stopTicket(stopped)
expect(() => h.engine.attach(stopped, '2')).toThrow(new UserError('ticketStopped'))
expect(() => h.engine.attach('missing', '2')).toThrow(new UserError('unknownTicket'))
})
it('ends the batch Eval of a message attached to a ticket and assesses the rest again, leaving the message to that ticket', () => {
const h = harness()
const ticketId = accepted(h).ticketId!, other = accepted(h, '5', 't5').ticketId!
const elsewhere = { threads: [{ source: 'chat', id: 'x' }] }
h.engine.receive(trigger('2', elsewhere)); h.engine.receive(trigger('3', elsewhere)); h.pump()
h.engine.attach(ticketId, '3'); h.pump()
expect(h.repo.decisions.byThread('chat:x')).toEqual([expect.objectContaining({ kind: 'unavailable', triggerIds: ['2', '3'],
reason: [{ code: 'routed', triggerId: '3', values: { ticket: ticketId } }] })])
expect(h.engine.candidate('2')?.triggers.map(item => item.id)).toEqual(['2'])
expect(followupOwners(h.repo.triggers.get('3')!, h.repo)).toEqual([ticketId])
expect(() => h.engine.attach(other, '3')).toThrow(new UserError('workerDecides', { tickets: ticketId }))
})
it('names missing things apart from refusals and fills in the values', () => {
expect([new UserError('unknownTicket').missing, new UserError('ticketEnded').missing]).toEqual([true, false])
expect(new UserError('notAdmissible', { reason: 'not_a_member' })).toMatchObject({ code: 'notAdmissible', values: { reason: 'not_a_member' }, message: 'Trigger is not admissible: not_a_member' })
})
})
describe('direct decisions', () => {
it('decides an explicit request no Eval holds in a direct Eval, refuses a proactive accept without matched rules, and accepts a Trigger once', () => {
const h = harness()
h.engine.receive(trigger('1'))
const decision = h.engine.decide('1', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'routed' })
expect(decision).toMatchObject({ kind: 'accept', triggerIds: ['1'], strength: 'explicit' })
expect(h.repo.threads.get('chat:t1')).toMatchObject({ pending: [] })
expect(h.repo.threads.get('chat:t1')).not.toHaveProperty('evaluating')
expect(h.engine.decide('1', { kind: 'accept', title: 'Again', scope: 'small', reason: 'routed' })).toEqual(decision)
// A guest's follow-up reached that ticket without an Eval: its worker decides it, and only what the worker hands back can
// become new work, under the gate's authority, inferred.
h.engine.receive(trigger('2', { actor: guest })); h.pump()
const ticketId = decision.ticketId!
expect(() => h.engine.decide('2', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'rule', rules: ['r1'] }))
.toThrow(new UserError('workerDecides', { tickets: ticketId }))
h.engine.handback(ticketId, '2', 'A different fix')
expect(() => h.engine.decide('2', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'proactive' })).toThrow(new UserError('notExplicit'))
expect(h.engine.decide('2', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'rule',
rules: ['r1'] })).toMatchObject({ kind: 'accept', strength: 'inferred' })
expect(() => h.engine.decide('missing', { kind: 'ignore', reason: 'x' })).toThrow(new UserError('unknownTrigger'))
})
it('accepts an inferred candidate only on rules the Policy admits, whoever names them', () => {
const h = harness({ admitsInferred: rules => rules.includes('enabled') })
accepted(h)
h.engine.receive(trigger('2', { actor: guest })); h.engine.reevaluate('2'); h.pump()
const candidate = h.requests.at(-1)!
expect(() => h.decide(candidate, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'rule', rules: ['disabled'] })).toThrow(new UserError('notExplicit'))
expect(h.decide(candidate, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'rule', rules: ['enabled'] })).toMatchObject({ kind: 'accept' })
})
it('ends the Eval of a replayed Trigger that started a ticket, and returns that ticket\'s Decision for it', () => {
const h = harness()
const decision = accepted(h)
h.tick(1)
expect(h.engine.reevaluate('1')).toBe(true)
h.pump()
expect(h.engine.candidate('1')).toBeUndefined()
expect(h.repo.decisions.byThread('chat:t1').at(-1)).toMatchObject({ kind: 'ignore', triggerIds: ['1'],
reason: [{ code: 'startedTicket', triggerId: '1', values: { ticket: decision.ticketId } }] })
expect(h.engine.decide('1', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'again' })).toEqual(decision)
expect(h.repo.tickets.list()).toHaveLength(1)
expect(() => h.engine.reevaluate('missing')).toThrow(new UserError('unknownTrigger'))
})
it('keeps a replayed Trigger that started a ticket out of the Eval of the Triggers queued with it', () => {
const h = harness()
const decision = accepted(h)
h.engine.receive(trigger('2', { actor: guest })); h.engine.reevaluate('2'); h.engine.reevaluate('1'); h.pump()
// The member's replayed request lends the guest's message no authority.
expect(h.engine.candidate('1')).toBeUndefined()
expect(h.engine.candidate('2')).toMatchObject({ strength: 'inferred', triggers: [{ id: '2' }] })
expect(() => h.engine.decide('2', { kind: 'accept', title: 'Other', scope: 'small', reason: 'routed' })).toThrow(new UserError('notExplicit'))
expect(h.engine.decide('1', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'again' })).toEqual(decision)
expect(h.repo.tickets.list()).toHaveLength(1)
})
it('starts a ticket from a request accepted beside a replayed one, with its own origin and acknowledgement', () => {
const h = harness()
const first = accepted(h)
h.tick(1); h.engine.receive(trigger('2')); h.engine.reevaluate('2'); h.engine.reevaluate('1'); h.pump()
const second = h.decide(h.requests.at(-1)!, { kind: 'accept', title: 'Other', scope: 'small', reason: 'routed', acknowledgement: 'On it' })
expect(second.ticketId).not.toBe(first.ticketId)
expect(h.repo.tickets.get(second.ticketId!)!.origin).toEqual({ triggerId: '2', triggerIds: ['2'] })
expect(h.outbox.find(message => message.id === key('ack', '2'))).toMatchObject({ text: 'On it', kind: 'acknowledgement' })
// The replayed request still answers with the ticket it started.
expect(h.engine.decide('1', { kind: 'accept', title: 'Again', scope: 'small', reason: 'again' })).toEqual(first)
})
})
describe('automatic input', () => {
it('holds back automatic Input from a worker that reported blocked or failed until a person\'s message arrives', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const bot = (id: string) => trigger(id, { human: false })
h.engine.receive(bot('2'))
h.engine.report(ticketId, { state: 'blocked', summary: 'needs a decision' })
// Both pending and subsequent automated inputs stay queued on the ticket without waking the worker.
h.engine.receive(bot('3')); h.settle()
expect(h.repo.tickets.get(ticketId)).toMatchObject({ undelivered: [], inputs: [expect.objectContaining({ triggerId: '2' }), expect.objectContaining({ triggerId: '3' })] })
expect(h.delivered).toEqual([])
h.tick(1)
h.engine.receive(trigger('4', { actor: guest })); h.engine.receive(bot('5')); h.settle()
expect(h.delivered.map(([, input]) => input)).toEqual([key('input', ticketId, 'trigger', '4'), key('input', ticketId, 'trigger', '5')])
})
it('stops waiting for a person on a person\'s message, a later report that is neither blocked nor failed, or a retry', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const ticket = () => h.repo.tickets.get(ticketId)!
h.engine.report(ticketId, { state: 'blocked', summary: 'needs a decision' })
expect(waitsForPerson(ticket())).toBe(true)
expect(ticketStage(ticket())).toEqual({ stage: 'person', action: 'blocked' })
// The worker has the person's reply, so the stale blocked report no longer holds the ticket.
h.tick(1); h.engine.receive(trigger('2', { actor: guest })); h.settle()
expect(waitsForPerson(ticket())).toBe(false)
expect(ticketStage(ticket())).toEqual({ stage: 'working' })
h.tick(1); h.engine.report(ticketId, { state: 'failed', summary: 'broke' })
expect(waitsForPerson(ticket())).toBe(true)
h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'found a way' })
expect(waitsForPerson(ticket())).toBe(false)
h.tick(1); h.engine.report(ticketId, { state: 'blocked', summary: 'needs access' })
h.engine.retryTicket(ticketId)
expect(waitsForPerson(ticket())).toBe(false)
})
it('launches a failed launch again for a person\'s message, but not for a Policy change', () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
const ticketId = h.decide(h.requests.at(-1)!, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'requested' }).ticketId!
h.engine.report(ticketId, { state: 'failed', summary: 'The prepare command failed' })
vi.mocked(h.executor.start).mockClear()
h.setVersion('v2'); h.engine.policyChanged('v1', 'v2')
expect(h.executor.start).not.toHaveBeenCalled()
h.tick(1); h.engine.receive(trigger('2', { actor: guest }))
expect(h.executor.start).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ id: ticketId }), expect.objectContaining({ state: 'pending' }))
})
})
describe('replies', () => {
it('acknowledges a person\'s message that reached an open ticket once, and refuses others', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
// The acknowledgement of the accept stays; a later request for one reuses it.
expect(h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '1', text: 'On it' })).toEqual({ id: key('ack', '1'), status: 'pending', reused: true })
expect(h.outbox.map(message => message.text)).toEqual(['ok'])
h.engine.receive(trigger('2', { actor: guest }))
expect(h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '2', text: 'Looking' })).toEqual({ id: key('ack', '2'), status: 'pending', reused: false })
h.engine.receive(trigger('3', { human: false }))
expect(() => h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '3', text: 'x' })).toThrow(new UserError('ackNotPerson'))
h.engine.receive(trigger('6', { threads: [{ source: 'chat', id: 'elsewhere' }] }))
expect(() => h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '6', text: 'x' })).toThrow(new UserError('notReached'))
h.engine.stopTicket(ticketId)
expect(() => h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '2', text: 'x' })).toThrow(new UserError('ackEnded'))
})
it('sends an answer only after the worker reported it, a result only for a ready ticket, and attention only for its current question', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const asked = trigger('2', { actor: guest, channel: { source: 'chat', thread: 't1', address: { message: '2' } } })
h.engine.receive(asked)
expect(() => h.engine.reply({ purpose: 'answer', triggerId: '2', text: 'Yes' })).toThrow(new UserError('noAnswer'))
h.engine.report(ticketId, { state: 'progress', summary: 'Yes', answers: '2' })
expect(h.engine.reply({ purpose: 'answer', triggerId: '2', text: 'Yes' })).toMatchObject({ status: 'pending', reused: false })
expect(h.outbox.at(-1)).toMatchObject({ channel: asked.channel, text: 'Yes', kind: 'reply' })
expect(h.repo.tickets.get(ticketId)!.answers).toEqual([])
expect(() => h.engine.reply({ purpose: 'result', ticketId, text: 'Done' })).toThrow(new UserError('notReady'))
h.engine.report(ticketId, { state: 'progress', summary: 'asking', question: 'Which color?', askedBy: '2' })
expect(() => h.engine.reply({ purpose: 'attention', ticketId, question: 'Which size?', text: 'x' })).toThrow(new UserError('notAttention'))
// Without an open question there is nothing to ask, whether or not a question is named.
const quiet = accepted(h, '9', 't9').ticketId!
expect(() => h.engine.reply({ purpose: 'attention', ticketId: quiet, text: 'Anything to send.' })).toThrow(new UserError('notAttention'))
expect(() => h.engine.reply({ purpose: 'attention', ticketId: quiet, question: '', text: 'Anything to send.' })).toThrow(new UserError('notAttention'))
const attention = h.repo.tickets.get(ticketId)!.attention!
expect(h.engine.reply({ purpose: 'attention', ticketId, question: 'Which color?', text: 'Which color?' }).id).toBe(key('attention-reply', attention.id))
// A question goes where the message that raised it was written.
expect(h.outbox.at(-1)!.channel).toEqual(asked.channel)
h.engine.report(ticketId, { state: 'ready', summary: 'Done' })
expect(h.engine.reply({ purpose: 'result', ticketId, text: 'Done' })).toMatchObject({ status: 'pending' })
expect(h.outbox.at(-1)!.channel).toEqual(trigger('1').channel)
})
})
describe('ticket commands', () => {
it('ends a ticket by the same rule on a close and on a ready report: once no PR it opened is open, as the latest of them ended', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
// It started from someone's PR and opened its own.
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-5' }, false)
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-7' }, true)
h.engine.threadClosed({ source: 'gh', id: 'pr-7' }, 'merged', '2026-09-23T00:00:00Z')
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ outcome: 'merged' })
// A person's comment resumes it; its ready report ends it again as its own PR did, though the one it started from is open.
h.tick(10); h.engine.receive(trigger('2', { threads: [{ source: 'gh', id: 'pr-5' }] })); h.settle()
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('ended')
h.engine.report(ticketId, { state: 'ready', summary: 'Answered' })
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ outcome: 'merged' })
// The PR it started from closing later changes nothing.
h.engine.threadClosed({ source: 'gh', id: 'pr-5' }, 'closed', '2026-09-23T00:00:30Z')
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ outcome: 'merged' })
// A ticket that opened no PR ends when the one it follows closes, as it did.
const other = accepted(h, '3', 't3').ticketId!
h.engine.bindThread(other, { source: 'gh', id: 'pr-8' }, false)
h.engine.report(other, { state: 'ready', summary: 'Pushed to their branch' })
expect(h.repo.tickets.get(other)).not.toHaveProperty('ended')
h.engine.threadClosed({ source: 'gh', id: 'pr-8' }, 'closed', '2026-09-23T00:01:00Z')
expect(h.repo.tickets.get(other)!.ended).toMatchObject({ outcome: 'closed' })
})
it('stops following a Thread whose Subscription a person removes', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-7' }, true)
expect(h.engine.unsubscribe('gh:pr-7')).toBe(true)
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('follows')
// Its ready report completes it, since it follows no Thread now.
h.engine.report(ticketId, { state: 'ready', summary: 'Done' })
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ outcome: 'completed' })
})
it('follows a Source\'s Threads, and ends once no Thread of its own is open, unless resumed work has not finished', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const close = (id: string, outcome: 'merged' | 'closed', at: string) => {
const open = h.repo.tickets.open().map(ticket => ticket.id)
h.engine.threadClosed({ source: 'gh', id }, outcome, at)
return open.filter(item => h.repo.tickets.get(item)!.ended)
}
expect(h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, false)).toMatchObject({ follows: [{ thread: 'gh:pr-9', own: false }] })
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-10' }, true)
expect(h.repo.threads.get('gh:pr-9')!.subscriptions).toEqual([ticketId])
// The Thread it started from closes while its own is open: it records the close and works on.
expect(close('pr-9', 'closed', '2026-09-23T00:00:00Z')).toEqual([])
expect(h.repo.tickets.get(ticketId)).toMatchObject({ follows: [{ thread: 'gh:pr-9', closed: { outcome: 'closed' } }, { thread: 'gh:pr-10' }] })
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('ended')
// Its own closes too: it ends as that one did, when it did, once.
expect(close('pr-10', 'merged', '2026-09-23T00:00:01Z')).toEqual([ticketId])
expect(h.repo.tickets.get(ticketId)).toMatchObject({ ended: { by: 'executor', outcome: 'merged', at: '2026-09-23T00:00:01.000Z' } })
expect(close('pr-10', 'merged', '2026-09-23T00:00:01Z')).toEqual([])
expect(() => h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-11' }, true)).toThrow(new UserError('ticketEnded'))
// A ticket retried after its PR closed but before the close was reported works on until it reports ready.
const other = accepted(h, '2', 't2').ticketId!
h.engine.bindThread(other, { source: 'gh', id: 'pr-12' }, true)
h.engine.report(other, { state: 'failed', summary: 'tests fail' })
h.tick(10); h.engine.retryTicket(other)
expect(close('pr-12', 'closed', '2026-09-23T00:00:05Z')).toEqual([])
expect(h.repo.tickets.get(other)!.ended).toBeUndefined()
h.engine.report(other, { state: 'ready', summary: 'done' })
expect(h.repo.tickets.get(other)).toMatchObject({ ended: { outcome: 'closed' } })
})
it('retries a ticket that reported failed with a new Assignment, and asks only a waiting worker for its status', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
expect(() => h.engine.retryTicket(ticketId)).toThrow(new UserError('notRetryable'))
expect(h.engine.requestStatus(ticketId)).toBe(false)
expect(h.repo.tickets.get(ticketId)!.inputs).toEqual([])
h.engine.report(ticketId, { state: 'waiting', summary: 'idle' })
expect(h.engine.requestStatus(ticketId)).toBe(true)
expect(h.repo.tickets.get(ticketId)!.inputs).toEqual([expect.objectContaining({ kind: 'text', text: expect.stringContaining('status-only') })])
h.engine.report(ticketId, { state: 'failed', summary: 'broke' })
const retried = h.engine.retryTicket(ticketId)
expect(retried.assignments).toEqual([expect.objectContaining({ state: 'ended', endedReason: 'retried' }), expect.objectContaining({ state: 'pending' })])
expect(h.executor.stop).toHaveBeenCalledOnce()
expect(h.executor.start).toHaveBeenLastCalledWith(expect.objectContaining({ id: ticketId }), retried.assignments[1])
})
})
describe('failed deliveries', () => {
it('forgets a failed delivery once a new Assignment takes over or the ticket ends', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const failed = () => {
h.engine.deliveryFailed(ticketId, runningAssignment(h.repo.tickets.get(ticketId)!)!.id, 'Session busy')
expect(h.repo.tickets.get(ticketId)!.delivery).toMatchObject({ error: 'Session busy' })
}
failed()
h.engine.report(ticketId, { state: 'blocked', summary: 'needs access' })
h.engine.retryTicket(ticketId); h.settle()
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('delivery')
failed()
h.engine.stopTicket(ticketId)
expect(h.repo.tickets.get(ticketId)).not.toHaveProperty('delivery')
})
})
describe('problems', () => {
it('dismisses the problem an unavailable Decision shows, and only such a Decision', () => {
const h = harness()
const decision = accepted(h)
expect(() => h.engine.dismiss(decision.id)).toThrow(new UserError('unknownProblem'))
h.engine.receive(trigger('2', { threads: [{ source: 'chat', id: 't2' }] })); h.pump()
const unavailable = h.decide(h.requests.at(-1)!, { kind: 'unavailable', reason: 'model down' })
expect(h.engine.dismiss(unavailable.id)).toMatchObject({ kind: 'unavailable', dismissed: true })
expect(h.repo.decisions.get(unavailable.id)).toMatchObject({ dismissed: true })
})
})
describe('retry after a failed delivery', () => {
it('starts a new Assignment for a ticket whose Input keeps failing to reach its worker, which takes the Input', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'failed', summary: 'tests fail' })
// A person's answer goes to the failed worker, and delivering it fails.
h.tick(5); h.engine.receive(trigger('2'))
const answered = h.repo.tickets.get(ticketId)!
expect(answered.undelivered).toHaveLength(1)
h.engine.deliveryFailed(ticketId, runningAssignment(answered)!.id, 'Session gone')
expect(ticketStage(h.repo.tickets.get(ticketId)!)).toEqual({ stage: 'person', action: 'delivery' })
const retried = h.engine.retryTicket(ticketId)
expect(retried.assignments.map(item => item.state)).toEqual(['ended', 'pending'])
expect(retried).not.toHaveProperty('delivery')
expect(retried.undelivered).toHaveLength(1)
})
})
describe('Evals across Policy versions, revoked Mandates and mixed batches', () => {
it('queues an Eval\'s batch again when it is decided under an older Policy, such as after an upgrade changed the version', () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
const candidate = h.requests.at(-1)!
// An upgrade changes the version without a Policy change being reported.
h.setVersion('v2')
expect(h.decide(candidate, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'r' })).toMatchObject({ kind: 'unavailable', reason: [{ code: 'policyChanged' }] })
expect(h.repo.threads.get('chat:t1')).toMatchObject({ pending: ['1'] })
h.pump()
expect(h.requests.at(-1)).toMatchObject({ policyVersion: 'v2', triggers: [expect.objectContaining({ id: '1' })] })
})
it('queues a person\'s message for Eval when the only ticket it reached cannot resume under the Policy now, and says it reached none', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'ready', summary: 'Done' }); h.settle()
h.setVersion('revoked')
h.tick(5); h.engine.receive(trigger('2'))
expect(h.repo.tickets.get(ticketId)).toMatchObject({ undelivered: [], ended: expect.anything() })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
expect(followupOwners(trigger('2'), h.repo)).toEqual([])
})
it('queues a person\'s message that waited out a pause for Eval when the ticket it reached cannot resume once the pause lifts', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'ready', summary: 'Done' }); h.settle()
h.setPaused(true)
h.tick(5); h.engine.receive(trigger('2')); h.pump()
expect(h.repo.tickets.get(ticketId)!.undelivered).toHaveLength(1)
h.setVersion('revoked'); h.setPaused(false); h.engine.evaluateDue()
expect(h.repo.tickets.get(ticketId)).toMatchObject({ undelivered: [], ended: expect.anything() })
expect(h.repo.threads.get('chat:t1')!.pending.includes('2') || h.engine.candidate('2') !== undefined).toBe(true)
})
it('takes a new ticket\'s origin and acknowledgement from the admissible Trigger that gave its batch its strength', () => {
const h = harness()
h.engine.receive(trigger('1'))
h.tick(1); h.engine.receive(trigger('2', { human: false, actor: { source: 'chat', id: 'ci-bot' }, channel: { source: 'chat', thread: 'chat:t1', address: { message: 'bot' } } }))
h.pump()
expect(h.requests.at(-1)!.triggers.map(item => item.id)).toEqual(['1', '2'])
const decision = h.decide(h.requests.at(-1)!, { kind: 'accept', title: 'Fix', scope: 'small', reason: 'r', acknowledgement: 'ok' })
expect(h.repo.tickets.get(decision.ticketId!)!.origin).toEqual({ triggerId: '1', triggerIds: ['1', '2'] })
expect(h.outbox).toEqual([expect.objectContaining({ id: key('ack', '1'), channel: trigger('1').channel })])
})
})
describe('gaps', () => {
it('takes a mixed batch\'s strength, actor and basis from its strongest admissible Trigger, the latest of equal strength', () => {
// A guest's message is admitted as inferred, as on a Thread a ticket follows.
const gate: Policy['gate'] = t => t.actor.id === 'alice' ? { admissible: true, strength: 'explicit', basis: { via: 'listed' } }
: { admissible: true, strength: 'inferred', basis: { via: 'followup' } }
for (const order of [['guest', 'alice'], ['alice', 'guest']]) {
const h = harness({ gate })
order.forEach((who, index) => { h.tick(1); h.engine.receive(trigger(String(10 + index), { actor: who === 'alice' ? member : guest })) })
h.pump()
const alice = String(10 + order.indexOf('alice'))
expect(h.repo.threads.get('chat:t1')!.evaluating).toMatchObject({ strength: 'explicit', actor: member, basis: { via: 'listed' }, primary: alice })
}
const named = harness({ gate: t => ({ admissible: true, strength: 'explicit', basis: { via: 'listed', sender: t.actor.id } }) })
named.engine.receive(trigger('1', { actor: { source: 'chat', id: 'bob' } }))
named.tick(1); named.engine.receive(trigger('2', { actor: { source: 'chat', id: 'carol' } }))
named.pump()
expect(named.repo.threads.get('chat:t1')!.evaluating).toMatchObject({ actor: { id: 'carol' }, basis: { sender: 'carol' }, primary: '2' })
})
it('changes nothing and delivers nothing on a late start of an Assignment a retry replaced', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const first = h.repo.tickets.get(ticketId)!.assignments[0]!.id
h.engine.report(ticketId, { state: 'failed', summary: 'broke' })
h.engine.retryTicket(ticketId)
const before = h.repo.tickets.get(ticketId)!
const delivered = h.delivered.length
h.engine.assignmentStarted(ticketId, first); h.settle({ launch: false })
expect(h.repo.tickets.get(ticketId)!.assignments).toEqual(before.assignments)
expect(h.delivered).toHaveLength(delivered)
})
it('records a stop that names an unknown or ended ticket as ignored', () => {
const h = harness()
const ended = accepted(h).ticketId!
h.engine.report(ended, { state: 'ready', summary: 'Done' })
for (const [id, ticketId] of [['2', 'missing'], ['3', ended]] as const) {
h.tick(1); h.engine.receive(trigger(id, { threads: [{ source: 'chat', id: `t${id}` }] })); h.pump()
expect(h.decide(h.requests.at(-1)!, { kind: 'stop', ticketId, reason: 'stop it' })).toMatchObject({ kind: 'ignore', reason: [{ code: 'stopUnknown' }] })
}
expect(h.repo.tickets.get(ended)!.ended).toMatchObject({ by: 'executor', outcome: 'completed' })
})
it('refuses a direct decision on a Trigger the gate does not admit, and leaves the queue as it was', () => {
const h = harness()
h.engine.receive(trigger('1', { human: false }))
const pending = h.repo.threads.get('chat:t1')!.pending
expect(() => h.engine.decide('1', { kind: 'ignore', reason: 'noise' })).toThrow(new UserError('notAdmissible', { reason: 'not human' }))
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(pending)
expect(h.repo.decisions.byThread('chat:t1')).toEqual([])
})
it('refuses to retry a ticket a person stopped after its failed report', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'failed', summary: 'broke' })
h.engine.stopTicket(ticketId)
expect(() => h.engine.retryTicket(ticketId)).toThrow(new UserError('notRetryable'))
})
it('answers a question once, sends a result only for finished work, and the same result once', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.report(ticketId, { state: 'progress', summary: 'It reads the config.', answers: '2' })
expect(h.engine.reply({ purpose: 'answer', triggerId: '2', text: 'It reads the config.' })).toMatchObject({ reused: false })
expect(() => h.engine.reply({ purpose: 'answer', triggerId: '2', text: 'Something else.' })).toThrow(new UserError('noAnswer'))
expect(() => h.engine.reply({ purpose: 'result', ticketId, text: 'Done' })).toThrow(new UserError('notReady'))
h.engine.report(ticketId, { state: 'ready', summary: 'Done' })
const sent = h.outbox.length
expect(h.engine.reply({ purpose: 'result', ticketId, text: 'Done' })).toMatchObject({ reused: false })
expect(h.engine.reply({ purpose: 'result', ticketId, text: 'Done' })).toMatchObject({ reused: true })
expect(h.outbox).toHaveLength(sent + 1)
// A ticket whose PR closed unmerged, or that a person stopped, has no result to send.
const closed = accepted(h, '5', 't5').ticketId!
h.engine.bindThread(closed, { source: 'gh', id: 'pr-1' }, true)
h.engine.threadClosed({ source: 'gh', id: 'pr-1' }, 'closed', '2026-09-23T00:01:00Z')
expect(() => h.engine.reply({ purpose: 'result', ticketId: closed, text: 'x' })).toThrow(new UserError('notReady'))
const stopped = accepted(h, '6', 't6').ticketId!
h.engine.stopTicket(stopped)
expect(() => h.engine.reply({ purpose: 'result', ticketId: stopped, text: 'x' })).toThrow(new UserError('notReady'))
})
it('keeps an Eval\'s deadline on a repeated delivery, refuses to queue a held Trigger again, and puts expired Triggers first', () => {
const h = harness()
h.engine.receive(trigger('1')); h.pump()
const evalId = h.requests[0]!.evalId
h.engine.candidateDelivered(evalId)
const deadline = h.repo.threads.get('chat:t1')!.evaluating!.deadline
h.tick(30); h.engine.candidateDelivered(evalId)
expect(h.repo.threads.get('chat:t1')!.evaluating!.deadline).toBe(deadline)
expect(h.engine.reevaluate('1')).toBe(false)
// A Trigger that arrives meanwhile queues behind the expired one, and the fresh Eval takes both in that order.
h.tick(1); h.engine.receive(trigger('2', { actor: guest }))
h.tick(30); h.engine.evaluateDue()
expect(h.repo.threads.get('chat:t1')!.evaluating!.triggerIds).toEqual(['1', '2'])
expect(h.repo.decisions.byThread('chat:t1')).toEqual([])
})
})
describe('final review', () => {
it('ignores an Eval an earlier version left holding only a Trigger that started a ticket, instead of recording it unavailable', () => {
const h = harness()
const decision = accepted(h)
// An earlier version could leave an Eval holding the Trigger after it started a ticket.
const thread = h.repo.threads.get('chat:t1')!
h.repo.threads.save({ ...thread, evaluating: { id: 'old-eval', threadKey: 'chat:t1', triggerIds: ['1'], primary: '1', strength: 'explicit', actor: member,
basis: { via: 'listed' }, policyVersion: 'v1', startedAt: '', deadline: '2999-01-01T00:00:00.000Z' } })
expect(h.engine.decide('1', { kind: 'accept', title: 'Again', scope: 'small', reason: 'replayed' })).toEqual(decision)
expect(h.repo.decisions.byThread('chat:t1').at(-1)).toMatchObject({ kind: 'ignore', reason: [{ code: 'startedTicket', triggerId: '1',
values: { ticket: decision.ticketId } }] })
expect(h.repo.decisions.byThread('chat:t1').some(item => item.kind === 'unavailable')).toBe(false)
expect(h.repo.threads.get('chat:t1')).not.toHaveProperty('evaluating')
})
it('clears the count of expired Evals when the gate ignores what a Thread queued', () => {
const h = harness()
h.engine.receive(trigger('1', { human: false }))
h.repo.threads.save({ ...h.repo.threads.get('chat:t1')!, expired: 1 })
h.pump()
expect(h.repo.decisions.byThread('chat:t1')).toEqual([expect.objectContaining({ kind: 'ignore' })])
expect(h.repo.threads.get('chat:t1')).not.toHaveProperty('expired')
})
})
describe('final review: Policy transitions', () => {
it('tells a worker of each Policy change that bears on its ticket, also of one that repeats an earlier transition', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const effect = { requesterAllowed: false as const }
for (const [from, to] of [['A', 'B'], ['B', 'A'], ['A', 'B']]) { h.tick(1); h.engine.policyChanged(from!, to!, new Map([[ticketId, effect]])) }
expect(h.repo.tickets.get(ticketId)!.inputs.filter(input => input.kind === 'policy-changed')).toHaveLength(3)
})
})
describe('final review: Threads followed for context', () => {
it('lets a worker acknowledge or ask no one on a Thread it follows only for context', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.followForContext(ticketId, { source: 'chat', id: 'side' })
h.tick(1); h.engine.receive(trigger('5', { threads: [{ source: 'chat', id: 'side' }], channel: { source: 'chat', thread: 'side', address: { message: '5' } } }))
expect(() => h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '5', text: 'On it' })).toThrow(new UserError('notReached'))
expect(() => h.engine.report(ticketId, { state: 'progress', summary: 'A question', question: 'Which page?', askedBy: '5' })).toThrow(new UserError('notAsker'))
})
})
describe('final review: close times', () => {
it('records a Source\'s close time in the canonical form, and keeps the ticket\'s updatedAt moving forward', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-1' }, true)
h.tick(30)
const before = h.repo.tickets.get(ticketId)!.updatedAt
// GitHub gives whole seconds, and the PR merged before the ticket's last change.
h.engine.threadClosed({ source: 'gh', id: 'pr-1' }, 'merged', '2026-09-23T00:00:05Z')
const ended = h.repo.tickets.get(ticketId)!
expect(ended.ended).toMatchObject({ at: '2026-09-23T00:00:05.000Z', outcome: 'merged' })
expect(ended.follows).toEqual([{ thread: 'gh:pr-1', own: true, closed: { at: '2026-09-23T00:00:05.000Z', outcome: 'merged' } }])
expect(ended.updatedAt >= before).toBe(true)
})
})
describe('model-surface review', () => {
it('resumes an ended ticket only on a person\'s message, not on a bot\'s that is attached to it', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'ready', summary: 'Done' })
h.tick(1); h.engine.receive(trigger('2', { human: false, actor: { source: 'chat', id: 'ci' }, threads: [{ source: 'chat', id: 'ci' }] }))
h.engine.attach(ticketId, '2')
expect(h.repo.tickets.get(ticketId)!.ended).toBeDefined()
// A person's message resumes it, with what waited.
h.tick(1); h.engine.receive(trigger('3'))
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
})
})
describe('follow-up review: turn ends', () => {
const merge = (h: ReturnType<typeof harness>) => h.engine.threadClosed({ source: 'gh', id: 'pr-9' }, 'merged', '2026-09-23T00:00:01Z')
it('lets a merge send to Eval only the messages its worker had no turn to read after its ready report', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, true)
h.engine.report(ticketId, { state: 'ready', summary: 'PR ready' })
// A person thanks the worker, which reads it in a turn and leaves it; another message comes after that turn.
h.tick(1); h.engine.receive(trigger('2')); h.settle(); h.read(ticketId)
h.tick(1); h.engine.turnEnded(ticketId, runningAssignment(h.repo.tickets.get(ticketId)!)!.id)
h.tick(1); h.engine.receive(trigger('3')); h.settle()
merge(h)
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['3'])
})
it('keeps a ticket open at a merge while its worker works on a message it took up, also after a progress report', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, true)
h.engine.report(ticketId, { state: 'ready', summary: 'PR ready' })
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '2', text: 'On it' })
// It answers another question in a progress report while it works on the message.
h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'Answered the other question' })
merge(h)
expect(h.repo.tickets.get(ticketId)!.ended).toBeUndefined()
})
it('ends a ticket whose close it deferred as the close did when its worker then drops the work', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, true)
h.engine.report(ticketId, { state: 'ready', summary: 'PR ready' })
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.engine.reply({ purpose: 'acknowledgement', ticketId, triggerId: '2', text: 'On it' })
merge(h)
h.engine.report(ticketId, { state: 'dropped', summary: 'Nothing more to do' })
expect(h.repo.tickets.get(ticketId)!.ended).toMatchObject({ outcome: 'merged' })
})
it('sends a message another ticket handed back to Eval when a stop on it ends the ticket that kept it', () => {
const h = harness()
const first = accepted(h).ticketId!
const second = accepted(h, '2', 't2').ticketId!
h.engine.bindThread(second, { source: 'chat', id: 't1' }, false)
h.engine.receive(trigger('3')); h.settle()
expect(h.engine.handback(second, '3', 'It is about the first fix')).toBe(false)
h.engine.decide('3', { kind: 'stop', ticketId: first, reason: 'asked to stop' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['3'])
})
it('gives a live ticket\'s worker again a message it had that a person sent to Eval and the main Session attached back', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'working' })
h.engine.reevaluate('2'); h.pump()
h.engine.attach(ticketId, '2')
expect(h.repo.tickets.get(ticketId)!.inputs.filter(input => input.kind === 'trigger' && input.triggerId === '2')).toHaveLength(2)
})
})
describe('follow-up review: turn ends and deliveries', () => {
it('lets a stop send to Eval a message that waited for delivery when a turn ended, and reached the worker after', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'progress', summary: 'working' })
h.tick(1); h.engine.receive(trigger('2'))
const ticket = () => h.repo.tickets.get(ticketId)!, assignmentId = runningAssignment(ticket())!.id
const [inputId] = ticket().undelivered
// The turn ends while the message is on its way; it reaches the worker after, and a person stops the ticket in that turn.
h.tick(1); h.engine.turnEnded(ticketId, assignmentId)
h.tick(1); h.engine.inputsDelivered(ticketId, assignmentId, [inputId!])
h.tick(1); h.engine.stopTicket(ticketId, 'not needed')
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
})
})
describe('follow-up review: delivery times', () => {
it('lets a stop send to Eval a message that came before the worker\'s waiting report but reached the worker after it', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.tick(1); h.engine.receive(trigger('2'))
const ticket = () => h.repo.tickets.get(ticketId)!, assignmentId = runningAssignment(ticket())!.id
const [inputId] = ticket().undelivered
// The Executor reports the worker waiting while the message is on its way; it reaches the worker after, and a person stops.
h.tick(1); h.engine.report(ticketId, { state: 'waiting', summary: 'Waiting for input' })
h.tick(1); h.engine.inputsDelivered(ticketId, assignmentId, [inputId!])
h.tick(1); h.engine.stopTicket(ticketId, 'not needed')
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
})
})
describe('routing review', () => {
it('records two stops of one follow-up that reached two tickets, made in the same moment, as two Decisions', () => {
const h = harness()
const first = accepted(h).ticketId!
const second = accepted(h, '2', 't2').ticketId!
h.engine.bindThread(second, { source: 'chat', id: 't1' }, false)
h.engine.receive(trigger('3')); h.settle()
const stops = [first, second].map(ticketId => h.engine.decide('3', { kind: 'stop', ticketId, reason: 'asked to stop' }))
expect(new Set(stops.map(stop => stop.id)).size).toBe(2)
expect(stops.map(stop => h.repo.decisions.get(stop.id)?.ticketId)).toEqual([first, second])
expect([first, second].map(id => h.repo.tickets.get(id)!.ended?.outcome)).toEqual(['stopped', 'stopped'])
})
})
describe('what a worker read', () => {
/** A ticket that ended, resumed by a person's message `2`, whose new Assignment started; its Inputs wait for delivery. */
function resumed() {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'ready', summary: 'done' })
h.tick(1); h.engine.receive(trigger('2'))
const assignmentId = h.repo.tickets.get(ticketId)!.assignments.at(-1)!.id
h.tick(1); h.engine.assignmentStarted(ticketId, assignmentId)
const [note, message] = h.repo.tickets.get(ticketId)!.undelivered as [string, string]
return { h, ticketId, assignmentId, note, message, pending: () => h.repo.threads.get('chat:t1')!.pending }
}
it('sends to Eval a message that woke a ticket and reached its worker only after a progress report, at a dropped report', () => {
const { h, ticketId, assignmentId, note, message, pending } = resumed()
// Delivering the message fails at first; the worker reports progress, and the retry hands the message over while it
// writes that nothing is left.
h.engine.inputsDelivered(ticketId, assignmentId, [note])
h.engine.deliveryFailed(ticketId, assignmentId, 'Session busy')
h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'Looking' })
h.tick(1); h.engine.inputsDelivered(ticketId, assignmentId, [message])
h.tick(1); h.engine.report(ticketId, { state: 'dropped', summary: 'Nothing to do' })
expect(pending()).toEqual(['2'])
})
it('counts a message that woke a ticket as read by a later dropped report once its worker\'s Session took it into a step', () => {
const { h, ticketId, assignmentId, note, message, pending } = resumed()
// The Session takes it into the step its arrival starts, before the Executor records the delivery.
h.engine.inputsRead(ticketId, [message])
h.engine.inputsDelivered(ticketId, assignmentId, [note, message])
h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'Looking' })
h.tick(1); h.engine.report(ticketId, { state: 'dropped', summary: 'Nothing to do' })
expect(pending()).toEqual([])
})
it('sends to Eval at a merge a message that reached the worker after its last step, which no step took before its turn ended', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.bindThread(ticketId, { source: 'gh', id: 'pr-9' }, true)
h.engine.report(ticketId, { state: 'ready', summary: 'PR ready' })
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.tick(1); h.engine.turnEnded(ticketId, runningAssignment(h.repo.tickets.get(ticketId)!)!.id)
h.engine.threadClosed({ source: 'gh', id: 'pr-9' }, 'merged', '2026-09-23T00:00:01Z')
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
})
it('counts a message a retry delivered after a failure and a turn end as read by a dropped report only once a step took it', () => {
for (const taken of [false, true]) {
const { h, ticketId, assignmentId, note, message, pending } = resumed()
h.engine.inputsDelivered(ticketId, assignmentId, [note])
h.engine.deliveryFailed(ticketId, assignmentId, 'Session busy')
h.tick(1); h.engine.turnEnded(ticketId, assignmentId)
h.tick(1); h.engine.inputsDelivered(ticketId, assignmentId, [message])
if (taken) h.read(ticketId)
h.tick(1); h.engine.report(ticketId, { state: 'dropped', summary: 'Nothing to do' })
expect(pending()).toEqual(taken ? [] : ['2'])
}
})
it('counts a message a step took before a restart cut its turn off as read only once the restarted worker reports', () => {
for (const reported of [false, true]) {
const h = harness()
const ticketId = accepted(h).ticketId!
h.engine.report(ticketId, { state: 'progress', summary: 'working' })
h.tick(1); h.engine.receive(trigger('2')); h.settle(); h.read(ticketId)
// The Assignment starts again with a recovery note, in the same Session, which holds the message.
h.tick(1); h.engine.assignmentInterrupted(ticketId, runningAssignment(h.repo.tickets.get(ticketId)!)!.id); h.settle()
if (reported) { h.tick(1); h.engine.report(ticketId, { state: 'progress', summary: 'Recovered' }) }
h.tick(1); h.engine.stopTicket(ticketId, 'not needed')
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(reported ? [] : ['2'])
}
})
})
describe('read claims review', () => {
it('gives a retry\'s Assignment the messages the failed one had delivered but no step of its worker took', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
h.tick(1); h.engine.receive(trigger('2')); h.settle()
const ticket = () => h.repo.tickets.get(ticketId)!
const inputId = ticket().inputs.find(input => input.kind === 'trigger' && input.triggerId === '2')!.id
expect(ticket().undelivered).toEqual([])
h.tick(1); h.engine.report(ticketId, { state: 'failed', summary: 'The turn broke.' })
h.tick(1); h.engine.retryTicket(ticketId)
expect(ticket().undelivered).toEqual([inputId])
})
})
describe('read claims review: retries', () => {
it('gives a retry only what the Assignment it replaces had delivered and no step took, not what an earlier end let go of', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const ticket = () => h.repo.tickets.get(ticketId)!
const inputOf = (triggerId: string) => ticket().inputs.find(input => input.kind === 'trigger' && input.triggerId === triggerId)!.id
// A follow-up reaches the worker, and the ticket ends before any step took it, which sends it to Eval.
h.tick(1); h.engine.receive(trigger('2')); h.settle()
h.tick(1); h.engine.report(ticketId, { state: 'dropped', summary: 'nothing to do' })
expect(h.repo.threads.get('chat:t1')!.pending).toEqual(['2'])
// A person's message resumes the ticket; its worker reports blocked before a step took the message, and a person retries.
h.tick(1); h.engine.receive(trigger('3')); h.settle()
h.tick(1); h.engine.report(ticketId, { state: 'blocked', summary: 'Which page?' })
h.tick(1); h.engine.retryTicket(ticketId)
expect(ticket().undelivered).toEqual([inputOf('3')])
})
})
describe('read claims review: restarts', () => {
it('gives a restart\'s Assignment what the replaced one had delivered and no step took, and a retry of it gets it again', () => {
const h = harness()
const ticketId = accepted(h).ticketId!
const ticket = () => h.repo.tickets.get(ticketId)!
const inputOf = (triggerId: string) => ticket().inputs.find(input => input.kind === 'trigger' && input.triggerId === triggerId)!.id
h.tick(1); h.engine.receive(trigger('2')); h.settle()
// The host restarts mid-work before a step took the message, which a cancel may have dropped: the new Assignment gets it.
h.tick(1); h.engine.assignmentInterrupted(ticketId, runningAssignment(ticket())!.id)
expect(ticket().undelivered[0]).toBe(inputOf('2'))
h.settle()
// That Assignment blocks before a step took it, and a person retries: the retry's Assignment gets it too.
h.tick(1); h.engine.report(ticketId, { state: 'blocked', summary: 'Which page?' })
h.tick(1); h.engine.retryTicket(ticketId)
expect(ticket().undelivered).toEqual([inputOf('2')])
})
})
describe('ignores from the card', () => {
const said = (text: string) => ({ code: 'said', values: { text } })
it('marks the ignore of an inferred candidate that no assessment judged, unlike an assessed one or an explicit candidate\'s', () => {
const h = harness()
accepted(h)
// A guest's message sent to Eval is an inferred candidate, which the main Session ignores from its card.
h.engine.receive(trigger('2', { actor: guest })); h.engine.reevaluate('2'); h.pump()
expect(h.requests.at(-1)).toMatchObject({ strength: 'inferred' })
expect(h.engine.decide('2', { kind: 'ignore', reason: 'Plain chat.' }).reason).toEqual([{ code: 'ignoredFromCard' }, said('Plain chat.')])
// An assessment's ignore keeps its rule judgments, without the mark.
h.engine.receive(trigger('3', { actor: guest })); h.engine.reevaluate('3'); h.pump()
expect(h.decide(h.requests.at(-1)!, { kind: 'ignore', reason: 'Plain chat.', audit: { rule_assessments: [] } }))
.toMatchObject({ reason: [said('Plain chat.')], audit: { rule_assessments: [] } })
// An explicit candidate is always routed from its card.
h.engine.receive(trigger('4', { threads: [{ source: 'chat', id: 't9' }] })); h.pump()
expect(h.engine.decide('4', { kind: 'ignore', reason: 'Thanks.' }).reason).toEqual([said('Thanks.')])
// Accepting an inferred candidate still takes an assessment's matched rules.
h.engine.receive(trigger('5', { actor: guest })); h.engine.reevaluate('5'); h.pump()
expect(() => h.engine.decide('5', { kind: 'accept', title: 'Fix', scope: 'small', reason: 'chat' })).toThrow(new UserError('notExplicit'))
})
})