test: close per-file coverage gaps opened by the message-machine refactor

Downstream packages lost the tests that exercised their agent-loop-facing
edges when the loop was rewritten. Restore 100% per-file coverage with
behavior tests through public seams: llm-retry config validation and
cancellation races, goal replay drift/staleness/teardown edges, plan-mode
disposed-flush, workspace-context empty-change commits, api-proxy
synchronous send failures, acp-snapshot spill-path extraction and refresh
write-back, ACP injection-triggered turns, cli-demo and tui inbox
lifecycle edges, and agent-loop retry/settlement/lifecycle branches.
The only source changes are narrowly-justified v8 ignore annotations on
invariant guards and one redundant-guard removal (workspace-context).
This commit is contained in:
_Kerman
2026-07-26 11:49:19 +08:00
parent 8432c68b6a
commit af3311e1f6
16 changed files with 940 additions and 5 deletions

View File

@@ -14,6 +14,17 @@ import {
} from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Test-only log-only event driven through appendOutOfBand below. */
'test/acp-out-of-band': { note: string }
}
interface OutOfBandSessionEventMap {
'test/acp-out-of-band': true
}
}
/** Boilerplate: initialize + create one session, returning its id. */
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities })
@@ -342,6 +353,43 @@ describe('acp bridge — turn outcomes', () => {
expect(text).toContain('real answer')
})
it('an out-of-band injection TURN while the prompt is queued neither captures nor settles it', async () => {
// Unlike the idle inject above (which appends context without a turn), a
// log-only out-of-band append on a closed log opens a real synthetic
// injection-triggered turn. Its turn/start must NOT capture inflight.turn
// (only message-triggered turns own the prompt) and its turn/end must not
// settle the prompt — the prompt settles on its OWN later message turn.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let appended: Promise<unknown> | undefined
const sessions = harness.ctx.sessions
harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && appended === undefined) {
// Fires synchronously inside followup(), after the bridge installed the
// in-flight slot but before the prompt's own turn starts; the synthetic
// turn/start + turn/end land in that window.
appended = sessions.appendOutOfBand(
agent.session,
'test/acp-out-of-band',
{ note: 'log-only' },
{ kind: 'injection', source: { kind: 'plugin', plugin: 'test' } },
)
}
})
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(res.stopReason).toBe('end_turn')
await appended
// The synthetic injection turn precedes the prompt's own message turn.
const triggers = agent.session.events.flatMap(e => e.type === 'turn/start' ? [e.data.trigger.kind] : [])
expect(triggers).toEqual(['injection', 'message'])
const text = harness.updates
.filter(u => u.sessionUpdate === 'agent_message_chunk')
.map(u => (u.content.type === 'text' ? u.content.text : ''))
.join('')
expect(text).toContain('real answer')
})
it('rejects a second prompt while one is in flight', async () => {
harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const sessionId = await newSession(harness)

View File

@@ -1431,6 +1431,32 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
expect(result.terminal.output).not.toContain('queued')
// A cancellation discards queued steering: the badge clears without drains.
submitSteering('third')
submitSteering('fourth')
await tick()
expect(result.terminal.output).toContain('2 queued')
const discarded = result.agent.steeredIds.splice(0).map(id => ({
id, content: [{ type: 'text' as const, text: 'discarded' }], source: { kind: 'user' as const },
}))
// Another agent's dequeue/discard, and ones naming no pending id, leave
// the badge alone.
result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!)
result.ctx.emit('agent/inbox/dequeue', result.agent, {
id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
})
result.ctx.emit('agent/inbox/discard', other, discarded)
result.ctx.emit('agent/inbox/discard', result.agent, [
{ id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } },
])
await tick()
expect(result.terminal.output).toContain('2 queued')
result.terminal.output = ''
result.ctx.emit('agent/inbox/discard', result.agent, discarded)
await tick()
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
expect(result.terminal.output).not.toContain('queued')
await dispose(result)
})
@@ -1811,6 +1837,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
// /reload without a Loader in the context degrades to a warning.
expect(result.terminal.output).toContain('/reload needs the cordis Loader')
expect(result.exit).toHaveBeenCalledWith(0)
// The exit above left the TUI disposed (the mocked runtime.exit returns):
// a message submitted now is refused instead of reaching the agent. The
// refusal notice lands in the transcript, but the stopped UI no longer
// paints, so assert the refusal through the agent surface.
const sentBefore = result.agent.sent.length
const steeredBefore = result.agent.steered.length
result.terminal.send('after shutdown')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toHaveLength(sentBefore)
expect(result.agent.steered).toHaveLength(steeredBefore)
await result.controller.dispose()
await result.ctx.fiber.dispose()
@@ -2148,6 +2186,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).not.toContain('secret full snapshot payload')
const invalidCards: [JsonValue, string][] = [
['plain-string-source', 'invalid-shape'],
[{ kind: 'other' }, 'invalid-kind'],
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],