Why: The send button, stop button, and queued sends each looked at a different slice of run state, so a reconnect or a late assistant message could disagree. Evidence: A depth-4 walk of send, HTTP results, SSE, reconnect, conversation switch, and flush keeps busy aligned with an in-flight run, hides the live bubble once an assistant message exists, and keeps one user message per request id. Impact: The event listener list is exhaustive over the stream contract, including run.check. A disconnected stream shows 重连中. Refs: feature/runstate-shell Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { canFlushOutbox, deriveShellPhase, hasInFlight, isShellBusy, showLiveBubble, type ShellFacts } from './runstate'
|
|
|
|
interface Model {
|
|
facts: ShellFacts
|
|
outbox: string[]
|
|
userRequestIds: string[]
|
|
flushed: string[]
|
|
}
|
|
|
|
const idle: ShellFacts = { posting: false, openRun: false, liveRunId: null, assistantRunIds: [] }
|
|
|
|
function model(): Model {
|
|
return { facts: { ...idle, assistantRunIds: [] }, outbox: [], userRequestIds: [], flushed: [] }
|
|
}
|
|
|
|
type Step =
|
|
| 'send'
|
|
| 'post-ok'
|
|
| 'post-409'
|
|
| 'post-fail'
|
|
| 'delta'
|
|
| 'assistant'
|
|
| 'done'
|
|
| 'reconnect'
|
|
| 'switch'
|
|
| 'queue'
|
|
| 'flush'
|
|
|
|
function apply(m: Model, step: Step): Model {
|
|
const facts = {
|
|
...m.facts,
|
|
assistantRunIds: m.facts.assistantRunIds.slice(),
|
|
}
|
|
const next: Model = { facts, outbox: m.outbox.slice(), userRequestIds: m.userRequestIds.slice(), flushed: m.flushed.slice() }
|
|
const phase = deriveShellPhase(next.facts)
|
|
const requestId = 'req-1'
|
|
switch (step) {
|
|
case 'send':
|
|
if (isShellBusy(phase)) {
|
|
next.outbox.push(requestId)
|
|
return next
|
|
}
|
|
next.facts.posting = true
|
|
return next
|
|
case 'post-ok':
|
|
next.facts.posting = false
|
|
next.facts.openRun = true
|
|
next.facts.liveRunId = 'run-1'
|
|
if (!next.userRequestIds.includes(requestId)) next.userRequestIds.push(requestId)
|
|
return next
|
|
case 'post-409':
|
|
next.facts.posting = false
|
|
if (!next.outbox.includes(requestId)) next.outbox.unshift(requestId)
|
|
return next
|
|
case 'post-fail':
|
|
next.facts.posting = false
|
|
return next
|
|
case 'delta':
|
|
if (next.facts.openRun) next.facts.liveRunId = 'run-1'
|
|
return next
|
|
case 'assistant':
|
|
if (next.facts.liveRunId && !next.facts.assistantRunIds.includes(next.facts.liveRunId)) {
|
|
next.facts.assistantRunIds.push(next.facts.liveRunId)
|
|
}
|
|
return next
|
|
case 'done':
|
|
next.facts.openRun = false
|
|
return next
|
|
case 'reconnect':
|
|
return next
|
|
case 'switch':
|
|
return model()
|
|
case 'queue':
|
|
next.outbox.push('queued')
|
|
return next
|
|
case 'flush': {
|
|
if (!canFlushOutbox(deriveShellPhase(next.facts)) || next.outbox.length === 0) return next
|
|
const id = next.outbox.shift() ?? ''
|
|
next.flushed.push(id)
|
|
next.facts.posting = true
|
|
if (id === requestId && !next.userRequestIds.includes(id)) next.userRequestIds.push(id)
|
|
return next
|
|
}
|
|
default: {
|
|
const unexpected: never = step
|
|
throw new Error(unexpected)
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertInvariants(m: Model) {
|
|
const phase = deriveShellPhase(m.facts)
|
|
expect(isShellBusy(phase)).toBe(hasInFlight(m.facts))
|
|
expect(showLiveBubble(m.facts.liveRunId, m.facts.assistantRunIds)).toBe(
|
|
!!m.facts.liveRunId && !m.facts.assistantRunIds.includes(m.facts.liveRunId),
|
|
)
|
|
if (isShellBusy(phase)) {
|
|
expect(canFlushOutbox(phase)).toBe(false)
|
|
}
|
|
expect(new Set(m.userRequestIds).size).toBe(m.userRequestIds.length)
|
|
}
|
|
|
|
const steps: Step[] = ['send', 'post-ok', 'post-409', 'post-fail', 'delta', 'assistant', 'done', 'reconnect', 'switch', 'queue', 'flush']
|
|
|
|
describe('shell run state', () => {
|
|
it('walks sequences up to depth 4 without breaking the four invariants', () => {
|
|
let frontier: Model[] = [model()]
|
|
for (let depth = 0; depth < 4; depth++) {
|
|
const grown: Model[] = []
|
|
for (const current of frontier) {
|
|
for (const step of steps) {
|
|
const next = apply(current, step)
|
|
assertInvariants(next)
|
|
grown.push(next)
|
|
}
|
|
}
|
|
frontier = grown
|
|
}
|
|
expect(frontier.length).toBe(steps.length ** 4)
|
|
})
|
|
|
|
it('follows sending, running, settling, then idle', () => {
|
|
let m = model()
|
|
m = apply(m, 'send')
|
|
expect(deriveShellPhase(m.facts)).toBe('sending')
|
|
m = apply(m, 'post-ok')
|
|
expect(deriveShellPhase(m.facts)).toBe('running')
|
|
expect(showLiveBubble(m.facts.liveRunId, m.facts.assistantRunIds)).toBe(true)
|
|
m = apply(m, 'done')
|
|
expect(deriveShellPhase(m.facts)).toBe('settling')
|
|
m = apply(m, 'assistant')
|
|
expect(deriveShellPhase(m.facts)).toBe('idle')
|
|
expect(showLiveBubble(m.facts.liveRunId, m.facts.assistantRunIds)).toBe(false)
|
|
})
|
|
})
|