feat: initial release v0.3.0
This commit is contained in:
370
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
370
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
||||
import { applyRunStreamEvent, getStageOutput } from '@/lib/query/hooks/run-stream/state-machine'
|
||||
|
||||
function applySequence(events: RunStreamEvent[]) {
|
||||
let state = null
|
||||
for (const event of events) {
|
||||
state = applyRunStreamEvent(state, event)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
describe('run stream state-machine', () => {
|
||||
it('marks unfinished steps as failed when run.error arrives', () => {
|
||||
const runId = 'run-1'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-a',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-b',
|
||||
stepTitle: 'B',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
text: 'ok',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'run.error',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'failed',
|
||||
message: 'exception TypeError: fetch failed sending request',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.status).toBe('failed')
|
||||
expect(state?.stepsById['step-a']?.status).toBe('failed')
|
||||
expect(state?.stepsById['step-a']?.errorMessage).toContain('fetch failed')
|
||||
expect(state?.stepsById['step-b']?.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns readable error output for failed step without stream text', () => {
|
||||
const output = getStageOutput({
|
||||
id: 'step-failed',
|
||||
attempt: 1,
|
||||
title: 'failed',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
status: 'failed',
|
||||
dependsOn: [],
|
||||
blockedBy: [],
|
||||
groupId: null,
|
||||
parallelKey: null,
|
||||
retryable: true,
|
||||
textOutput: '',
|
||||
reasoningOutput: '',
|
||||
textLength: 0,
|
||||
reasoningLength: 0,
|
||||
message: '',
|
||||
errorMessage: 'exception TypeError: fetch failed sending request',
|
||||
updatedAt: Date.now(),
|
||||
seqByLane: {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(output).toContain('【错误】')
|
||||
expect(output).toContain('fetch failed sending request')
|
||||
})
|
||||
|
||||
it('merges retry attempts into one step instead of duplicating stage entries', () => {
|
||||
const runId = 'run-2'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'first-attempt',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1_r2',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1_r2',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'retry-output',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepOrder).toEqual(['clip_x_phase1'])
|
||||
expect(state?.stepsById['clip_x_phase1']?.attempt).toBe(2)
|
||||
expect(state?.stepsById['clip_x_phase1']?.textOutput).toBe('retry-output')
|
||||
})
|
||||
|
||||
it('resets step output when a higher stepAttempt starts and ignores stale lower attempt chunks', () => {
|
||||
const runId = 'run-3'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'old-output',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 2,
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
lane: 'text',
|
||||
seq: 2,
|
||||
textDelta: 'should-be-ignored',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.200Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 2,
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'new-output',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['clip_y_phase1']?.attempt).toBe(2)
|
||||
expect(state?.stepsById['clip_y_phase1']?.textOutput).toBe('new-output')
|
||||
})
|
||||
|
||||
it('reopens completed step when late chunk arrives, then finalizes on run.complete', () => {
|
||||
const runId = 'run-4'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_characters',
|
||||
stepTitle: 'characters',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'analyze_characters',
|
||||
stepTitle: 'characters',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
text: 'partial',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_characters',
|
||||
lane: 'text',
|
||||
seq: 2,
|
||||
textDelta: '-tail',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'run.complete',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'completed',
|
||||
payload: { ok: true },
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.status).toBe('completed')
|
||||
expect(state?.stepsById['analyze_characters']?.status).toBe('completed')
|
||||
expect(state?.stepsById['analyze_characters']?.textOutput).toBe('partial-tail')
|
||||
})
|
||||
|
||||
it('moves activeStepId to the latest step when no step is running', () => {
|
||||
const runId = 'run-5'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
text: 'a',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-2',
|
||||
stepTitle: 'step 2',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
text: 'b',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.activeStepId).toBe('step-2')
|
||||
})
|
||||
|
||||
it('marks step as blocked when blockedBy is present', () => {
|
||||
const runId = 'run-6'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-b',
|
||||
stepTitle: 'B',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
blockedBy: ['step-a'],
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['step-b']?.status).toBe('blocked')
|
||||
expect(state?.stepsById['step-b']?.blockedBy).toEqual(['step-a'])
|
||||
})
|
||||
|
||||
it('auto-follows active step when selected step was not manually pinned', () => {
|
||||
const runId = 'run-7'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-2',
|
||||
stepTitle: 'step 2',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.activeStepId).toBe('step-2')
|
||||
expect(state?.selectedStepId).toBe('step-2')
|
||||
})
|
||||
|
||||
it('moves think-tagged text chunks into reasoning output', () => {
|
||||
const runId = 'run-8'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_locations',
|
||||
stepTitle: 'locations',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.200Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_locations',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: '<think>先分析文本</think>{"locations":[]}',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['analyze_locations']?.reasoningOutput).toBe('先分析文本')
|
||||
expect(state?.stepsById['analyze_locations']?.textOutput).toBe('{"locations":[]}')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user