feat(workflow): show durable run records in Chat

This commit is contained in:
pku-xht
2026-08-10 18:37:30 +08:00
parent d2321d210a
commit 8de6df19d9
73 changed files with 3013 additions and 227 deletions

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { WorkflowRunId, type WorkflowRunId as WorkflowRunIdType } from '@deepseek-ai/dsh-workflow/types'
import * as ToolWorkflowInvariant from '../src/invariant.ts'
import type {} from '../src/types.ts'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(ToolWorkflowInvariant)
return ctx
}
describe('durable workflow-record invariants', () => {
it('accepts interleaved complete runs and an unfinished continuous prefix', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('workflow-record-valid'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const first = WorkflowRunId('first')
const second = WorkflowRunId('second')
const third = WorkflowRunId('third')
session.append('tool-workflow/run-start', { runId: first, name: 'first' })
session.append('tool-workflow/run-start', { runId: second, name: 'second' })
session.append('tool-workflow/agent-start', {
runId: second, seq: 1, label: '', phase: '', childId: SessionId('child'),
})
session.append('tool-workflow/run-end', { runId: first, stopReason: 'completed' })
session.append('tool-workflow/agent-end', { runId: second, seq: 1, outcome: 'cancelled' })
session.append('tool-workflow/run-end', { runId: second, stopReason: 'cancelled' })
session.append('tool-workflow/run-start', { runId: third, name: 'third' })
session.append('tool-workflow/agent-start', {
runId: third, seq: 1, label: 'failed', childId: SessionId('failed-child'),
})
session.append('tool-workflow/agent-end', { runId: third, seq: 1, outcome: 'failed' })
session.append('tool-workflow/run-end', { runId: third, stopReason: 'error' })
session.append('tool-workflow/run-start', { runId: WorkflowRunId('prefix'), name: 'prefix' })
expect(() => session.append('tool-workflow/agent-start', {
runId: WorkflowRunId('prefix'), seq: 1, label: 'open', childId: SessionId('open-child'),
})).not.toThrow()
})
it('rejects a malformed candidate before commit and keeps the fold reusable', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('workflow-record-invalid'))
const runId = WorkflowRunId('run')
session.append('tool-workflow/run-start', { runId, name: 'run' })
const before = session.seq
expect(() => session.append('tool-workflow/agent-end', {
runId, seq: 1, outcome: 'completed',
})).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-tool-workflow',
}))
expect(session.seq).toBe(before)
expect(() => session.append('tool-workflow/run-end', {
runId, stopReason: 'completed',
})).not.toThrow()
})
type Mutation = (session: Session, runId: WorkflowRunIdType) => void
const appendRaw = (session: Session, type: string, data: unknown): void => {
const append = session.append.bind(session) as (eventType: string, eventData: unknown) => unknown
append(type, data)
}
const invalidCases: readonly [string, Mutation, RegExp][] = [
['null event data', (session) => {
appendRaw(session, 'tool-workflow/run-start', null)
}, /data must be a JSON object/],
['primitive event data', (session) => {
appendRaw(session, 'tool-workflow/run-start', 1)
}, /data must be a JSON object/],
['array event data', (session) => {
appendRaw(session, 'tool-workflow/run-start', [])
}, /data must be a JSON object/],
['numeric run id', (session) => {
session.append('tool-workflow/agent-start', {
runId: 1 as never, seq: 1, label: 'bad', childId: SessionId('child'),
})
}, /runId must be a non-empty string/],
['empty run id', (session) => {
session.append('tool-workflow/agent-start', {
runId: WorkflowRunId(''), seq: 1, label: 'bad', childId: SessionId('child'),
})
}, /runId must be a non-empty string/],
['empty run name', (session) => {
session.append('tool-workflow/run-start', { runId: WorkflowRunId('empty-name'), name: '' })
}, /name must be a non-empty string/],
['non-string run name', (session) => {
session.append('tool-workflow/run-start', { runId: WorkflowRunId('bad-name'), name: 1 as never })
}, /name must be a non-empty string/],
['duplicate run', (session, runId) => {
session.append('tool-workflow/run-start', { runId, name: 'again' })
}, /repeats run/],
['missing run', (session) => {
session.append('tool-workflow/agent-start', {
runId: WorkflowRunId('missing'), seq: 1, label: 'bad', childId: SessionId('child'),
})
}, /no matching tool-workflow\/run-start/],
['non-positive member seq', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 0, label: 'bad', childId: SessionId('child'),
})
}, /positive safe integer/],
['non-integer member seq', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1.5, label: 'bad', childId: SessionId('child'),
})
}, /positive safe integer/],
['non-string member label', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 1 as never, childId: SessionId('child'),
})
}, /label must be a string/],
['non-string member phase', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'bad', phase: 1 as never, childId: SessionId('child'),
})
}, /phase must be a string/],
['empty child id', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'bad', childId: SessionId(''),
})
}, /childId must be a non-empty string/],
['duplicate member start', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'one', childId: SessionId('child'),
})
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'two', childId: SessionId('child-2'),
})
}, /repeats member seq/],
['invalid member outcome', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'one', childId: SessionId('child'),
})
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'unknown' as never })
}, /outcome unknown is invalid/],
['duplicate member end', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'one', childId: SessionId('child'),
})
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
}, /repeats member seq/],
['run end with an open member', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'open', childId: SessionId('child'),
})
session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
}, /leaves member seq 1 open/],
['invalid run stop reason', (session, runId) => {
session.append('tool-workflow/run-end', { runId, stopReason: 'unknown' as never })
}, /stopReason unknown is invalid/],
['event after run end', (session, runId) => {
session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'late', childId: SessionId('child'),
})
}, /appears after/],
['unknown workflow event', (session, runId) => {
appendRaw(session, 'tool-workflow/unknown', { runId })
}, /unknown tool-workflow event type/],
]
it.each(invalidCases)('rejects %s', async (_name, mutate, pattern) => {
const ctx = await setup()
const session = ctx.sessions.create()
const runId = WorkflowRunId('run')
session.append('tool-workflow/run-start', { runId, name: 'run' })
expect(() => { mutate(session, runId) }).toThrow(pattern)
})
it('validates existing cold history while allowing an unfinished prefix', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const valid = ctx.sessions.create(SessionId('workflow-record-cold-valid'))
valid.append('tool-workflow/run-start', { runId: WorkflowRunId('valid'), name: 'valid' })
valid.append('tool-workflow/agent-start', {
runId: WorkflowRunId('valid'), seq: 1, label: 'open', childId: SessionId('child'),
})
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(ToolWorkflowInvariant)).resolves.toBeDefined()
const brokenCtx = new Context()
await brokenCtx.plugin(SessionStore)
const broken = brokenCtx.sessions.create(SessionId('workflow-record-cold-invalid'))
broken.append('tool-workflow/run-start', { runId: WorkflowRunId('broken'), name: 'broken' })
broken.append('tool-workflow/run-end', { runId: WorkflowRunId('broken'), stopReason: 'completed' })
broken.append('tool-workflow/agent-start', {
runId: WorkflowRunId('broken'), seq: 1, label: 'late', childId: SessionId('late'),
})
await brokenCtx.plugin(InvariantService, { enabled: true })
await expect(brokenCtx.plugin(ToolWorkflowInvariant)).rejects.toThrow(/appears after/)
})
})

View File

@@ -3,15 +3,18 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import type {
WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
WorkflowRunId as WorkflowRunIdType, WorkflowStartRequest,
} from '@deepseek-ai/dsh-workflow'
import { CallId } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as toolWorkflow from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
@@ -20,30 +23,62 @@ class StubEngine extends WorkflowService {
requests: WorkflowStartRequest[] = []
cancels: string[] = []
disposed = 0
disposeBarrier: Promise<void> | undefined
settle!: (result: WorkflowResult) => void
readonly settlements = new Map<WorkflowRunIdType, (result: WorkflowResult) => void>()
startError: Error | undefined
emitMemberDuringStart = false
start(request: WorkflowStartRequest): WorkflowRun {
if (this.startError) throw this.startError
this.requests.push(request)
const id = WorkflowRunId(`run-${this.requests.length}`)
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
this.settlements.set(id, this.settle)
if (this.emitMemberDuringStart) {
const info = { id, meta: request.meta }
const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') }
this.emitWorkflowEvent('workflow/agent-start', info, member)
this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' })
}
request.signal?.addEventListener('abort', () => {
this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
}, { once: true })
return {
id: WorkflowRunId('run-1'),
meta: { name: 'stub-flow', description: 'd' },
id,
meta: request.meta,
result,
cancel: (reason?: string) => {
this.cancels.push(reason ?? 'cancelled')
this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
},
dispose: () => {
dispose: async () => {
this.disposed += 1
return Promise.resolve()
await this.disposeBarrier
this.settlements.delete(id)
},
}
}
settleRun(id: WorkflowRunIdType, result: WorkflowResult): void {
const settle = this.settlements.get(id)
if (settle === undefined) throw new Error(`unknown stub workflow ${id}`)
settle(result)
}
agentStart(id: WorkflowRunIdType, agent: WorkflowAgentInfo): void {
this.emitWorkflowEvent('workflow/agent-start', {
id,
meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
}, agent)
}
agentEnd(id: WorkflowRunIdType, agent: WorkflowAgentEndInfo): void {
this.emitWorkflowEvent('workflow/agent-end', {
id,
meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
}, agent)
}
}
async function setup(config?: { toolName?: string; maxResultChars?: number }) {
@@ -53,14 +88,19 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
await ctx.plugin(StubEngine)
await ctx.plugin(toolWorkflow, config ?? {})
const engine = ctx.workflows as StubEngine
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
return { ctx, engine, parent }
const session = Session.create(SessionId('caller'))
const parent = { id: session.id, options: {}, session } as unknown as Agent
return { ctx, engine, parent, session }
}
const SCRIPT = 'return 1'
const META = { name: 'audit', description: 'd' }
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
function execute(ctx: Context, args: unknown, extra?: {
agent?: Agent
signal?: AbortSignal
parent?: ToolExecutionToken
}): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
@@ -68,6 +108,7 @@ function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?:
arguments: args,
...extra?.agent ? { agent: extra.agent } : {},
...extra?.signal ? { signal: extra.signal } : {},
...extra?.parent ? { parent: extra.parent } : {},
})
}
@@ -90,6 +131,167 @@ describe('dsh-tool-workflow', () => {
expect(engine.disposed).toBe(1)
})
it('records one top-level run and its members in the calling Session after cleanup', async () => {
const { ctx, engine, parent, session } = await setup()
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
const runId = WorkflowRunId('run-1')
engine.agentStart(runId, {
seq: 1,
label: '',
phase: '',
childId: SessionId('child-1'),
})
engine.agentEnd(runId, {
seq: 1,
label: '',
phase: '',
childId: SessionId('child-1'),
outcome: 'completed',
})
engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 })
expect((await pending).isError).toBe(false)
expect(engine.disposed).toBe(1)
expect(session.events.map(event => [event.type, event.data])).toEqual([
['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }],
['tool-workflow/agent-start', {
runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1',
}],
['tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }],
['tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }],
])
})
it('writes run-end only after run disposal reaches quiescence', async () => {
const { ctx, engine, parent, session } = await setup()
const barrier = Promise.withResolvers<undefined>()
engine.disposeBarrier = barrier.promise
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), {
value: null, stopReason: 'completed', agentsStarted: 0,
})
await vi.waitFor(() => { expect(engine.disposed).toBe(1) })
expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start'])
barrier.resolve(undefined)
expect((await pending).isError).toBe(false)
expect(session.events.map(event => event.type)).toEqual([
'tool-workflow/run-start', 'tool-workflow/run-end',
])
})
it('records zero-member and concurrent runs independently', async () => {
const { ctx, engine, parent, session } = await setup()
const first = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'first' } }, { agent: parent })
const second = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'second' } }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
const secondId = WorkflowRunId('run-2')
engine.agentStart(secondId, {
seq: 1, label: 'member', childId: SessionId('child-2'),
})
engine.agentEnd(secondId, {
seq: 1, label: 'member', childId: SessionId('child-2'), outcome: 'failed',
})
engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 })
expect((await first).isError).toBe(false)
expect((await second).isError).toBe(true)
expect(session.events.filter(event => event.type === 'tool-workflow/agent-start'))
.toHaveLength(1)
expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data))
.toEqual([
{ runId: 'run-1', stopReason: 'completed' },
{ runId: 'run-2', stopReason: 'error' },
])
})
it('buffers synchronous member events until start returns the run identity', async () => {
const { ctx, engine, parent, session } = await setup()
engine.emitMemberDuringStart = true
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), {
value: null, stopReason: 'completed', agentsStarted: 1,
})
expect((await pending).isError).toBe(false)
expect(session.events.map(event => event.type)).toEqual([
'tool-workflow/run-start',
'tool-workflow/agent-start',
'tool-workflow/agent-end',
'tool-workflow/run-end',
])
})
it('does not record nested transport executions', async () => {
const { ctx, engine, parent, session } = await setup()
const pending = execute(ctx, { script: SCRIPT, meta: META }, {
agent: parent,
parent: Symbol('outer') as ToolExecutionToken,
})
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
expect((await pending).isError).toBe(false)
expect(session.events).toEqual([])
})
it.each([
'tool-workflow/run-start',
'tool-workflow/agent-start',
'tool-workflow/agent-end',
'tool-workflow/run-end',
] as const)('isolates a first append failure at %s and preserves a valid prefix', async (failedType) => {
const { ctx, engine, parent, session } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const append = session.append.bind(session)
session.append = ((type: Parameters<Session['append']>[0], data: never) => {
if (type === failedType) throw new Error(`injected ${failedType} failure`)
return append(type, data)
}) as Session['append']
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
const runId = WorkflowRunId('run-1')
engine.agentStart(runId, {
seq: 1, label: 'member', childId: SessionId('child-1'),
})
engine.agentEnd(runId, {
seq: 1, label: 'member', childId: SessionId('child-1'), outcome: 'completed',
})
engine.settleRun(runId, { value: null, stopReason: 'completed', agentsStarted: 1 })
expect((await pending).isError).toBe(false)
expect(engine.disposed).toBe(1)
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain(failedType)
const types = session.events.map(event => event.type)
const expectedPrefixes = {
'tool-workflow/run-start': [],
'tool-workflow/agent-start': ['tool-workflow/run-start'],
'tool-workflow/agent-end': ['tool-workflow/run-start', 'tool-workflow/agent-start'],
'tool-workflow/run-end': [
'tool-workflow/run-start', 'tool-workflow/agent-start', 'tool-workflow/agent-end',
],
} as const
expect(types).toEqual(expectedPrefixes[failedType])
})
it('contains an append failure whose thrown value cannot be rendered', async () => {
const { ctx, engine, parent, session } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
session.append = () => {
throw { toString: () => { throw new Error('coercion trap') } }
}
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), {
value: null, stopReason: 'completed', agentsStarted: 0,
})
expect((await pending).isError).toBe(false)
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('[unrenderable thrown value]')
})
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
@@ -251,7 +453,8 @@ describe('dsh-tool-workflow', () => {
})
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
const session = Session.create(SessionId('caller'))
const parent = { id: session.id, options: {}, session } as unknown as Agent
const controller = new AbortController()
const pending = execute(ctx, {
script: 'await new Promise(() => {})\nreturn 1',