refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -58,12 +58,8 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
return { ctx, hooks }
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
return agent.whenIdle()
}
function events(agent: Agent): SessionEvent[] {
@@ -85,7 +81,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
}
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
@@ -100,10 +96,9 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
agent.followup([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent)
// The prompt was blocked: model never called, turn ended rejected.
// The prompt was blocked before the model and before a turn opened.
expect(adapter.requests).toHaveLength(0)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('rejected')
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
// The hook ran and was recorded.
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true)
expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true)

View File

@@ -46,8 +46,8 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
return agent.whenIdle()
}
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
@@ -316,8 +316,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
@@ -497,8 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {